Skip to content

Commit 9f76037

Browse files
fix(plugin-chatbot,app-shell): keep an assistant answer's chart after a reload (#27396)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8HgycqL368Q1SBpG5BAa6AZ)_ Fixes #25908 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:AI 助手回答里的图表,刷新页面后不再消失;从服务器历史和本地缓存恢复时都会按原位置画回来。 - 为什么改:直播流里的图表是一次性的,服务器不存它;服务器存的是 `visualize_data` 工具结果,里面的 `chart` 字段就是同一份图表描述,之前没人读它。 - 风险与回滚:只影响聊天消息的恢复路径;没有 `chart` 字段的旧结果照旧不画图;回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-chatbot/src/mapMessages.ts`(唯一读 `chart` 的地方与防重复画图规则)、`ui/packages/app-shell/src/hooks/useChatConversation.ts`(缓存写法)、`ui/packages/plugin-chatbot/src/useObjectChat.ts`(API 模式重建)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-chatbot/src/__tests__/mapMessages.persistedChart-25908.test.ts packages/plugin-chatbot/src/__tests__/useObjectChat.persistedChart-25908.test.tsx packages/app-shell/src/console/ai/__tests__/AiChatPage.persistedChart-25908.test.ts`,预期三个文件全绿。 - 需要你做的:无,席位落地。 ## What changes An assistant answer that drew a chart came back as text only after a reload. The live stream draws the chart from a transient `data-chart` part that the server never stores. What the server stores is the `visualize_data` tool result, one JSON string whose `chart` key holds the same descriptor the part carried (#26744, comment 6457382770). Nothing read that key back. - **One reader** (`plugin-chatbot` `mapMessages.ts`, `chartFromToolResult`). It reads `chart` off a `visualize_data` result through the existing `parseResultEnvelope`, and narrows it with the same `chartFromDescriptor` the `data-chart` part goes through, now shared by both. The top-level `rows` (a 20-row narration preview) are never drawn. A result stored before the producer wrote `chart` gives no chart, with no rebuild from its other keys. - **Where it sits.** `extractCharts` reads tool results only for a message that has no `data-chart` part. A live message carries both, so it still draws the chart once. A stored message carries only the result, so each chart takes the offset of its tool part among the text parts. - **Hydration** (`app-shell` `hydratedMessagesToChatMessages`) sets `charts` from `chartsFromParts(message.parts)`. That walk covers both the server's `visualize_data` results and the cache's `data-chart` parts, and the offsets index the same text join the hydration makes `content`. The share page (`SharedRecordPage`) renders these messages directly, so it gets the chart too, with no change to its file. - **API-mode round trip** (`useObjectChat` `aiInitialMessages`). The rebuild used to write one text part and then the tools, so a hydrated `charts` key was dropped. It now writes `chartAnswerParts(content, charts)`: the text cut at each chart's offset, with a `data-chart` part between the runs. The mapper reads that back as the same content and the same charts at the same offsets. - **Cache** (`app-shell` `sanitizeChatMessagesForCache`). It writes the same `chartAnswerParts` in place of the single text part, so a cache-fallback reload keeps the chart and its place. A tool-output arm would not have kept the place, because a tool part carries no position in the cache. - **Where the fix sits, and why.** The order offered two places: the rebuild emits `data-chart` parts, or `mapMessages` re-derives charts from the tool output. This PR takes the first for the round trip and the cache, because the live path already draws from `data-chart` parts and only position-encoded parts keep `textOffset` through a rebuild that joins text. The one reader of `chart` sits in `mapMessages`, and the hydration mapper reaches it through `chartsFromParts`. - **Tool name.** `@objectstack/spec` declares `visualize_data` only as a member of `PLATFORM_TOOLS_BY_PACKAGE['service-ai']`, with no constant of its own. `mapMessages` keeps one literal, `VISUALIZE_DATA_TOOL` (a module export, not a package export), and a test pins it to that registry. - **New exports** (`Clause-②: yes (widening)`, claim 6487631480): `chartsFromParts`, `chartAnswerParts` and the `ChartAnswerPart` type, on `@object-ui/plugin-chatbot` and `@object-ui/plugin-chatbot/core`. app-shell is private and needs to call the one reader, so these exports are how it does. ## Tests - `plugin-chatbot` `mapMessages.persistedChart-25908.test.ts`. A stored result yields the same chart the live part draws (title, series labels, all 30 rows rather than the 20-row preview), at the offset of its tool part. A bare string result is read too. A result without `chart` gives no chart and does not throw, and neither does a non-JSON one. Only `visualize_data` is read, and its name is pinned to the spec registry. A live message with both the part and the result draws one chart. `chartAnswerParts` maps back to the same message, and a chart without an offset follows the text. - `plugin-chatbot` `useObjectChat.persistedChart-25908.test.tsx`. In API mode, a hydrated answer keeps one chart at its offset through the store, and the bubble draws it once, between the two text runs. - `app-shell` `AiChatPage.persistedChart-25908.test.ts`. Server history restores the chart between the text it sat between, and a result without `chart` restores text and tool with no chart. A live answer is cached and restored with its chart in place, and a restored server answer survives being cached again. ## Acceptance notes - The reach has never been measured in a running console, and the producer change (cloud `abffd69`) runs on hosted deploys only (`@objectstack/service-ai` 10.3.0 on npm predates it). The proof here is fixture-level, against the result shape #26744 read on cloud `main` `a32c80c`. - The fixtures use the AI SDK text output wrapper (`{ type: 'text', value }`), the form a string tool result takes, and a bare string. A JSON-wrapped output (`{ type: 'json', value }`) with no `status` key resolves to the wrapper in the shared `parseResultEnvelope`, so it would give no chart. The producer returns a string, so that shape is not reached, and this PR does not change the shared parser. - Local mode (no `api`) keeps dropping hydrated render-only keys in `normalizeMessages`, `charts` and `buildProgress` alike, as before. AiChatPage runs local mode only with no chat backend, which has no stored conversations. ## Verification All readings below were taken on head `e1f1c06a`, branched from `origin/main` `c4c515ad`. The four commits `main` gained since touch none of this PR's files. <details><summary>New pins and ablations</summary> - The three new files: `Test Files 3 passed (3)`, `Tests 14 passed (14)`. - Each ablation goes through `scripts/ablation-replace.mjs`, which confirms the mutation landed, restores the file and reads it back equal to the HEAD blob with an empty `git diff HEAD`. Every leg is a source-level mutation, because `ui/vitest.config.mts` aliases `@object-ui/plugin-chatbot` and its `/core` entry to `src`. - Reader (`chartFromToolResult` returns undefined): 3 tests fail, the two result-reading pins in `mapMessages.persistedChart-25908` and the server-history pin in `AiChatPage.persistedChart-25908`. - API-mode rebuild (`chartAnswerParts` handed no charts in `aiInitialMessages`): 2 tests fail, both in `useObjectChat.persistedChart-25908`. - Cache writer (`sanitizeChatMessagesForCache` handed no charts): 2 tests fail, both cache-fallback pins in `AiChatPage.persistedChart-25908`. </details> <details><summary>Package suites and typecheck</summary> - `pnpm --dir ui exec vitest run packages/plugin-chatbot/`: `Test Files 88 passed (88)`, `Tests 828 passed (828)`. - `pnpm --dir ui exec vitest run packages/app-shell/src/console/ai/ packages/app-shell/src/hooks/ packages/app-shell/src/layout/`: `Test Files 245 passed (245)`, `Tests 1703 passed (1703)`. - The three `SharedRecordPage` test files in `ui/apps/console/src/pages/`: `Test Files 3 passed (3)`, `Tests 19 passed (19)`. - `pnpm --filter @object-ui/plugin-chatbot run type-check && pnpm --filter @object-ui/app-shell run type-check`: exit 0. - `eslint --no-inline-config` on the nine touched ts/tsx files: 0 errors. The warnings that remain sit on lines this PR does not touch. - `pnpm --dir ui run check:new-line-citations`: 0 new citations. </details> <details><summary>Derived gates (dispatch-gates --commands, reconciled with --ran: 35 derived, 35 run, 0 NOT-MEASURED)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` </details> Families CI owns (the required checks) are cited from this PR's check runs at hand-back; they were not re-run locally. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the touched files: 0 errors; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not applicable. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English, apart from the 维护者速读 block the dispatch order asks for. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbYXpAEUq8s7bsevHLW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b223072 commit 9f76037

