diff --git a/.changeset/build-progress-phase-vocabulary.md b/.changeset/build-progress-phase-vocabulary.md new file mode 100644 index 00000000000..8f98826549d --- /dev/null +++ b/.changeset/build-progress-phase-vocabulary.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': minor +--- + +**Declare the build-progress PHASE vocabulary on `@objectstack/spec/ai`.** + +The `data-build-progress` stream frame has shipped as prose only: `AIToolContext.onProgress` +documents the channel and its example carries a `phase`, but nothing ever declared which +phases exist. Consumers filled that gap by guessing, and a guess here is not merely +unlabelled — the objectui chat panel coerces any value it does not recognise to `structure`, +which renders a "still building" spinner, so a build turn that has finished and moved on to +verifying itself keeps claiming to be building. + +New exports (additive; nothing removed or renamed): + +- `BUILD_PROGRESS_PHASES` / `BuildProgressPhaseSchema` / `BuildProgressPhase` — the CLOSED + phase vocabulary: `structure`, `data`, `verify`, `done`, in lifecycle order. An + out-of-vocabulary value is refused, and the refusal names the accepted set. +- `BuildProgressFrameSchema` / `BuildProgressFrame` — the frame's FLOOR: a required `phase` + plus an optional `hop` (which post-apply verification hop) and `tool` (the tool that hop is + running). Deliberately loose, not strict: the presentation fields the chat panel already + reads ride the same frame and belong to it, so a strict schema here would refuse every + frame shipping today. +- `BUILD_PROGRESS_FRAME_TYPE` — `'data-build-progress'`, the one literal both ends select on. + +Producers emit these frames from the agent loop rather than from the applying tool: a tool's +`ctx.onProgress` handle dies when the tool returns, and the verification window opens after +it does. Consumers should compare phases by value and treat every phase as optional — a turn +that seeds no sample data never reports `data`. + +Clause-②: yes (widening) diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index ef079cef641..1c924a95192 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -108,7 +108,7 @@ Runtime environment, logging, jobs, caching, and observability. | **[Translation](/docs/references/system/translation)** | `translation.zod.ts` | Translation | i18n support | | **[Worker](/docs/references/system/worker)** | `worker.zod.ts` | Worker | Background workers | -## AI Protocol (11 of 11 schemas) +## AI Protocol (11 of 12 schemas) AI/ML capabilities - agents, skills, tools, MCP exposure, RAG, and cost tracking. diff --git a/content/docs/references/ai/build-progress.mdx b/content/docs/references/ai/build-progress.mdx new file mode 100644 index 00000000000..039020e025b --- /dev/null +++ b/content/docs/references/ai/build-progress.mdx @@ -0,0 +1,83 @@ +--- +title: Build Progress +description: Build Progress protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Build-progress PHASE vocabulary for the `data-build-progress` stream frame +(cloud#2172 ruling A). + +## Producer + +The cloud AI-studio **agent loop** — deliberately not the tool it just ran. +A build turn applies its change through `apply_blueprint` / `apply_edit` and +then keeps working: the loop spends a further POST-APPLY VERIFICATION window +re-reading and re-seeding what it wrote (measured on cloud#1838: 111 seconds +and 9 tool calls *after* `apply_blueprint` returned, one of them the +registered `verify_build` tool — see `PLATFORM_TOOLS_BY_PACKAGE` in +`../system/constants/platform-tool-names`). A tool's own `ctx.onProgress` +handle dies when the tool returns, so a frame emitted in that window can only +come from the loop. WHERE in the loop it is emitted is cloud#2172's decision, +not this module's; what such a frame may SAY is declared here. + +## Consumer + +The objectui chat panel — `extractBuildProgress` in +`packages/plugin-chatbot/src/mapMessages.ts`, which lifts the reconciled part +onto `ChatBuildProgress` for the build panel in `ChatbotEnhanced.tsx`. That +reader recognises a fixed set and coerces everything else to `'structure'`, +which renders a "still building" spinner. So an UNDECLARED phase is not +merely unlabelled — it reads as the wrong phase, and the user watches a +build that finished two minutes ago still claim to be building +(objectui#7388). The panel's per-phase COPY is objectui's to choose; this +module fixes only the set of values it must be able to tell apart. + +## Channel + +Unchanged: the `data-`-prefixed custom part described on +`AIToolContext.onProgress` in `../contracts/ai-service`, reconciled in place +under a stable part id. This module adds the vocabulary that prose has always +assumed and never declared; it moves no transport and renames nothing. + + +**Source:** `packages/spec/src/ai/build-progress.zod.ts` + + +## TypeScript Usage + +```typescript +import { BuildProgressFrameSchema, BuildProgressPhaseSchema } from '@objectstack/spec/ai'; +import type { BuildProgressFrame, BuildProgressPhase } from '@objectstack/spec/ai'; + +// Validate data +const result = BuildProgressFrameSchema.parse(data); +``` + +--- + +## BuildProgressFrame + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **phase** | `Enum<'structure' \| 'data' \| 'verify' \| 'done'>` | ✅ | Build-turn phase this frame reports | +| **hop** | `integer` | optional | Post-apply verification hop this frame reports | +| **tool** | `string` | optional | Name of the tool the current hop is running | + + +--- + +## BuildProgressPhase + +### Allowed Values + +* `structure` +* `data` +* `verify` +* `done` + + +--- + diff --git a/content/docs/references/ai/index.mdx b/content/docs/references/ai/index.mdx index a0d0157560d..8fb0c1b7041 100644 --- a/content/docs/references/ai/index.mdx +++ b/content/docs/references/ai/index.mdx @@ -9,6 +9,7 @@ This section contains all protocol schemas for the ai layer of ObjectStack. + diff --git a/content/docs/references/ai/meta.json b/content/docs/references/ai/meta.json index 46db5a19698..25ab80c64e5 100644 --- a/content/docs/references/ai/meta.json +++ b/content/docs/references/ai/meta.json @@ -14,6 +14,8 @@ "---Models & Runtime---", "conversation", "model-registry", - "usage" + "usage", + "---More---", + "build-progress" ] } \ No newline at end of file diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index b401bd9b17f..e913a27704a 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1525 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1527 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -19,7 +19,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | -| [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | +| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 31 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 74 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 173 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,19 +33,20 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 33 | 272 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 156 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **193** | **1525** | 14 protocol modules | +| **Total** | **194** | **1527** | 14 protocol modules | --- ## AI Protocol -**Source:** `packages/spec/src/ai/` · **Import:** `@objectstack/spec/ai` · **11 pages, 66 schemas** +**Source:** `packages/spec/src/ai/` · **Import:** `@objectstack/spec/ai` · **12 pages, 68 schemas** Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | File | Schemas | | :--- | :--- | | [`agent.zod.ts`](/docs/references/ai/agent) | `AIModelConfig`, `Agent`, `StructuredOutputConfig`, `StructuredOutputFormat`, `TransformPipelineStep` | +| [`build-progress.zod.ts`](/docs/references/ai/build-progress) | `BuildProgressFrame`, `BuildProgressPhase` | | [`conversation.zod.ts`](/docs/references/ai/conversation) | `CodeContent`, `ConversationAnalytics`, `ConversationContext`, `ConversationMessage`, `ConversationSession`, `ConversationSummary`, `FileContent`, `FunctionCall`, `ImageContent`, `MessageContent`, `MessageContentType`, `MessagePruningEvent`, `MessageRole`, `TextContent`, `TokenBudgetConfig`, `TokenBudgetStrategy`, `TokenUsageStats`, `ToolCall` | | [`embedding.zod.ts`](/docs/references/ai/embedding) | `EmbeddingModel`, `VectorStore`, `VectorStoreProvider` | | [`knowledge-document.zod.ts`](/docs/references/ai/knowledge-document) | `KnowledgeChunk`, `KnowledgeDocument`, `KnowledgeHit` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index 10b21e9c004..f81ac8b9742 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -256,7 +256,7 @@ directory rather than per file. | Dir | Sites | |---|---| -| `ai/` | 77 | +| `ai/` | 78 | | `api/` | 451 | | `identity/` | 32 | | `integration/` | 8 | diff --git a/packages/spec/api-surface/ai.json b/packages/spec/api-surface/ai.json index 9d7c970b3dc..453b8c8a442 100644 --- a/packages/spec/api-surface/ai.json +++ b/packages/spec/api-surface/ai.json @@ -8,6 +8,8 @@ "Agent (type)", "AgentParsed (type)", "AgentSchema (const)", + "BUILD_PROGRESS_FRAME_TYPE (const)", + "BUILD_PROGRESS_PHASES (const)", "BlueprintApp (type)", "BlueprintAppParsed (type)", "BlueprintAppSchema (const)", @@ -31,6 +33,10 @@ "BlueprintViewSchema (const)", "BlueprintWidgetCondition (type)", "BlueprintWidgetConditionSchema (const)", + "BuildProgressFrame (type)", + "BuildProgressFrameSchema (const)", + "BuildProgressPhase (type)", + "BuildProgressPhaseSchema (const)", "CodeContentSchema (const)", "ConversationAnalytics (type)", "ConversationAnalyticsParsed (type)", diff --git a/packages/spec/authorable-surface/ai.json b/packages/spec/authorable-surface/ai.json index a2f7ca17a4f..35c3ebc34b8 100644 --- a/packages/spec/authorable-surface/ai.json +++ b/packages/spec/authorable-surface/ai.json @@ -84,6 +84,9 @@ "ai/BlueprintWidgetCondition:field", "ai/BlueprintWidgetCondition:op", "ai/BlueprintWidgetCondition:value", + "ai/BuildProgressFrame:hop", + "ai/BuildProgressFrame:phase", + "ai/BuildProgressFrame:tool", "ai/CodeContent:language", "ai/CodeContent:metadata", "ai/CodeContent:text", diff --git a/packages/spec/declaration-map/ai.json b/packages/spec/declaration-map/ai.json index 4f6d260852d..f047617c2f7 100644 --- a/packages/spec/declaration-map/ai.json +++ b/packages/spec/declaration-map/ai.json @@ -27,6 +27,10 @@ "BlueprintViewSchema": "ai/BlueprintView", "BlueprintWidgetCondition": "ai/BlueprintWidgetCondition", "BlueprintWidgetConditionSchema": "ai/BlueprintWidgetCondition", + "BuildProgressFrame": "ai/BuildProgressFrame", + "BuildProgressFrameSchema": "ai/BuildProgressFrame", + "BuildProgressPhase": "ai/BuildProgressPhase", + "BuildProgressPhaseSchema": "ai/BuildProgressPhase", "CodeContentSchema": "ai/CodeContent", "ConversationAnalytics": "ai/ConversationAnalytics", "ConversationAnalyticsSchema": "ai/ConversationAnalytics", diff --git a/packages/spec/export-origins/ai.json b/packages/spec/export-origins/ai.json index 1cf29b92e70..13b6fab762a 100644 --- a/packages/spec/export-origins/ai.json +++ b/packages/spec/export-origins/ai.json @@ -8,6 +8,8 @@ "Agent": "src/ai/agent.zod.ts#Agent (type)", "AgentParsed": "src/ai/agent.zod.ts#AgentParsed (type)", "AgentSchema": "src/ai/agent.zod.ts#AgentSchema (const)", + "BUILD_PROGRESS_FRAME_TYPE": "src/ai/build-progress.zod.ts#BUILD_PROGRESS_FRAME_TYPE (const)", + "BUILD_PROGRESS_PHASES": "src/ai/build-progress.zod.ts#BUILD_PROGRESS_PHASES (const)", "BlueprintApp": "src/ai/solution-blueprint.zod.ts#BlueprintApp (type)", "BlueprintAppParsed": "src/ai/solution-blueprint.zod.ts#BlueprintAppParsed (type)", "BlueprintAppSchema": "src/ai/solution-blueprint.zod.ts#BlueprintAppSchema (const)", @@ -31,6 +33,10 @@ "BlueprintViewSchema": "src/ai/solution-blueprint.zod.ts#BlueprintViewSchema (const)", "BlueprintWidgetCondition": "src/ai/solution-blueprint.zod.ts#BlueprintWidgetCondition (type)", "BlueprintWidgetConditionSchema": "src/ai/solution-blueprint.zod.ts#BlueprintWidgetConditionSchema (const)", + "BuildProgressFrame": "src/ai/build-progress.zod.ts#BuildProgressFrame (type)", + "BuildProgressFrameSchema": "src/ai/build-progress.zod.ts#BuildProgressFrameSchema (const)", + "BuildProgressPhase": "src/ai/build-progress.zod.ts#BuildProgressPhase (type)", + "BuildProgressPhaseSchema": "src/ai/build-progress.zod.ts#BuildProgressPhaseSchema (const)", "CodeContentSchema": "src/ai/conversation.zod.ts#CodeContentSchema (const)", "ConversationAnalytics": "src/ai/conversation.zod.ts#ConversationAnalytics (type)", "ConversationAnalyticsParsed": "src/ai/conversation.zod.ts#ConversationAnalyticsParsed (type)", diff --git a/packages/spec/json-schema.manifest/ai.json b/packages/spec/json-schema.manifest/ai.json index 1ab2704cdb4..e76b41a930e 100644 --- a/packages/spec/json-schema.manifest/ai.json +++ b/packages/spec/json-schema.manifest/ai.json @@ -15,6 +15,8 @@ "ai/BlueprintSummaryOperations", "ai/BlueprintView", "ai/BlueprintWidgetCondition", + "ai/BuildProgressFrame", + "ai/BuildProgressPhase", "ai/CodeContent", "ai/ConversationAnalytics", "ai/ConversationContext", diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 141b7166c6a..2318f1e994a 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (200 schemas) +## 3. Schema Inventory by Domain (201 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -92,7 +92,7 @@ here is verifiable from the installed package. | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 14 | Flow, Approval, BPMN Interop, Control Flow, State Machine, Webhook, Schedule Organization | | shared | 15 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | -| ai | 11 | Agent, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | +| ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | | marketplace | 4 | Marketplace, Package, Package Version, Template Manifest | | security | 4 | Permission, RLS, Sharing, Explain | diff --git a/packages/spec/src/ai/build-progress.test.ts b/packages/spec/src/ai/build-progress.test.ts new file mode 100644 index 00000000000..3f48b90477b --- /dev/null +++ b/packages/spec/src/ai/build-progress.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for the build-progress PHASE vocabulary (cloud#2172 ruling A). + * + * What is actually at stake here is not "does zod reject a bad string" — it is + * that this vocabulary is CLOSED and that its refusal is LOUD. objectui#7388 + * measured the cost of the alternative: the consumer's reader coerces any + * value it does not recognise to `'structure'`, so a phase nobody declared + * renders as a "still building" spinner for the 111 seconds the build spends + * being verified after it finished. A refusal that merely throws leaves the + * next author guessing; a refusal that names the accepted set tells them. + * + * So the refusal leg asserts the ENVELOPE, not the throw: zod 4 answers an + * out-of-vocabulary enum value with `invalid_value` and carries the full + * accepted set on the issue. ⛔ A bare `expect(...).toThrow()` here would stay + * green against a schema that had degraded to a bare `z.string()` with a + * refinement, which is exactly the degradation worth catching. + * + * The membership leg is the closed-enum pin. Each member was measured against + * a real end of the channel (provenance is recorded per member on + * `BUILD_PROGRESS_PHASES` in the source), so this array moving is a claim that + * someone re-measured — not an edit to wave through. + */ + +import { describe, it, expect } from 'vitest'; + +import { + BUILD_PROGRESS_PHASES, + BUILD_PROGRESS_FRAME_TYPE, + BuildProgressPhaseSchema, + BuildProgressFrameSchema, + type BuildProgressPhase, +} from './build-progress.zod'; + +/** + * The phases objectui's reader discriminates TODAY + * (`packages/plugin-chatbot/src/mapMessages.ts` `extractBuildProgress`, and the + * `ChatBuildProgress['phase']` union in `ChatbotEnhanced.tsx`). Declaring + * `verify` must not disturb the inherited three — this is the control that has + * to keep moving, not a restatement of the vocabulary under test. + */ +const PHASES_THE_CONSUMER_ALREADY_RENDERS = ['structure', 'data', 'done'] as const; + +describe('BUILD_PROGRESS_PHASES (closed membership)', () => { + it('is exactly the measured vocabulary, in lifecycle order', () => { + expect(BUILD_PROGRESS_PHASES).toEqual(['structure', 'data', 'verify', 'done']); + }); + + it('still contains every phase the objectui reader already discriminates', () => { + for (const phase of PHASES_THE_CONSUMER_ALREADY_RENDERS) { + expect(BUILD_PROGRESS_PHASES).toContain(phase); + } + }); + + it('names the post-apply verification phase the card exists to declare', () => { + expect(BUILD_PROGRESS_PHASES).toContain('verify'); + }); +}); + +describe('BuildProgressPhaseSchema', () => { + it('parses every declared phase', () => { + for (const phase of BUILD_PROGRESS_PHASES) { + const result = BuildProgressPhaseSchema.safeParse(phase); + expect(result.success).toBe(true); + expect(result.success && result.data).toBe(phase); + } + }); + + it('REFUSES an undeclared phase, and the refusal names the accepted set', () => { + const result = BuildProgressPhaseSchema.safeParse('rebuilding'); + + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable: an undeclared phase parsed'); + + const [issue] = result.error.issues; + expect(result.error.issues).toHaveLength(1); + expect(issue.code).toBe('invalid_value'); + // The loud half: the author is told what WAS allowed, not merely that they + // were wrong. Compared against the exported vocabulary so the two cannot + // drift apart silently. + expect((issue as { values?: readonly unknown[] }).values).toEqual([...BUILD_PROGRESS_PHASES]); + }); + + it('REFUSES the neighbouring vocabulary of the sibling blueprint-progress frame', () => { + // `data-blueprint-progress` is a DIFFERENT channel with its own phases + // (`designing` / `done`). Folding its vocabulary in here would be the + // "one enum for two frames" mistake; `designing` must not parse. + expect(BuildProgressPhaseSchema.safeParse('designing').success).toBe(false); + }); + + it('REFUSES a non-string', () => { + expect(BuildProgressPhaseSchema.safeParse(3).success).toBe(false); + expect(BuildProgressPhaseSchema.safeParse(undefined).success).toBe(false); + }); +}); + +describe('BUILD_PROGRESS_FRAME_TYPE', () => { + it('is the literal both ends select on', () => { + // objectui selects parts with `p.type === 'data-build-progress'`; the + // `data-` prefix is what makes it a custom data part at all. + expect(BUILD_PROGRESS_FRAME_TYPE).toBe('data-build-progress'); + expect(BUILD_PROGRESS_FRAME_TYPE.startsWith('data-')).toBe(true); + }); +}); + +describe('BuildProgressFrameSchema', () => { + it('parses a frame carrying only the required phase', () => { + const result = BuildProgressFrameSchema.safeParse({ phase: 'verify' }); + expect(result.success).toBe(true); + expect(result.success && result.data.phase).toBe('verify'); + expect(result.success && result.data.hop).toBeUndefined(); + expect(result.success && result.data.tool).toBeUndefined(); + }); + + it('parses a frame carrying both optional fields', () => { + const result = BuildProgressFrameSchema.safeParse({ + phase: 'verify', + hop: 4, + tool: 'verify_build', + }); + expect(result.success).toBe(true); + expect(result.success && result.data).toMatchObject({ + phase: 'verify', + hop: 4, + tool: 'verify_build', + }); + }); + + it('parses a frame carrying each optional field on its own', () => { + expect(BuildProgressFrameSchema.safeParse({ phase: 'data', hop: 0 }).success).toBe(true); + expect(BuildProgressFrameSchema.safeParse({ phase: 'data', tool: 'create_seed' }).success).toBe(true); + }); + + it('is a FLOOR: the panel fields objectui already reads survive the parse', () => { + // Regression guard for "tighten it to .strict()". Every key below is read + // by `extractBuildProgress` off this same frame today; refusing or + // stripping them would break the shipping consumer. + const shipping = { + phase: 'structure' as const, + appLabel: 'CRM', + items: [{ type: 'object', name: 'contacts' }], + done: 1, + total: 4, + seq: 12, + }; + + const result = BuildProgressFrameSchema.safeParse(shipping); + expect(result.success).toBe(true); + expect(result.success && result.data).toMatchObject(shipping); + }); + + it('REFUSES an undeclared phase, locating it at `phase`', () => { + const result = BuildProgressFrameSchema.safeParse({ phase: 'rebuilding', hop: 1 }); + + expect(result.success).toBe(false); + if (result.success) throw new Error('unreachable: a frame with an undeclared phase parsed'); + + const [issue] = result.error.issues; + expect(issue.code).toBe('invalid_value'); + expect(issue.path).toEqual(['phase']); + }); + + it('REFUSES a frame with no phase at all', () => { + const result = BuildProgressFrameSchema.safeParse({ hop: 2, tool: 'verify_build' }); + expect(result.success).toBe(false); + expect(result.success === false && result.error.issues[0].path).toEqual(['phase']); + }); + + it('REFUSES a malformed hop and an empty tool name', () => { + expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: -1 }).success).toBe(false); + expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', hop: 1.5 }).success).toBe(false); + expect(BuildProgressFrameSchema.safeParse({ phase: 'verify', tool: '' }).success).toBe(false); + }); +}); + +describe('BuildProgressPhase (type)', () => { + it('admits every declared member and is assignable from the array', () => { + const everyPhase: BuildProgressPhase[] = [...BUILD_PROGRESS_PHASES]; + expect(everyPhase).toHaveLength(BUILD_PROGRESS_PHASES.length); + }); +}); diff --git a/packages/spec/src/ai/build-progress.zod.ts b/packages/spec/src/ai/build-progress.zod.ts new file mode 100644 index 00000000000..53034bc672c --- /dev/null +++ b/packages/spec/src/ai/build-progress.zod.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +/** + * Build-progress PHASE vocabulary for the `data-build-progress` stream frame + * (cloud#2172 ruling A). + * + * ## Producer + * + * The cloud AI-studio **agent loop** — deliberately not the tool it just ran. + * A build turn applies its change through `apply_blueprint` / `apply_edit` and + * then keeps working: the loop spends a further POST-APPLY VERIFICATION window + * re-reading and re-seeding what it wrote (measured on cloud#1838: 111 seconds + * and 9 tool calls *after* `apply_blueprint` returned, one of them the + * registered `verify_build` tool — see `PLATFORM_TOOLS_BY_PACKAGE` in + * `../system/constants/platform-tool-names`). A tool's own `ctx.onProgress` + * handle dies when the tool returns, so a frame emitted in that window can only + * come from the loop. WHERE in the loop it is emitted is cloud#2172's decision, + * not this module's; what such a frame may SAY is declared here. + * + * ## Consumer + * + * The objectui chat panel — `extractBuildProgress` in + * `packages/plugin-chatbot/src/mapMessages.ts`, which lifts the reconciled part + * onto `ChatBuildProgress` for the build panel in `ChatbotEnhanced.tsx`. That + * reader recognises a fixed set and coerces everything else to `'structure'`, + * which renders a "still building" spinner. So an UNDECLARED phase is not + * merely unlabelled — it reads as the wrong phase, and the user watches a + * build that finished two minutes ago still claim to be building + * (objectui#7388). The panel's per-phase COPY is objectui's to choose; this + * module fixes only the set of values it must be able to tell apart. + * + * ## Channel + * + * Unchanged: the `data-`-prefixed custom part described on + * `AIToolContext.onProgress` in `../contracts/ai-service`, reconciled in place + * under a stable part id. This module adds the vocabulary that prose has always + * assumed and never declared; it moves no transport and renames nothing. + */ + +/** + * The closed phase vocabulary, in emission-lifecycle order. + * + * Membership was MEASURED against the two ends of the channel, not designed — + * every member below is one a real producer emits or a real consumer already + * discriminates: + * + * - `structure`, `data`, `done` — the consumer's own declared union, + * `ChatBuildProgress['phase']` at `packages/plugin-chatbot/src/ChatbotEnhanced.tsx:163` + * in objectui, and the set its reader discriminates at + * `packages/plugin-chatbot/src/mapMessages.ts:744` + * (`d.phase === 'data' || d.phase === 'done' ? d.phase : 'structure'`). + * `structure` doubles as that reader's coercion default. + * - `verify` — the post-apply verification window objectui#7388 asks the panel + * to be able to name, and the reason cloud#2172 ruled this vocabulary into + * the spec. Corroborated in this repo by the `verify_build` tool that + * `service-ai-studio` actually registers + * (`../system/constants/platform-tool-names`). + * + * Order is the order a build turn passes through these states; it is NOT a + * scale. ⛔ Consumers compare phases by VALUE — never by index, and never by + * assuming every phase occurs (a turn that applies no seed data never reports + * `data`, and `apply_edit` turns need not report `structure`). + * + * Exported as an array as well as a schema so a consumer can build an + * exhaustive per-phase label map without forcing the lazy schema to + * construct just to read `.options`. + */ +export const BUILD_PROGRESS_PHASES = ['structure', 'data', 'verify', 'done'] as const; + +/** + * The phase a `data-build-progress` frame is reporting. + * + * Closed on purpose: an unrecognised value is REFUSED here, loudly, at the + * seam that can still say which value was wrong. The alternative is what + * objectui#7388 measured — a silent coercion to a neighbouring phase, which + * costs the user a correct-looking label describing something that already + * finished. + */ +export const BuildProgressPhaseSchema = lazySchema(() => z.enum(BUILD_PROGRESS_PHASES)); + +/** + * The stream part name this vocabulary rides, as a Vercel UI-message-stream + * custom data-part name. Emitters pass it as `ctx.onProgress({ type, id, data })` + * and the objectui reader selects parts by exactly this string, so it is the + * one literal both ends must agree on. + */ +export const BUILD_PROGRESS_FRAME_TYPE = 'data-build-progress'; + +/** + * The `data` payload of a `data-build-progress` frame — a FLOOR, not a + * ceiling. + * + * Declared minimal on purpose, and deliberately **not** strict. The same frame + * already carries the objectui build panel's own presentation fields + * (`appLabel`, `items`, `done`, `total`, `seq` — read in `extractBuildProgress`), + * which are the consumer's to shape; a strict schema here would refuse every + * frame shipping today and would claim ownership of fields this package does + * not define. What this schema asserts is the part any consumer may rely on: + * a frame says which phase it is in, and may say which verification hop it is + * on and which tool that hop is running. + */ +export const BuildProgressFrameSchema = lazySchema(() => z.looseObject({ + /** Which phase of the build turn this frame reports. */ + phase: BuildProgressPhaseSchema.describe('Build-turn phase this frame reports'), + /** + * Which hop of the post-apply verification loop this frame is on — the + * counter behind the "9 tool calls" cloud#1838 measured, so the panel can + * show motion through a window that is otherwise a single flat phase. + * + * Typed as a non-negative integer rather than pinned to a base: whether the + * loop counts its first hop as 0 or 1 is part of the emitter's placement, + * which cloud#2172 owns and this card does not decide. ⛔ Consumers render it + * as progress, never as an index into anything here. + */ + hop: z.number().int().nonnegative().optional().describe('Post-apply verification hop this frame reports'), + /** + * Name of the tool the current hop is running, e.g. `verify_build`. + * + * A free string, not a closed set: the runtime's executable tool set is + * registered at boot and legitimately includes plugin-contributed names that + * `PLATFORM_PROVIDED_TOOL_NAMES` cannot know about + * (`../system/constants/platform-tool-names`). Consumers treat it as a label. + */ + tool: z.string().min(1).optional().describe('Name of the tool the current hop is running'), +})); + +/** A phase from the closed {@link BUILD_PROGRESS_PHASES} vocabulary. */ +export type BuildProgressPhase = z.input; + +/** The `data` payload of a `data-build-progress` frame. */ +export type BuildProgressFrame = z.input; diff --git a/packages/spec/src/ai/index.ts b/packages/spec/src/ai/index.ts index 55a878bacd2..12f67b2da75 100644 --- a/packages/spec/src/ai/index.ts +++ b/packages/spec/src/ai/index.ts @@ -19,6 +19,7 @@ * - Embedding — embedding model + vector store references * - Usage — token accounting + per-call cost * - MCP — references and bindings to external MCP servers + * - Build Progress — phase vocabulary for the agent loop's `data-build-progress` frames */ export * from './agent.zod'; @@ -35,6 +36,7 @@ export * from './mcp.zod'; export * from './knowledge-source.zod'; export * from './knowledge-document.zod'; export * from './solution-blueprint.zod'; +export * from './build-progress.zod'; // [#12414] entry-nameability: these factories' return types expand to mention // `/data`'s `FilterCondition` and `/automation`'s `StateNodeConfig` — both diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index b8f95f8022d..8ad36018f81 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -271,9 +271,11 @@ import type * as M185 from './shared/epoch.zod.js'; // [#18122] The closed duration vocabulary beside that instant — new module, // next free index (M186 is `automation/schedule-organization.zod.ts`). import type * as M187 from './shared/duration.zod.js'; +// [#18451] The build-progress PHASE vocabulary -- new module, next free index. +import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 785 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1026,6 +1028,18 @@ export type Iso868 = Assert, z.infer< typeof export type Iso873 = Assert, z.infer< typeof M187.DurationMs > >>; export type Iso874 = Assert, z.infer< typeof M187.DurationSeconds > >>; +// ai/build-progress.zod.ts -- the closed build-progress PHASE vocabulary +// (#18451, cloud#2172 ruling A) and the frame that carries it. The enum has +// no default and no transform; the frame is a `z.looseObject` whose three +// members are a bare enum and two plain optionals, so both are the (RISE) +// case. The frame pin is the load-bearing one: it is deliberately a FLOOR +// that passes the consumer's own panel fields through untouched, and the day +// someone gives `phase` or `hop` a `.default()` to 'help' a producer that +// omits it, author state and parsed state part company -- this is the line +// that says so by name. +export type Iso875 = Assert, z.infer< typeof M188.BuildProgressPhaseSchema > >>; +export type Iso876 = Assert, z.infer< typeof M188.BuildProgressFrameSchema > >>; + // shared/value-domain.zod.ts — the ONE standard-domain vocabulary (#14168); // `SpecifierValueDomainSchema` (Iso758) is an alias of it, so both pins hold // or fall together. A `z.enum` has no default or transform, the (RISE) case. @@ -1664,7 +1678,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 783 isomorphic pins', () => { + it('still declares all 785 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2227,7 +2241,18 @@ describe('ADR-0122 type-alias convention', () => { // `startTime: durationMs`-style `.default()` or a `.transform()` that // fills one member from another, this pin is the line that says the alias // has gained a second shape. - expect(pins).toHaveLength(783); + // 783 -> 785 is #18451's build-progress PHASE vocabulary + // (ai/build-progress.zod.ts, new module slot M188): `BuildProgressPhase` + // and `BuildProgressFrame`, the (RISE) case twice. The enum is a bare + // `z.enum` like the `ConnectorActionEffectSchema` pin that first taught + // this count to rise; the frame is a `z.looseObject` of one enum and two + // plain optionals. Neither carries a default or a transform, so no + // `XParsed` is declared and both come here instead. +2 added. + // + // Note the number is the same 783 -> 785 the DURATION block above records, + // arrived at from the same 783 after #16059's retirement took it back down. + // The two entries are different movements that share a pair of endpoints. + expect(pins).toHaveLength(785); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until #6605 nothing read either