diff --git a/.changeset/11988-build-verify-receiver.md b/.changeset/11988-build-verify-receiver.md new file mode 100644 index 0000000000..395fba5c08 --- /dev/null +++ b/.changeset/11988-build-verify-receiver.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-chatbot': minor +'@object-ui/i18n': minor +--- + +The chat build panel reads cloud's post-apply verification loop, and an unknown build phase no longer reads as "Building" (objectui#11988, the receiver objectstack-ai/cloud#2172's ruling A orders). + +After `apply_blueprint` finishes, cloud's agent loop reports its verification hops on a second `data-build-progress` part with the id `build-verify`, beside the build tree. A hop is `{ phase: 'verify', hop, tool }` and the exit is `{ phase: 'done' }`. The receiver used to take the last `data-build-progress` part whatever its id, and turned every phase other than `data` and `done` into `structure`. So the first verification hop replaced the finished tree with "Building your app…". + +- **The tree and the verification part are read apart, by part id.** The `build-verify` part never displaces the tree, whichever order the two parts arrive in. A message that carries only the tree maps and renders exactly as before. +- **The build panel shows a verification line under the tree.** It reads "Checking the change… step N" while a hop runs, with the hop's tool name as its tooltip, and "Checked the change" once the `done` frame arrives. A `build-verify` part on a message that has no build tree is not drawn as a tree. +- **Phases are read against the spec's vocabulary.** The receiver's phase table and frame type are typed by `@objectstack/spec/ai`'s own `BuildProgressPhase` and `BUILD_PROGRESS_FRAME_TYPE`, so a phase the spec adds or drops fails the type check here instead of drifting. The spec's runtime module is not imported, because it would put its whole AI schema module on the console's first load. A phase outside the vocabulary, or a frame without one, is now `unknown` and shows as a warning line ("Unknown build phase") on the tree header or on the verification line. It is no longer coerced to `structure`. +- **Type change.** `ChatBuildProgress.phase`, reached through `ChatMessage['buildProgress']`, widens from `'structure' | 'data' | 'done'` to `'structure' | 'data' | 'verify' | 'done' | 'unknown'`: the spec's phase vocabulary plus `'unknown'`, held equal to the spec's union by a compile-time test. `ChatBuildProgress` also gains an optional `verify` member, `{ phase, hop?, tool? }`. The published typings name no new `@objectstack/spec` symbol, so the package's spec range is unchanged. +- **New language-pack keys, in all ten packs:** `chatbot.build.verifying`, `chatbot.build.verifyStep`, `chatbot.build.verified` and `chatbot.build.unknownPhase`. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 8ddd6bdf9a..b4fddf743a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -3584,6 +3584,10 @@ const ar = { seed: "بيانات تجريبية", }, moreArtifacts: "+{{n}} أخرى", + verifying: "جارٍ التحقق من التغيير…", + verifyStep: "الخطوة {{n}}", + verified: "تم التحقق من التغيير", + unknownPhase: "مرحلة إنشاء غير معروفة", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cf2fe18fee..ea59a49ef3 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -3317,6 +3317,10 @@ const de = { seed: "Beispieldaten", }, moreArtifacts: "+{{n}} weitere", + verifying: "Änderung wird geprüft…", + verifyStep: "Schritt {{n}}", + verified: "Änderung geprüft", + unknownPhase: "Unbekannte Build-Phase", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index fe96bce55c..9caae37a81 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3847,6 +3847,9 @@ const en = { // `building`/`built` interpolate `appFallback` when the // build has no app label, so each pack must keep BOTH // frames in the one case/gender that noun phrase is in. + // `verifying`/`verifyStep`/`verified` are the post-apply + // verification line and `unknownPhase` the warning for a + // phase outside the spec's vocabulary (objectui#11988). // `plan.*` — the "N objects · N views · N dashboards" strip. Plural // FAMILIES: one slot per CLDR category the pack's language // selects (objectui#11432), plus the base key for a call @@ -3935,6 +3938,10 @@ const en = { seed: 'Sample data', }, moreArtifacts: '+{{n}} more', + verifying: 'Checking the change…', + verifyStep: 'step {{n}}', + verified: 'Checked the change', + unknownPhase: 'Unknown build phase', }, }, chatbotError: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c285ef92aa..ebab58f523 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -3382,6 +3382,10 @@ const es = { seed: "Datos de ejemplo", }, moreArtifacts: "+{{n}} más", + verifying: "Comprobando el cambio…", + verifyStep: "paso {{n}}", + verified: "Cambio comprobado", + unknownPhase: "Fase de creación desconocida", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 47bf58095b..45e77ec306 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -3379,6 +3379,10 @@ const fr = { seed: "Données d’exemple", }, moreArtifacts: "+{{n}} autres", + verifying: "Vérification de la modification…", + verifyStep: "étape {{n}}", + verified: "Modification vérifiée", + unknownPhase: "Phase de création inconnue", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index c3f7582d91..eecebbaf54 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -3319,6 +3319,10 @@ const ja = { seed: "サンプルデータ", }, moreArtifacts: "他 {{n}} 件", + verifying: "変更を確認しています…", + verifyStep: "ステップ {{n}}", + verified: "変更を確認しました", + unknownPhase: "不明なビルドフェーズ", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index d53773195e..4e1d9b0051 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -3316,6 +3316,10 @@ const ko = { seed: "샘플 데이터", }, moreArtifacts: "외 {{n}}개", + verifying: "변경 사항 확인 중…", + verifyStep: "{{n}}단계", + verified: "변경 사항 확인 완료", + unknownPhase: "알 수 없는 빌드 단계", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index fcb9a67b3f..fa9f6515a1 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -3377,6 +3377,10 @@ const pt = { seed: "Dados de exemplo", }, moreArtifacts: "+{{n}} mais", + verifying: "Verificando a alteração…", + verifyStep: "etapa {{n}}", + verified: "Alteração verificada", + unknownPhase: "Fase de criação desconhecida", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index f95686fe81..4bb5b5e545 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -3463,6 +3463,10 @@ const ru = { seed: "Демоданные", }, moreArtifacts: "ещё {{n}}", + verifying: "Проверка изменения…", + verifyStep: "шаг {{n}}", + verified: "Изменение проверено", + unknownPhase: "Неизвестный этап сборки", }, }, chatbotError: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 82c47ecb3f..a388f3be88 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3464,6 +3464,10 @@ const zh = { seed: '示例数据', }, moreArtifacts: '+{{n}} 个', + verifying: '正在检查改动…', + verifyStep: '第 {{n}} 步', + verified: '已检查改动', + unknownPhase: '未知的构建阶段', }, }, chatbotError: { diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index f665258883..3d959c6d86 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -157,10 +157,20 @@ export interface ChatChart { series: Array<{ dataKey: string; label?: string }>; } +/** + * A build-progress frame's phase: `@objectstack/spec/ai`'s closed + * `BuildProgressPhase` vocabulary, plus `unknown` for a value outside it, which + * is surfaced as a warning — never as "Building" (objectui#11988). Spelled out + * rather than imported so the published typings need no newer spec floor; + * `buildVerify-11988.test.tsx` asserts at compile time that it equals the + * spec's union, both ways. + */ +type ChatBuildPhase = 'structure' | 'data' | 'verify' | 'done' | 'unknown'; + /** A reconciled snapshot of an in-flight app build (apply_blueprint). */ export interface ChatBuildProgress { /** Coarse phase: drafting structure, generating sample data, or finished. */ - phase: 'structure' | 'data' | 'done'; + phase: ChatBuildPhase; /** Human label for the app being built (for the panel header). */ appLabel?: string; /** Artifacts drafted so far, cumulative. */ @@ -176,6 +186,12 @@ export interface ChatBuildProgress { * heartbeat). Absent from older runtimes. */ seq?: number; + /** + * The post-apply verification loop, read from its own `build-verify` part + * beside the tree (objectui#11988): `verify` while a hop runs, `done` once + * the loop exits. `hop` and `tool` are the spec frame's own fields. + */ + verify?: { phase: ChatBuildPhase; hop?: number; tool?: string }; } /** @@ -4222,13 +4238,17 @@ function BuildProgressPanel({ stalledLabel?: string; offlineLabel?: string; }) { - const { phase, appLabel, items, done, total, seq } = progress; + const { phase, appLabel, items, done, total, seq, verify } = progress; // objectui#7388 — every string this panel OWNS goes through the pack. The // labels it receives as props (`openBuiltAppLabel`, the connection cues, …) // are already localized by the host; these were the island left behind. const { t } = useObjectTranslation(); const groupLabelOf = useBuildGroupLabel(); const isDone = phase === 'done'; + // objectui#11988 — only the phases the tree draws read as "Building"; any + // other value is surfaced as a warning, never coerced into one of them. + const unknownPhase = t('chatbot.build.unknownPhase', { defaultValue: 'Unknown build phase' }); + const isUnknown = !isDone && phase !== 'structure' && phase !== 'data'; // The unnamed-build stand-in is itself a translated noun phrase, so it can be // interpolated into the two header frames the same way a real app label is — // one hole per frame, which is what `check-i18n-call-site-keys` checks. @@ -4258,13 +4278,17 @@ function BuildProgressPanel({
{isDone ? ( + ) : isUnknown ? ( + ) : ( )} {isDone ? t('chatbot.build.built', { app: appName, defaultValue: 'Built {{app}}' }) - : t('chatbot.build.building', { app: appName, defaultValue: 'Building {{app}}…' })} + : isUnknown + ? unknownPhase + : t('chatbot.build.building', { app: appName, defaultValue: 'Building {{app}}…' })} {!isDone && phase === 'data' ? ( @@ -4334,6 +4358,26 @@ function BuildProgressPanel({ ); })} + {verify ? ( + // The verification line advances per hop and closes on `done`. +
+ {verify.phase === 'done' ? ( + + ) : verify.phase === 'verify' ? ( + + ) : ( + + )} + {verify.phase === 'done' + ? t('chatbot.build.verified', { defaultValue: 'Checked the change' }) + : verify.phase === 'verify' + ? t('chatbot.build.verifying', { defaultValue: 'Checking the change…' }) + : unknownPhase} + {verify.phase === 'verify' && verify.hop !== undefined + ? ` ${t('chatbot.build.verifyStep', { n: verify.hop, defaultValue: 'step {{n}}' })}` + : null} +
+ ) : null} {isDone && builtApp && (onDesignBuiltApp || onOpenBuiltApp || onPreviewDraftApp) ? (
{/* ADR-0080 D5 cold-start handoff: Studio is the built app's diff --git a/packages/plugin-chatbot/src/__tests__/buildVerify-11988.test.tsx b/packages/plugin-chatbot/src/__tests__/buildVerify-11988.test.tsx new file mode 100644 index 0000000000..1d218772fd --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/buildVerify-11988.test.tsx @@ -0,0 +1,169 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#11988 — the build-progress RECEIVER reads the post-apply + * verification loop (cloud#2172 ruling A, the wire shape of cloud PR #2721). + * + * The loop reports on its own `data-build-progress` part, id `build-verify`, + * beside the apply_blueprint tree: a hop is `{ phase: 'verify', hop, tool }` + * and the exit is `{ phase: 'done' }`. The receiver used to take the LAST + * `data-build-progress` part whatever its id and coerce any phase but + * `data` / `done` to `structure`, so the first hop replaced the finished tree + * with "Building your app…". + * + * Pinned here, through the real mapper and the real panel: + * - the tree and the verify part are read apart, by part id, in either order; + * - the verify line advances per hop and closes on `done`; + * - control: a message with only the tree is unchanged; + * - a phase outside the spec's vocabulary is surfaced, never "Building". + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { BUILD_PROGRESS_PHASES, type BuildProgressPhase } from '@objectstack/spec/ai'; +import { I18nProvider } from '@object-ui/i18n'; +import { ChatbotEnhanced, type ChatBuildProgress } from '../ChatbotEnhanced'; +import { uiMessageToChatMessage } from '../mapMessages'; + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +const ITEMS = [ + { type: 'object', name: 'customer' }, + { type: 'view', name: 'customer.list' }, + { type: 'app', name: 'crm' }, +]; + +/** The apply_blueprint tree, finished — what the verify loop runs after. */ +const TREE = { + type: 'data-build-progress', + id: 'build-progress', + data: { phase: 'done', appLabel: 'CRM', items: ITEMS, done: 3, total: 3 }, +}; + +function verifyPart(data: Record) { + return { type: 'data-build-progress', id: 'build-verify', data }; +} + +function map(parts: Array>) { + return uiMessageToChatMessage({ id: 'a1', role: 'assistant', parts }); +} + +function renderIn(language: string, parts: Array>) { + return render( + + + , + ); +} + +afterEach(cleanup); + +describe('the published phase union IS the spec vocabulary plus `unknown` (objectui#11988)', () => { + it('is pinned at compile time, in both directions', () => { + // A `tsc` error, not a runtime failure: compiled by this package's + // `tsconfig.test.json`. A phase the spec adds or drops turns it red. + type _TreePhaseIsSpec = Assert, BuildProgressPhase>>; + type _VerifyPhaseIsSpec = Assert< + Equal['phase'], 'unknown'>, BuildProgressPhase> + >; + expect(true).toBe(true); + }); +}); + +describe('the receiver keeps the tree and the build-verify part apart (objectui#11988)', () => { + it('a verify hop never displaces the finished tree', () => { + const out = map([TREE, verifyPart({ phase: 'verify', hop: 1, tool: 'verify_build' })]); + expect(out.buildProgress).toEqual({ + phase: 'done', + appLabel: 'CRM', + items: ITEMS, + done: 3, + total: 3, + verify: { phase: 'verify', hop: 1, tool: 'verify_build' }, + }); + }); + + it('is keyed by part id, not by position: a verify part ahead of the tree changes nothing', () => { + const out = map([verifyPart({ phase: 'verify', hop: 1, tool: 'verify_build' }), TREE]); + expect(out.buildProgress).toMatchObject({ phase: 'done', appLabel: 'CRM', items: ITEMS }); + expect(out.buildProgress?.verify).toEqual({ phase: 'verify', hop: 1, tool: 'verify_build' }); + }); + + it('the exit frame closes the verify state', () => { + const out = map([TREE, verifyPart({ phase: 'done' })]); + expect(out.buildProgress?.verify).toEqual({ phase: 'done' }); + expect(out.buildProgress?.phase).toBe('done'); + }); + + it('control: a message with only the tree maps exactly as before, with no verify state', () => { + const out = map([TREE]); + expect(out.buildProgress).toEqual({ phase: 'done', appLabel: 'CRM', items: ITEMS, done: 3, total: 3 }); + }); + + it('a verify part with no tree beside it is not read as a build tree', () => { + const out = map([verifyPart({ phase: 'verify', hop: 2, tool: 'verify_build' })]); + expect(out.buildProgress).toBeUndefined(); + }); + + it('reads every phase of the spec vocabulary as itself, on the tree and on the verify part', () => { + for (const phase of BUILD_PROGRESS_PHASES) { + const out = map([{ ...TREE, data: { ...TREE.data, phase } }, verifyPart({ phase })]); + expect(out.buildProgress?.phase).toBe(phase); + expect(out.buildProgress?.verify?.phase).toBe(phase); + } + }); + + it('a phase outside the vocabulary is `unknown` on either part, never coerced to `structure`', () => { + const out = map([{ ...TREE, data: { ...TREE.data, phase: 'deploying' } }, verifyPart({ phase: 'rechecking' })]); + expect(out.buildProgress?.phase).toBe('unknown'); + expect(out.buildProgress?.verify?.phase).toBe('unknown'); + }); +}); + +describe('the build panel renders the verify line (objectui#11988)', () => { + it('shows the finished tree AND a verify line that advances per hop', () => { + renderIn('en', [TREE, verifyPart({ phase: 'verify', hop: 1, tool: 'verify_build' })]); + expect(screen.getByText('Built CRM')).toBeInTheDocument(); + expect(screen.getByTestId('build-verify')).toHaveTextContent('Checking the change… step 1'); + expect(screen.getByTestId('build-verify')).toHaveAttribute('title', 'verify_build'); + expect(screen.queryByText(/Building/)).not.toBeInTheDocument(); + cleanup(); + + renderIn('en', [TREE, verifyPart({ phase: 'verify', hop: 2, tool: 'verify_build' })]); + expect(screen.getByTestId('build-verify')).toHaveTextContent('Checking the change… step 2'); + }); + + it('closes the verify line on `done`', () => { + renderIn('en', [TREE, verifyPart({ phase: 'done' })]); + expect(screen.getByText('Built CRM')).toBeInTheDocument(); + expect(screen.getByTestId('build-verify')).toHaveTextContent('Checked the change'); + expect(screen.getByTestId('build-verify')).not.toHaveTextContent(/Checking/); + }); + + it('control: a tree-only message renders the panel unchanged, with no verify line', () => { + renderIn('en', [TREE]); + expect(screen.getByText('Built CRM')).toBeInTheDocument(); + expect(screen.queryByTestId('build-verify')).not.toBeInTheDocument(); + }); + + it('an unknown tree phase is surfaced as a warning, not rendered as "Building"', () => { + renderIn('en', [{ ...TREE, data: { ...TREE.data, phase: 'deploying' } }]); + expect(screen.getByText('Unknown build phase')).toBeInTheDocument(); + expect(screen.queryByText(/Building/)).not.toBeInTheDocument(); + }); + + it('an unknown verify phase is surfaced as a warning on the verify line', () => { + renderIn('en', [TREE, verifyPart({ phase: 'rechecking' })]); + expect(screen.getByTestId('build-verify')).toHaveTextContent('Unknown build phase'); + }); + + it('the verify line is localized through the pack', () => { + renderIn('zh', [TREE, verifyPart({ phase: 'verify', hop: 3, tool: 'verify_build' })]); + expect(screen.getByTestId('build-verify')).toHaveTextContent('正在检查改动… 第 3 步'); + expect(screen.queryByText(/Checking the change/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index f5a64e5ed4..41d3a6113c 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -14,6 +14,7 @@ * apps that drive `useChat` themselves (e.g. Studio, which needs a custom * `prepareSendMessagesRequest` transport). */ +import type { BUILD_PROGRESS_FRAME_TYPE, BuildProgressPhase } from '@objectstack/spec/ai'; import type { ChatMessage, ChatToolInvocation, ChatSource, ChatBuildProgress, ChatBlueprintProgress, ChatChart } from './ChatbotEnhanced'; interface AnyPart { @@ -824,21 +825,76 @@ function extractSources(parts: AnyPart[]): ChatSource[] | undefined { } /** - * Lift the live build progress from the stream's reconciled `data-build-progress` + * The build-progress frame vocabulary of `@objectstack/spec/ai`, read through + * its TYPES (objectui#11988). Both constants are typed by the spec's own + * declarations, so the compiler refuses a value the spec does not declare and, + * for the phases, requires a row for every phase it does: neither can drift + * from the vocabulary. The spec's runtime `BUILD_PROGRESS_PHASES` is not + * imported: its module does not tree-shake, and a value import of it moved the + * console's eager closure about 27 KB gzipped over budget (a one-time reading + * of `pnpm check:eager-closure` against `fff07fbba`, objectui#11988). The + * type-only import is erased from the published typings, so it asks nothing of + * the package's spec floor. `buildVerify-11988.test.tsx` reads the runtime + * array and pins every member. + */ +const BUILD_FRAME_TYPE: typeof BUILD_PROGRESS_FRAME_TYPE = 'data-build-progress'; +const BUILD_PHASES: Record = { structure: true, data: true, verify: true, done: true }; + +/** + * The part id cloud's post-apply verification loop reports on (cloud PR #2721's + * wire shape, cloud#2172 ruling A). It rides the same `data-build-progress` + * type as the apply_blueprint tree but under its own stable id, so the two + * reconcile side by side on one message (objectui#11988). + */ +const BUILD_VERIFY_PART_ID = 'build-verify'; + +/** + * A frame's phase, read against the spec's closed vocabulary. A value outside + * it is `unknown` — never coerced to a neighbouring phase, which is how a + * finished build used to read as "Building" (objectui#7388). + */ +function readBuildPhase(phase: unknown): ChatBuildProgress['phase'] { + return typeof phase === 'string' && Object.prototype.hasOwnProperty.call(BUILD_PHASES, phase) + ? (phase as BuildProgressPhase) + : 'unknown'; +} + +/** The `data` of the LAST `data-build-progress` part on the message that `pick` keeps. */ +function buildFrame(parts: AnyPart[], pick: (id?: string) => boolean): Record | undefined { + const data = parts.filter((p) => p.type === BUILD_FRAME_TYPE && pick(p.id)).pop()?.data; + return data && typeof data === 'object' ? (data as Record) : undefined; +} + +/** + * The post-apply verification state, from the `build-verify` part: the hop + * the loop is on and the tool it runs, until a `done` frame closes it. + */ +function extractBuildVerify(parts: AnyPart[]): ChatBuildProgress['verify'] { + const d = buildFrame(parts, (id) => id === BUILD_VERIFY_PART_ID); + if (!d) return undefined; + return { + phase: readBuildPhase(d.phase), + ...(typeof d.hop === 'number' ? { hop: d.hop } : {}), + ...(typeof d.tool === 'string' ? { tool: d.tool } : {}), + }; +} + +/** + * Lift the live build TREE from the stream's reconciled `data-build-progress` * part (emitted by apply_blueprint via `ctx.onProgress`). With a stable id the - * SDK keeps a single, in-place-updated part, so we just read the latest one. + * SDK keeps a single, in-place-updated part, so we just read the latest one — + * of the parts that are not the `build-verify` part, which never displaces the + * tree (objectui#11988). */ function extractBuildProgress(parts: AnyPart[]): ChatBuildProgress | undefined { - const part = parts.filter((p) => p.type === 'data-build-progress').pop(); - const data = part?.data; - if (!data || typeof data !== 'object') return undefined; - const d = data as Record; + const d = buildFrame(parts, (id) => id !== BUILD_VERIFY_PART_ID); + if (!d) return undefined; const items = Array.isArray(d.items) ? (d.items as Array>) .filter((i) => typeof i?.type === 'string' && typeof i?.name === 'string') .map((i) => ({ type: i.type as string, name: i.name as string })) : []; - const phase = d.phase === 'data' || d.phase === 'done' ? d.phase : 'structure'; + const phase = readBuildPhase(d.phase); return { phase, ...(typeof d.appLabel === 'string' ? { appLabel: d.appLabel } : {}), @@ -961,11 +1017,14 @@ export function uiMessageToChatMessage( // the tool result — so the panel + its open/preview affordances survive a // refresh. Re-derived on every map (incl. useObjectChat's round-trip), so it // cannot be lost the way a one-shot hydration value would. - const buildProgress = + const tree = extractBuildProgress(parts) ?? resolvedTools .map((tool) => buildProgressFromDraftReview(tool.draftReview)) .find((bp): bp is ChatBuildProgress => Boolean(bp)); + // The verification line rides on the tree it verifies (objectui#11988). + const verify = extractBuildVerify(parts); + const buildProgress = tree && verify ? { ...tree, verify } : tree; return { id: (msg.id ?? `msg-${Math.random().toString(36).slice(2, 8)}`) as string, role: (msg.role ?? 'assistant') as ChatMessage['role'],