10 files changed

Lines changed: 540 additions & 34 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
---
4+
5+
fix(plugin-chatbot): an assistant answer's chart is still there after a reload
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The live stream draws a chart from a transient `data-chart` part that the server never stores, so a reloaded answer used to come back as text only. The `visualize_data` tool result is stored, and its `chart` key holds the same descriptor the part carried. The chat now reads it back:
11+
12+
- **Server history.** A stored message with no `data-chart` part takes its charts from its `visualize_data` results, each placed where its tool part sat among the text. The chart has the same title, series labels and rows as the live one. The result's top-level `rows`, a preview cut for the model's narration, are never drawn. A result stored before the producer wrote `chart` gets no chart, and nothing is rebuilt from its other keys.
13+
- **API mode.** `useObjectChat` rebuilds a hydrated message's `charts` into the store as `data-chart` parts between the text runs they sat between, so the chart keeps its place after the round trip. A live message that carries both the part and the result still draws the chart once.
14+
- **New exports** on the package root and `@object-ui/plugin-chatbot/core`: `chartsFromParts(parts)` returns a stored message's charts with their `textOffset`, and `chartAnswerParts(content, charts)` is its inverse (text runs with `data-chart` parts between them), plus the `ChartAnswerPart` type. The console uses both to restore charts from server history and from its message cache.

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
detectAuthoringVerdict,
6969
detectBuiltAppPackage,
7070
buildProgressFromDraftReview,
71+
chartsFromParts,
7172
// The authoring/honest -> runtime message seam (objectui#4399 / PR #4416),
7273
// consumed here one hop up from the plugin's own renderers (objectui#4437).
7374
// See `runtimeMessages` below for why this host calls it exactly once.
@@ -294,12 +295,20 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
294295
}
295296
}
296297

