Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-post-tool-first-text-delta.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai': patch
---

Fix StreamProcessor dropping the first text delta of a post-tool segment. When an assistant turn opened with a tool call and then spoke, the message auto-created by `TOOL_CALL_START` left `hasToolCallsSinceTextStart` set through `TEXT_MESSAGE_START`, so the segment reset fired one delta late and folded away the first `TEXT_MESSAGE_CONTENT` chunk (e.g. losing the first word of the reply). The segment reset now runs on the first post-tool delta regardless of whether the prior segment was empty, and only the flush of a non-empty prior segment stays gated.
6 changes: 4 additions & 2 deletions packages/ai/src/activities/chat/stream/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,12 +1307,14 @@ export class StreamProcessor {
// Detect if this is a NEW text segment (after tool calls) vs continuation
const isNewSegment =
state.hasToolCallsSinceTextStart &&
previousSegment.length > 0 &&
this.isNewTextSegment(chunk, previousSegment)

if (isNewSegment) {
// Emit any accumulated text before starting new segment
if (previousSegment !== state.lastEmittedText) {
if (
previousSegment.length > 0 &&
previousSegment !== state.lastEmittedText
) {
this.emitTextUpdateForMessage(messageId)
}
// Reset SEGMENT text accumulation for the new text segment after tool calls
Expand Down
33 changes: 33 additions & 0 deletions packages/ai/tests/stream-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,39 @@ describe('StreamProcessor', () => {
expect((textParts[0] as any).content).toBe('Before')
expect((textParts[1] as any).content).toBe('After')
})

// A turn that opens with a tool call and THEN speaks: the assistant
// message is auto-created by TOOL_CALL_START, so TEXT_MESSAGE_START hits
// the pending-message path and does not reset the post-tool segment flag.
// The first TEXT_MESSAGE_CONTENT delta must still survive in the assembled
// TextPart (spec: every delta appends) rather than being folded away on the
// next delta.
it('keeps the first post-tool text delta when the turn opens with a tool call', () => {
const processor = new StreamProcessor()
processor.prepareAssistantMessage()

processor.processChunk(ev.runStarted())
processor.processChunk(ev.toolStart('tc-1', 'search'))
processor.processChunk(ev.toolArgs('tc-1', '{}'))
processor.processChunk(ev.toolEnd('tc-1', 'search'))
processor.processChunk(ev.toolResult('tc-1', '{"ok":true}'))
processor.processChunk(ev.textStart())
processor.processChunk(ev.textContent('Hello '))
processor.processChunk(ev.textContent('from '))
processor.processChunk(ev.textContent('the '))
processor.processChunk(ev.textContent('model.'))
processor.processChunk(ev.textEnd())
processor.processChunk(ev.runFinished('stop'))
processor.finalizeStream()

const textParts = processor
.getMessages()
.flatMap((m) => m.parts)
.filter((p): p is TextPart => p.type === 'text')
expect(textParts.map((p) => p.content).join('')).toBe(
'Hello from the model.',
)
})
})

// ==========================================================================
Expand Down
Loading
Loading