Skip to content

Commit 1a02af6

Browse files
committed
feat(spec): declare the closed build-progress PHASE vocabulary the agent loop emits during post-apply verification
The `data-build-progress` frame has shipped as prose only: `AIToolContext.onProgress` documents the channel and its example carries a `phase`, but no declaration ever said which phases exist. The consumer filled that gap by guessing — objectui's `extractBuildProgress` coerces any value it does not recognise to `structure`, which renders a "still building" spinner, so the 111 seconds a build turn spends being verified after it finished read as the wrong phase rather than an unknown one. Declare the vocabulary where both ends can read it: - `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` — a CLOSED enum whose members were measured, not designed. `structure`/`data`/`done` are the consumer's own declared union; `verify` is the post-apply window, corroborated here by the `verify_build` tool `service-ai-studio` actually registers. Per-member provenance is recorded in the source. - `BuildProgressFrameSchema` — the frame's floor: a required `phase` plus an optional verification hop counter and tool name. Deliberately loose, because the panel fields the consumer already reads ride the same frame and are its to shape. - `BUILD_PROGRESS_FRAME_TYPE` — the one literal both ends select on. The docblock names producer (the cloud agent loop — not the tool, whose `onProgress` handle dies when it returns) and consumer (the objectui chat panel). Where in the loop the frames are emitted, and what copy each phase gets, stay with their owners. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb6b2c3 commit 1a02af6

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pin for the build-progress PHASE vocabulary (cloud#2172 ruling A).
5+
*
6+
* What is actually at stake here is not "does zod reject a bad string" — it is
7+
* that this vocabulary is CLOSED and that its refusal is LOUD. objectui#7388
8+
* measured the cost of the alternative: the consumer's reader coerces any
9+
* value it does not recognise to `'structure'`, so a phase nobody declared
10+
* renders as a "still building" spinner for the 111 seconds the build spends
11+
* being verified after it finished. A refusal that merely throws leaves the
12+
* next author guessing; a refusal that names the accepted set tells them.
13+
*
14+
* So the refusal leg asserts the ENVELOPE, not the throw: zod 4 answers an
15+
* out-of-vocabulary enum value with `invalid_value` and carries the full
16+
* accepted set on the issue. ⛔ A bare `expect(...).toThrow()` here would stay
17+
* green against a schema that had degraded to a bare `z.string()` with a
18+
* refinement, which is exactly the degradation worth catching.
19+
*
20+
* The membership leg is the closed-enum pin. Each member was measured against
21+
* a real end of the channel (provenance is recorded per member on
22+
* `BUILD_PROGRESS_PHASES` in the source), so this array moving is a claim that
23+
* someone re-measured — not an edit to wave through.
24+
*/
25+
26+
import { describe, it, expect } from 'vitest';
27+
28+
import {
29+
BUILD_PROGRESS_PHASES,
30+
BUILD_PROGRESS_FRAME_TYPE,
31+
BuildProgressPhaseSchema,
32+
BuildProgressFrameSchema,
33+
type BuildProgressPhase,
34+
} from './build-progress.zod';
35+
36+
/**
37+
* The phases objectui's reader discriminates TODAY
38+
* (`packages/plugin-chatbot/src/mapMessages.ts` `extractBuildProgress`, and the
39+
* `ChatBuildProgress['phase']` union in `ChatbotEnhanced.tsx`). Declaring
40+
* `verify` must not disturb the inherited three — this is the control that has
41+
* to keep moving, not a restatement of the vocabulary under test.
42+
*/
43+
const PHASES_THE_CONSUMER_ALREADY_RENDERS = ['structure', 'data', 'done'] as const;
44+
45+
describe('BUILD_PROGRESS_PHASES (closed membership)', () => {
46+
it('is exactly the measured vocabulary, in lifecycle order', () => {
47+
expect(BUILD_PROGRESS_PHASES).toEqual(['structure', 'data', 'verify', 'done']);
48+
});
49+
50+
it('still contains every phase the objectui reader already discriminates', () => {
51+
for (const phase of PHASES_THE_CONSUMER_ALREADY_RENDERS) {
52+
expect(BUILD_PROGRESS_PHASES).toContain(phase);
53+
}
54+
});
55+
56+
it('names the post-apply verification phase the card exists to declare', () => {
57+
expect(BUILD_PROGRESS_PHASES).toContain('verify');
58+
});
59+
});
60+
61+
describe('BuildProgressPhaseSchema', () => {
62+
it('parses every declared phase', () => {
63+
for (const phase of BUILD_PROGRESS_PHASES) {
64+
const result = BuildProgressPhaseSchema.safeParse(phase);
65+
expect(result.success).toBe(true);
66+
expect(result.success && result.data).toBe(phase);
67+
}
68+
});
69+
70+
it('REFUSES an undeclared phase, and the refusal names the accepted set', () => {
71+
const result = BuildProgressPhaseSchema.safeParse('rebuilding');
72+
73+
expect(result.success).toBe(false);
74+
if (result.success) throw new Error('unreachable: an undeclared phase parsed');
75+
76+
const [issue] = result.error.issues;
77+
expect(result.error.issues).toHaveLength(1);
78+
expect(issue.code).toBe('invalid_value');
79+
// The loud half: the author is told what WAS allowed, not merely that they
80+
// were wrong. Compared against the exported vocabulary so the two cannot
81+
// drift apart silently.
82+
expect((issue as { values?: readonly unknown[] }).values).toEqual([...BUILD_PROGRESS_PHASES]);
83+
});
84+
85+
it('REFUSES the neighbouring vocabulary of the sibling blueprint-progress frame', () => {
86+
// `data-blueprint-progress` is a DIFFERENT channel with its own phases
87+
// (`designing` / `done`). Folding its vocabulary in here would be the
88+
// "one enum for two frames" mistake; `designing` must not parse.
89+
expect(BuildProgressPhaseSchema.safeParse('designing').success).toBe(false);
90+
});
91+
92+
it('REFUSES a non-string', () => {
93+
expect(BuildProgressPhaseSchema.safeParse(3).success).toBe(false);
94+
expect(BuildProgressPhaseSchema.safeParse(undefined).success).toBe(false);
95+
});
96+
});
97+
98+
describe('BUILD_PROGRESS_FRAME_TYPE', () => {
99+
it('is the literal both ends select on', () => {
100+
// objectui selects parts with `p.type === 'data-build-progress'`; the
101+
// `data-` prefix is what makes it a custom data part at all.
102+
expect(BUILD_PROGRESS_FRAME_TYPE).toBe('data-build-progress');
103+
expect(BUILD_PROGRESS_FRAME_TYPE.startsWith('data-')).toBe(true);
104+
});
105+
});
106+
107+
describe('BuildProgressFrameSchema', () => {
108+
it('parses a frame carrying only the required phase', () => {
109+
const result = BuildProgressFrameSchema.safeParse({ phase: 'verify' });
110+
expect(result.success).toBe(true);
111+
expect(result.success && result.data.phase).toBe('verify');
112+
expect(result.success && result.data.hop).toBeUndefined();
113+
expect(result.success && result.data.tool).toBeUndefined();
114+
});
115+
116+
it('parses a frame carrying both optional fields', () => {
117+
const result = BuildProgressFrameSchema.safeParse({
118+
phase: 'verify',
119+
hop: 4,
120+
tool: 'verify_build',
121+
});
122+
expect(result.success).toBe(true);
123+
expect(result.success && result.data).toMatchObject({
124+
phase: 'verify',
125+
hop: 4,
126+
tool: 'verify_build',
127+
});
128+
});
129+
130+
it('parses a frame carrying each optional field on its own', () => {
131+
expect(BuildProgressFrameSchema.safeParse({ phase: 'data', hop: 0 }).success).toBe(true);
132+
expect(BuildProgressFrameSchema.safeParse({ phase: 'data', tool: 'create_seed' }).success).toBe(true);
133+
});
134+
135+
it('is a FLOOR: the panel fields objectui already reads survive the parse', () => {
136+
// Regression guard for "tighten it to .strict()". Every key below is read
137+
// by `extractBuildProgress` off this same frame today; refusing or
138+
// stripping them would break the shipping consumer.
139+
const shipping = {
140+
phase: 'structure' as const,
141+
appLabel: 'CRM',
142+
items: [{ type: 'object', name: 'contacts' }],
143+
done: 1,
144+
total: 4,
145+
seq: 12,
146+
};
147+
148+
const result = BuildProgressFrameSchema.safeParse(shipping);
149+
expect(result.success).toBe(true);
150+
expect(result.success && result.data).toMatchObject(shipping);
151+
});
152+
153+
it('REFUSES an undeclared phase, locating it at `phase`', () => {
154+
const result = BuildProgressFrameSchema.safeParse({ phase: 'rebuilding', hop: 1 });
155+
156+
expect(result.success).toBe(false);
157+
if (result.success) throw new Error('unreachable: a frame with an undeclared phase parsed');
158+
159+
const [issue] = result.error.issues;
160+
expect(issue.code).toBe('invalid_value');
161+
expect(issue.path).toEqual(['phase']);
162+
});
163+
164+
it('REFUSES a frame with no phase at all', () => {
165+
const result = BuildProgressFrameSchema.safeParse({ hop: 2, tool: 'verify_build' });
166+
expect(result.success).toBe(false);
167+
expect(result.success === false && result.error.issues[0].path).toEqual(['phase']);
168+
});
169+
170+
it('REFUSES a malformed hop and an empty tool name', () => {
171+
expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: -1 }).success).toBe(false);
172+
expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: 1.5 }).success).toBe(false);
173+
expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', tool: '' }).success).toBe(false);
174+
});
175+
});
176+
177+
describe('BuildProgressPhase (type)', () => {
178+
it('admits every declared member and is assignable from the array', () => {
179+
const everyPhase: BuildProgressPhase[] = [...BUILD_PROGRESS_PHASES];
180+
expect(everyPhase).toHaveLength(BUILD_PROGRESS_PHASES.length);
181+
});
182+
});
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { z } from 'zod';
4+
import { lazySchema } from '../shared/lazy-schema';
5+
6+
/**
7+
* Build-progress PHASE vocabulary for the `data-build-progress` stream frame
8+
* (cloud#2172 ruling A).
9+
*
10+
* ## Producer
11+
*
12+
* The cloud AI-studio **agent loop** — deliberately not the tool it just ran.
13+
* A build turn applies its change through `apply_blueprint` / `apply_edit` and
14+
* then keeps working: the loop spends a further POST-APPLY VERIFICATION window
15+
* re-reading and re-seeding what it wrote (measured on cloud#1838: 111 seconds
16+
* and 9 tool calls *after* `apply_blueprint` returned, one of them the
17+
* registered `verify_build` tool — see `PLATFORM_TOOLS_BY_PACKAGE` in
18+
* `../system/constants/platform-tool-names`). A tool's own `ctx.onProgress`
19+
* handle dies when the tool returns, so a frame emitted in that window can only
20+
* come from the loop. WHERE in the loop it is emitted is cloud#2172's decision,
21+
* not this module's; what such a frame may SAY is declared here.
22+
*
23+
* ## Consumer
24+
*
25+
* The objectui chat panel — `extractBuildProgress` in
26+
* `packages/plugin-chatbot/src/mapMessages.ts`, which lifts the reconciled part
27+
* onto `ChatBuildProgress` for the build panel in `ChatbotEnhanced.tsx`. That
28+
* reader recognises a fixed set and coerces everything else to `'structure'`,
29+
* which renders a "still building" spinner. So an UNDECLARED phase is not
30+
* merely unlabelled — it reads as the wrong phase, and the user watches a
31+
* build that finished two minutes ago still claim to be building
32+
* (objectui#7388). The panel's per-phase COPY is objectui's to choose; this
33+
* module fixes only the set of values it must be able to tell apart.
34+
*
35+
* ## Channel
36+
*
37+
* Unchanged: the `data-`-prefixed custom part described on
38+
* `AIToolContext.onProgress` in `../contracts/ai-service`, reconciled in place
39+
* under a stable part id. This module adds the vocabulary that prose has always
40+
* assumed and never declared; it moves no transport and renames nothing.
41+
*/
42+
43+
/**
44+
* The closed phase vocabulary, in emission-lifecycle order.
45+
*
46+
* Membership was MEASURED against the two ends of the channel, not designed —
47+
* every member below is one a real producer emits or a real consumer already
48+
* discriminates:
49+
*
50+
* - `structure`, `data`, `done` — the consumer's own declared union,
51+
* `ChatBuildProgress['phase']` at `packages/plugin-chatbot/src/ChatbotEnhanced.tsx:163`
52+
* in objectui, and the set its reader discriminates at
53+
* `packages/plugin-chatbot/src/mapMessages.ts:744`
54+
* (`d.phase === 'data' || d.phase === 'done' ? d.phase : 'structure'`).
55+
* `structure` doubles as that reader's coercion default.
56+
* - `verify` — the post-apply verification window objectui#7388 asks the panel
57+
* to be able to name, and the reason cloud#2172 ruled this vocabulary into
58+
* the spec. Corroborated in this repo by the `verify_build` tool that
59+
* `service-ai-studio` actually registers
60+
* (`../system/constants/platform-tool-names`).
61+
*
62+
* Order is the order a build turn passes through these states; it is NOT a
63+
* scale. ⛔ Consumers compare phases by VALUE — never by index, and never by
64+
* assuming every phase occurs (a turn that applies no seed data never reports
65+
* `data`, and `apply_edit` turns need not report `structure`).
66+
*
67+
* Exported as an array as well as a schema so a consumer can build an
68+
* exhaustive per-phase label map without forcing the lazy schema to
69+
* construct just to read `.options`.
70+
*/
71+
export const BUILD_PROGRESS_PHASES = ['structure', 'data', 'verify', 'done'] as const;
72+
73+
/**
74+
* The phase a `data-build-progress` frame is reporting.
75+
*
76+
* Closed on purpose: an unrecognised value is REFUSED here, loudly, at the
77+
* seam that can still say which value was wrong. The alternative is what
78+
* objectui#7388 measured — a silent coercion to a neighbouring phase, which
79+
* costs the user a correct-looking label describing something that already
80+
* finished.
81+
*/
82+
export const BuildProgressPhaseSchema = lazySchema(() => z.enum(BUILD_PROGRESS_PHASES));
83+
84+
/**
85+
* The stream part name this vocabulary rides, as a Vercel UI-message-stream
86+
* custom data-part name. Emitters pass it as `ctx.onProgress({ type, id, data })`
87+
* and the objectui reader selects parts by exactly this string, so it is the
88+
* one literal both ends must agree on.
89+
*/
90+
export const BUILD_PROGRESS_FRAME_TYPE = 'data-build-progress';
91+
92+
/**
93+
* The `data` payload of a `data-build-progress` frame — a FLOOR, not a
94+
* ceiling.
95+
*
96+
* Declared minimal on purpose, and deliberately **not** strict. The same frame
97+
* already carries the objectui build panel's own presentation fields
98+
* (`appLabel`, `items`, `done`, `total`, `seq` — read in `extractBuildProgress`),
99+
* which are the consumer's to shape; a strict schema here would refuse every
100+
* frame shipping today and would claim ownership of fields this package does
101+
* not define. What this schema asserts is the part any consumer may rely on:
102+
* a frame says which phase it is in, and may say which verification hop it is
103+
* on and which tool that hop is running.
104+
*/
105+
export const BuildProgressFrameSchema = lazySchema(() => z.looseObject({
106+
/** Which phase of the build turn this frame reports. */
107+
phase: BuildProgressPhaseSchema.describe('Build-turn phase this frame reports'),
108+
/**
109+
* Which hop of the post-apply verification loop this frame is on — the
110+
* counter behind the "9 tool calls" cloud#1838 measured, so the panel can
111+
* show motion through a window that is otherwise a single flat phase.
112+
*
113+
* Typed as a non-negative integer rather than pinned to a base: whether the
114+
* loop counts its first hop as 0 or 1 is part of the emitter's placement,
115+
* which cloud#2172 owns and this card does not decide. ⛔ Consumers render it
116+
* as progress, never as an index into anything here.
117+
*/
118+
hop: z.number().int().nonnegative().optional().describe('Post-apply verification hop this frame reports'),
119+
/**
120+
* Name of the tool the current hop is running, e.g. `verify_build`.
121+
*
122+
* A free string, not a closed set: the runtime's executable tool set is
123+
* registered at boot and legitimately includes plugin-contributed names that
124+
* `PLATFORM_PROVIDED_TOOL_NAMES` cannot know about
125+
* (`../system/constants/platform-tool-names`). Consumers treat it as a label.
126+
*/
127+
tool: z.string().min(1).optional().describe('Name of the tool the current hop is running'),
128+
}));
129+
130+
/** A phase from the closed {@link BUILD_PROGRESS_PHASES} vocabulary. */
131+
export type BuildProgressPhase = z.infer<typeof BuildProgressPhaseSchema>;
132+
133+
/** The `data` payload of a `data-build-progress` frame. */
134+
export type BuildProgressFrame = z.input<typeof BuildProgressFrameSchema>;

‎packages/spec/src/ai/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
* - Embedding — embedding model + vector store references
2020
* - Usage — token accounting + per-call cost
2121
* - MCP — references and bindings to external MCP servers
22+
* - Build Progress — phase vocabulary for the agent loop's `data-build-progress` frames
2223
*/
2324

2425
export * from './agent.zod';
@@ -35,6 +36,7 @@ export * from './mcp.zod';
3536
export * from './knowledge-source.zod';
3637
export * from './knowledge-document.zod';
3738
export * from './solution-blueprint.zod';
39+
export * from './build-progress.zod';
3840

3941
// [#12414] entry-nameability: these factories' return types expand to mention
4042
// `/data`'s `FilterCondition` and `/automation`'s `StateNodeConfig` — both

0 commit comments

Comments
 (0)