298+
// objectstack#25908 — the answer's charts, read off the `data-chart`
299+
// parts the cache wrote or the `visualize_data` results the server
300+
// persisted, each at the offset into `content` where its part sat. The
301+
// live stream's chart part is never stored, so this is the only way a
302+
// chart survives a reload.
303+
const charts = message.role === 'assistant' ? chartsFromParts(message.parts) : undefined;
304+
297305
return {
298306
id: message.id,
299307
role: message.role,
300308
content,
301309
...(toolInvocations.length > 0 ? { toolInvocations } : {}),
302310
...(buildProgress ? { buildProgress } : {}),
311+
...(charts ? { charts } : {}),
303312
};
304313
});
305314
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#25908 — an assistant answer's chart is still there after a
5+
* reload, from either place a reload restores a conversation from:
6+
*
7+
* - Server history: rows → `toUIMessages` (merges the `visualize_data` result
8+
* onto its call part) → `hydratedMessagesToChatMessages`. The stream's
9+
* `data-chart` part was never stored; the result's `chart` key was.
10+
* - Cache fallback: the live map → `sanitizeChatMessagesForCache` → JSON →
11+
* `hydratedMessagesToChatMessages`.
12+
*
13+
* The reference is the chart the live mapper draws from the streamed part, so
14+
* "restored" means the same title, series labels and every row. The reader and
15+
* the API-mode round trip are pinned in plugin-chatbot's
16+
* `mapMessages.persistedChart-25908.test.ts` and
17+
* `useObjectChat.persistedChart-25908.test.tsx`.
18+
*/
19+
import { describe, expect, it } from 'vitest';
20+
import { uiMessageToChatMessage } from '@object-ui/plugin-chatbot';
21+
import { hydratedMessagesToChatMessages } from '../AiChatPage';
22+
import { sanitizeChatMessagesForCache, toUIMessages } from '../../../hooks/useChatConversation';
23+
24+
const ROWS = Array.from({ length: 30 }, (_, i) => ({ stage: `stage_${i}`, amount: i * 10 }));
25+
26+
const DESCRIPTOR = {
27+
type: 'chart',
28+
chartType: 'bar',
29+
title: 'Pipeline by stage',
30+
data: ROWS,
31+
xAxisKey: 'stage',
32+
series: [{ dataKey: 'amount', label: 'Amount' }],
33+
};
34+
35+
const resultString = (withChart: boolean) =>
36+
JSON.stringify({
37+
rendered: 'chart',
38+
chartType: 'bar',
39+
object: 'opportunity',
40+
dimension: 'stage',
41+
measures: ['amount'],
42+
categories: 30,
43+
rows: ROWS.slice(0, 20),
44+
...(withChart ? { chart: DESCRIPTOR } : {}),
45+
});
46+
47+
const INTRO = 'Here is the pipeline.\n\n';
48+
const AFTER = 'Most of it sits in the late stages.';
49+
50+
/** The chart the live stream draws: the reference a restored chart must equal. */
51+
const LIVE_CHART = uiMessageToChatMessage({
52+
id: 'live',
53+
role: 'assistant',
54+
parts: [{ type: 'data-chart', id: 'chart-0', data: DESCRIPTOR }],
55+
}).charts![0];
56+
57+
/** The stored turn, as the server persists it: the call, then its result row. */
58+
function serverHistory(withChart: boolean) {
59+
return toUIMessages([
60+
{ id: 'u1', role: 'user', content: 'Show me the pipeline' },
61+
{
62+
id: 'a1',
63+
role: 'assistant',
64+
content: [
65+
{ type: 'text', text: INTRO },
66+
{ type: 'tool-call', toolCallId: 'call_1', toolName: 'visualize_data', input: {} },
67+
{ type: 'text', text: AFTER },
68+
],
69+
},
70+
{
71+
id: 't1',
72+
role: 'tool',
73+
content: [
74+
{
75+
type: 'tool-result',
76+
toolCallId: 'call_1',
77+
toolName: 'visualize_data',
78+
output: { type: 'text', value: resultString(withChart) },
79+
},
80+
],
81+
},
82+
]);
83+
}
84+
85+
describe('a reloaded answer keeps its chart (objectstack#25908)', () => {
86+
it('server history: the persisted chart comes back, between the text it sat between', () => {
87+
const [, answer] = hydratedMessagesToChatMessages(serverHistory(true));
88+
expect(answer.content).toBe(`${INTRO}${AFTER}`);
89+
expect(answer.charts).toEqual([{ ...LIVE_CHART, textOffset: INTRO.length }]);
90+
expect(answer.charts?.[0]?.data).toHaveLength(30);
91+
});
92+
93+
it('server history: a result persisted before the chart key gets no chart, and does not throw', () => {
94+
const [, answer] = hydratedMessagesToChatMessages(serverHistory(false));
95+
expect(answer.content).toBe(`${INTRO}${AFTER}`);
96+
expect(answer.charts).toBeUndefined();
97+
expect(answer.toolInvocations?.map((t) => t.toolName)).toEqual(['visualize_data']);
98+
});
99+
100+
it('cache fallback: the live answer is cached and restored with its chart in place', () => {
101+
const live = uiMessageToChatMessage({
102+
id: 'a1',
103+
role: 'assistant',
104+
parts: [
105+
{ type: 'text', text: INTRO },
106+
{ type: 'data-chart', id: 'chart-0', data: DESCRIPTOR },
107+
{
108+
type: 'tool-visualize_data',
109+
toolCallId: 'call_1',
110+
state: 'output-available',
111+
input: {},
112+
output: { type: 'text', value: resultString(true) },
113+
},
114+
{ type: 'text', text: AFTER },
115+
],
116+
});
117+
const cached = JSON.parse(JSON.stringify(sanitizeChatMessagesForCache([live])));
118+
const [restored] = hydratedMessagesToChatMessages(cached);
119+
expect(restored.content).toBe(`${INTRO}${AFTER}`);
120+
expect(restored.charts).toEqual([{ ...LIVE_CHART, textOffset: INTRO.length }]);
121+
});
122+
123+
it('cache fallback: a restored server answer is re-cached without losing the chart', () => {
124+
const [, answer] = hydratedMessagesToChatMessages(serverHistory(true));
125+
const cached = JSON.parse(JSON.stringify(sanitizeChatMessagesForCache([answer])));
126+
const [restored] = hydratedMessagesToChatMessages(cached);
127+
expect(restored.charts).toEqual(answer.charts);
128+
expect(restored.content).toBe(answer.content);
129+
});
130+
});

