Skip to content

Commit 69bceb1

Browse files
tombeckenhamclaude
andcommitted
fix: address CodeRabbit findings + e2e structured-output renderer
- e2e ChatUI: render `structured-output` parts so the assistant message has visible text content. The Phase 1 redesign routes structured-output JSON deltas into a dedicated part instead of a text part, so the e2e selector `getLastAssistantMessage` was returning an empty string and the structured-output-stream specs failed. - processor: reconcile cumulative `chunk.content` against the existing StructuredOutputPart.raw before appending, mirroring the dedup the text path already does. Adapters that emit cumulative content (rather than incremental delta) on a structured run would otherwise duplicate the JSON buffer and corrupt the partial parse. Adds a focused unit test. - example: fix the recipe metadata block in /generations/structured-chat so a valid `estimatedCostUsd === 0` renders `$0.00` instead of being hidden by a truthy check. Minor CodeRabbit catch. The remaining CodeRabbit findings were either stale (raw fallback already addressed in the prior fix commit) or pre-existing (initialMessages mount-only hydration was on main before this branch — out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 640c3b4 commit 69bceb1

4 files changed

Lines changed: 80 additions & 7 deletions

File tree

examples/ts-react-chat/src/routes/generations.structured-chat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,13 @@ function RecipeCard({ part }: { part: StructuredOutputPart }) {
186186
<span className="inline-block w-2 h-2 mt-2 rounded-full bg-orange-400 animate-pulse" />
187187
)}
188188
</div>
189-
{(recipe.cuisine || recipe.servings || recipe.estimatedCostUsd) && (
189+
{(recipe.cuisine ||
190+
recipe.servings ||
191+
recipe.estimatedCostUsd !== undefined) && (
190192
<p className="text-xs text-gray-400 mt-1">
191193
{recipe.cuisine}
192194
{recipe.servings ? ` · serves ${recipe.servings}` : ''}
193-
{recipe.estimatedCostUsd
195+
{recipe.estimatedCostUsd !== undefined
194196
? ` · ~$${recipe.estimatedCostUsd.toFixed(2)}`
195197
: ''}
196198
</p>

packages/typescript/ai/src/activities/chat/stream/processor.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -855,11 +855,28 @@ export class StreamProcessor {
855855
this.completeAllToolCallsForMessage(messageId)
856856

857857
if (this.structuredMessageIds.has(messageId)) {
858-
const delta =
859-
chunk.delta ||
860-
(chunk.content !== undefined && chunk.content !== ''
861-
? chunk.content
862-
: '')
858+
// `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
859+
// (mirrors what the plain-text branch handles below). Reconcile against
860+
// the existing raw buffer so adapters that emit cumulative content
861+
// don't duplicate the JSON.
862+
let delta = chunk.delta || ''
863+
if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
864+
const existingRaw = (
865+
this.messages
866+
.find((m) => m.id === messageId)
867+
?.parts.find(
868+
(p): p is Extract<MessagePart, { type: 'structured-output' }> =>
869+
p.type === 'structured-output',
870+
) ?? { raw: '' }
871+
).raw
872+
if (chunk.content.startsWith(existingRaw)) {
873+
delta = chunk.content.slice(existingRaw.length)
874+
} else if (existingRaw.startsWith(chunk.content)) {
875+
delta = ''
876+
} else {
877+
delta = chunk.content
878+
}
879+
}
863880
if (delta !== '') {
864881
this.messages = appendStructuredOutputDelta(
865882
this.messages,

packages/typescript/ai/tests/stream-processor.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3551,6 +3551,40 @@ describe('StreamProcessor', () => {
35513551
expect(sop?.data).toEqual({ name: 'A' })
35523552
})
35533553

3554+
it('reconciles cumulative TEXT_MESSAGE_CONTENT against existing raw without duplication', () => {
3555+
// Some adapters emit `content` (cumulative buffer) instead of `delta`.
3556+
// The structured branch must dedup it against the part's existing raw,
3557+
// otherwise we'd append the whole buffer each time and corrupt the JSON.
3558+
const processor = new StreamProcessor()
3559+
3560+
processor.processChunk(ev.runStarted())
3561+
processor.processChunk(ev.textStart('msg-1'))
3562+
processor.processChunk(
3563+
chunk(EventType.CUSTOM, {
3564+
name: 'structured-output.start',
3565+
value: { messageId: 'msg-1' },
3566+
}),
3567+
)
3568+
processor.processChunk(
3569+
chunk(EventType.TEXT_MESSAGE_CONTENT, {
3570+
messageId: 'msg-1',
3571+
content: '{"name":',
3572+
}),
3573+
)
3574+
processor.processChunk(
3575+
chunk(EventType.TEXT_MESSAGE_CONTENT, {
3576+
messageId: 'msg-1',
3577+
content: '{"name":"Alice"}',
3578+
}),
3579+
)
3580+
3581+
const sop = processor
3582+
.getMessages()
3583+
.find((m) => m.role === 'assistant')!
3584+
.parts.find((p) => p.type === 'structured-output') as any
3585+
expect(sop.raw).toBe('{"name":"Alice"}')
3586+
})
3587+
35543588
it('clears structuredMessageIds for messages dropped by removeMessagesAfter (reload)', () => {
35553589
// Reload removes the last assistant message and re-streams from the
35563590
// last user message. If the dropped assistant's routing entry lingers

testing/e2e/src/components/ChatUI.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,26 @@ export function ChatUI({
158158
</div>
159159
)
160160
}
161+
if (part.type === 'structured-output') {
162+
// Render the streamed JSON so the assistant message has
163+
// visible content for selectors (e.g. `getLastAssistantMessage`).
164+
// Previously this content arrived as a `text` part — the new
165+
// routing puts it on a `structured-output` part instead.
166+
const sop = part as any
167+
const text =
168+
sop.raw ||
169+
(sop.data !== undefined ? JSON.stringify(sop.data) : '')
170+
if (text === '') return null
171+
return (
172+
<div
173+
key={i}
174+
data-testid="structured-output-part"
175+
className="prose prose-invert prose-sm max-w-none whitespace-pre-wrap break-words"
176+
>
177+
{text}
178+
</div>
179+
)
180+
}
161181
return null
162182
})}
163183
</div>

0 commit comments

Comments
 (0)