diff --git a/.changeset/fix-post-tool-first-text-delta.md b/.changeset/fix-post-tool-first-text-delta.md new file mode 100644 index 0000000000..05c3c95aee --- /dev/null +++ b/.changeset/fix-post-tool-first-text-delta.md @@ -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. diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts index 8b6f5bc371..66b8bbc0db 100644 --- a/packages/ai/src/activities/chat/stream/processor.ts +++ b/packages/ai/src/activities/chat/stream/processor.ts @@ -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 diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts index 61a37de65b..6f95dd1b15 100644 --- a/packages/ai/tests/stream-processor.test.ts +++ b/packages/ai/tests/stream-processor.test.ts @@ -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.', + ) + }) }) // ========================================================================== diff --git a/testing/e2e/fixtures/text-tool-text/tool-text.json b/testing/e2e/fixtures/text-tool-text/tool-text.json new file mode 100644 index 0000000000..a976eb8d38 --- /dev/null +++ b/testing/e2e/fixtures/text-tool-text/tool-text.json @@ -0,0 +1,27 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "[tool-text] list the guitars in stock", + "sequenceIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "getGuitars", + "arguments": "{}" + } + ] + } + }, + { + "match": { + "userMessage": "[tool-text] list the guitars in stock", + "sequenceIndex": 1 + }, + "response": { + "content": "Absolutely, here's our inventory: Fender Stratocaster, Gibson Les Paul, and Martin D-28." + } + } + ] +} diff --git a/testing/e2e/tests/text-tool-text.spec.ts b/testing/e2e/tests/text-tool-text.spec.ts index 953fead6a4..291fc1351f 100644 --- a/testing/e2e/tests/text-tool-text.spec.ts +++ b/testing/e2e/tests/text-tool-text.spec.ts @@ -38,5 +38,33 @@ for (const provider of providersFor('text-tool-text')) { expect(combined).toContain('Let me check') expect(combined).toContain('Fender Stratocaster') }) + + // No leading text, so the tool call creates the message and the post-tool + // TEXT_MESSAGE_START skips the segment reset — the path that used to drop + // the reply's first word. + test('keeps the first word of the text that follows an opening tool call', async ({ + page, + testId, + aimockPort, + }) => { + await page.goto( + featureUrl(provider, 'text-tool-text', testId, aimockPort), + ) + + await sendMessage(page, '[tool-text] list the guitars in stock') + await waitForResponse(page) + + const toolCalls = await getToolCalls(page) + expect(toolCalls[0].name).toBe('getGuitars') + + await waitForAssistantText(page, 'Martin D-28') + + const allText = await page + .getByTestId('assistant-message') + .allInnerTexts() + const combined = allText.join(' ') + expect(combined).toContain('Absolutely, here') + expect(combined).toContain('Martin D-28') + }) }) }