‎ui/packages/app-shell/src/hooks/useChatConversation.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
1616
// `@object-ui/plugin-chatbot` is already a static member of this package's
1717
// barrel closure (`index.ts` re-exports `AiChatPage`, which imports it), so
1818
// this adds no package edge and no eager bundle weight.
19-
import { detectPendingApproval } from '@object-ui/plugin-chatbot/core';
19+
import { chartAnswerParts, detectPendingApproval } from '@object-ui/plugin-chatbot/core';
2020

2121
/** Minimal UIMessage shape compatible with `@ai-sdk/react`'s `useChat`. */
2222
export interface HydratedUIMessagePart {
@@ -213,6 +213,8 @@ interface CacheableChatMessage {
213213
role: 'user' | 'assistant' | 'system';
214214
content?: string;
215215
toolInvocations?: CacheableChatToolInvocation[];
216+
/** The answer's charts, each at its `textOffset` into `content`. */
217+
charts?: Parameters<typeof chartAnswerParts>[1];
216218
}
217219

218220
/**
@@ -443,10 +445,15 @@ export function sanitizeChatMessagesForCache(
443445
): HydratedUIMessage[] {
444446
return messages
445447
.map<HydratedUIMessage | undefined>((message) => {
446-
const parts: HydratedUIMessagePart[] = [];
447-
if (message.content) {
448-
parts.push({ type: 'text', text: message.content });
449-
}
448+
// objectstack#25908 — an answer's charts are cached as the `data-chart`
449+
// parts the live stream gave, between the text runs they sat between:
450+
// the stream's own chart part is never stored, and a tool part carries
451+
// no position, so this is the one record that keeps both the chart and
452+
// its place. `hydratedMessagesToChatMessages` reads them back.
453+
const parts: HydratedUIMessagePart[] = chartAnswerParts(
454+
message.content ?? '',
455+
message.role === 'assistant' ? message.charts : undefined,
456+
);
450457
if (message.role === 'assistant') {
451458
for (const tool of message.toolInvocations ?? []) {
452459
// Re-serialize the draft/plan affordance into a compact tool `output`.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#25908 — an assistant answer's chart survives a reload.
11+
*
12+
* The live stream draws a chart from a transient `data-chart` part that is
13+
* never stored. What the server stores is the `visualize_data` tool result: one
14+
* JSON string whose `chart` key is the same descriptor the part carried as
15+
* `data`, beside a top-level `rows` preview cut to 20 for the model's
16+
* narration. These pins hold the one reader of that key and the part writer
17+
* that carries a hydrated chart back to the mapper at its offset. The app-shell
18+
* half (server history and the message cache) is pinned in
19+
* `AiChatPage.persistedChart-25908.test.ts`.
20+
*/
21+
import { describe, expect, it } from 'vitest';
22+
import * as specSystem from '@objectstack/spec/system';
23+
import {
24+
VISUALIZE_DATA_TOOL,
25+
chartAnswerParts,
26+
chartsFromParts,
27+
uiMessageToChatMessage,
28+
} from '../mapMessages';
29+
30+
/** 30 categories: more than the 20-row preview, so a chart drawn from `rows` would show. */
31+
const ROWS = Array.from({ length: 30 }, (_, i) => ({ stage: `stage_${i}`, amount: i * 10 }));
32+
33+
/** The descriptor the producer streams as the part's `data` and persists under `chart`. */
34+
const DESCRIPTOR = {
35+
type: 'chart',
36+
chartType: 'bar',
37+
title: 'Pipeline by stage',
38+
data: ROWS,
39+
xAxisKey: 'stage',
40+
series: [{ dataKey: 'amount', label: 'Amount' }],
41+
};
42+
43+
/** The persisted result string: the narration preview AND the full descriptor. */
44+
const RESULT = JSON.stringify({
45+
rendered: 'chart',
46+
chartType: 'bar',
47+
object: 'opportunity',
48+
dimension: 'stage',
49+
measures: ['amount'],
50+
categories: 30,
51+
rows: ROWS.slice(0, 20),
52+
note: 'Showing the first 20 of 30 rows.',
53+
chart: DESCRIPTOR,
54+
});
55+
56+
/** A result written before the producer persisted `chart`. */
57+
const LEGACY_RESULT = JSON.stringify({
58+
rendered: 'chart',
59+
chartType: 'bar',
60+
object: 'opportunity',
61+
dimension: 'stage',
62+
measures: ['amount'],
63+
categories: 30,
64+
rows: ROWS.slice(0, 20),
65+
});
66+
67+
const toolPart = (output: unknown, type = `tool-${VISUALIZE_DATA_TOOL}`) => ({
68+
type,
69+
toolCallId: 'call_1',
70+
state: 'output-available',
71+
input: {},
72+
output,
73+
});
74+
75+
const INTRO = 'Here is the pipeline.\n\n';
76+
const AFTER = 'Most of it sits in the late stages.';
77+
78+
/** What the live mapper draws from the streamed part: the reference chart. */
79+
const LIVE = uiMessageToChatMessage({
80+
id: 'live',
81+
role: 'assistant',
82+
parts: [{ type: 'data-chart', id: 'chart-0', data: DESCRIPTOR }],
83+
}).charts![0];
84+
85+
describe('the persisted visualize_data chart is read back (objectstack#25908)', () => {
86+
it('reads the same chart the live stream drew: title, series labels and every row', () => {
87+
const charts = chartsFromParts([
88+
{ type: 'text', text: INTRO },
89+
toolPart({ type: 'text', value: RESULT }),
90+
{ type: 'text', text: AFTER },
91+
]);
92+
expect(charts).toEqual([{ ...LIVE, textOffset: INTRO.length }]);
93+
expect(charts?.[0]?.data).toHaveLength(30);
94+
expect(charts?.[0]?.series).toEqual([{ dataKey: 'amount', label: 'Amount' }]);
95+
});
96+
97+
it('reads a bare result string too, the shape a legacy tool row keeps', () => {
98+
expect(chartsFromParts([toolPart(RESULT)])?.[0]?.title).toBe('Pipeline by stage');
99+
});
100+
101+
it('a result persisted before the chart key gets no chart, and does not throw', () => {
102+
expect(chartsFromParts([toolPart({ type: 'text', value: LEGACY_RESULT })])).toBeUndefined();
103+
expect(chartsFromParts([toolPart('not json at all')])).toBeUndefined();
104+
});
105+
106+
it('only the visualize_data result is read', () => {
107+
expect(chartsFromParts([toolPart({ type: 'text', value: RESULT }, 'tool-query_data')])).toBeUndefined();
108+
});
109+
110+
it('the tool name is the one the spec registry declares for service-ai', () => {
111+
const registry = (
112+
specSystem as { PLATFORM_TOOLS_BY_PACKAGE?: Readonly<Record<string, readonly string[]>> }
113+
).PLATFORM_TOOLS_BY_PACKAGE;
114+
expect(registry?.['service-ai']).toContain(VISUALIZE_DATA_TOOL);
115+
});
116+
117+
it('a live message carrying both the part and the result draws the chart once', () => {
118+
const out = uiMessageToChatMessage({
119+
id: 'a1',
120+
role: 'assistant',
121+
parts: [
122+
{ type: 'text', text: INTRO },
123+
{ type: 'data-chart', id: 'chart-0', data: DESCRIPTOR },
124+
toolPart({ type: 'text', value: RESULT }),
125+
{ type: 'text', text: AFTER },
126+
],
127+
});
128+
expect(out.charts).toEqual([{ ...LIVE, textOffset: INTRO.length }]);
129+
});
130+
});
131+
132+
describe('a hydrated chart goes back to the mapper at its offset (objectstack#25908)', () => {
133+
const content = `${INTRO}${AFTER}`;
134+
const charts = [{ ...LIVE, textOffset: INTRO.length }];
135+
136+
it('chartAnswerParts cuts the text at the chart and maps back to the same message', () => {
137+
const parts = chartAnswerParts(content, charts);
138+
expect(parts.map((p) => p.type)).toEqual(['text', 'data-chart', 'text']);
139+
const out = uiMessageToChatMessage({ id: 'a1', role: 'assistant', parts });
140+
expect(out.content).toBe(content);
141+
expect(out.charts).toEqual(charts);
142+
});
143+
144+
it('a chart with no offset follows all of the text, and no empty run is written', () => {
145+
const { textOffset: _placement, ...unplaced } = charts[0];
146+
expect(chartAnswerParts(content, [unplaced]).map((p) => p.type)).toEqual(['text', 'data-chart']);
147+
expect(chartAnswerParts('', undefined)).toEqual([]);
148+
expect(chartAnswerParts(content, undefined)).toEqual([{ type: 'text', text: content }]);
149+
});
150+
});

0 commit comments

Comments
 (0)