From 15e67bdb36095813502074c9e4dbf92fdcef1a51 Mon Sep 17 00:00:00 2001 From: Patrick M Date: Wed, 26 Aug 2026 09:02:40 +0200 Subject: [PATCH 1/3] fix(ai): keep first text delta after a tool call in the same turn A turn that opens with a tool call and then speaks had its assistant message auto-created by TOOL_CALL_START, so TEXT_MESSAGE_START took the pending-message path and never reset hasToolCallsSinceTextStart. The segment reset then fired one TEXT_MESSAGE_CONTENT delta late and folded the first delta away, dropping the first word of the post-tool reply. Run the segment reset on the first post-tool delta even when the prior segment is empty; gate only the flush of a non-empty prior segment. Every delta now appends, per docs/chat-architecture.md. --- .changeset/fix-post-tool-first-text-delta.md | 5 +++ .../src/activities/chat/stream/processor.ts | 6 ++-- packages/ai/tests/stream-processor.test.ts | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-post-tool-first-text-delta.md 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 000000000..05c3c95ae --- /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 8b6f5bc37..66b8bbc0d 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 61a37de65..6f95dd1b1 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.', + ) + }) }) // ========================================================================== From a28e0e2f0a313ba2ae18da6ee68f83cf69a79cad Mon Sep 17 00:00:00 2001 From: Patrick M Date: Wed, 26 Aug 2026 09:15:45 +0200 Subject: [PATCH 2/3] test(e2e): cover text kept after an opening tool call Add a text-tool-text E2E case whose turn opens with a tool call and no leading text, then speaks. This exercises the client StreamProcessor path where the post-tool TEXT_MESSAGE_START does not reset the segment accumulator, which previously dropped the reply's first word. Asserts the leading word survives across the provider matrix. Red before the processor fix, green after. --- .../fixtures/text-tool-text/tool-text.json | 27 ++++++++++++++++++ testing/e2e/tests/text-tool-text.spec.ts | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 testing/e2e/fixtures/text-tool-text/tool-text.json 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 000000000..a976eb8d3 --- /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 953fead6a..291fc1351 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') + }) }) } From 0854a5b614367050b057928a6af1accfc858dc24 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:26:48 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- scripts/lovable-gateway.models.json | 387 ++++++---------------------- 1 file changed, 82 insertions(+), 305 deletions(-) diff --git a/scripts/lovable-gateway.models.json b/scripts/lovable-gateway.models.json index 8a193376e..79c6babbc 100644 --- a/scripts/lovable-gateway.models.json +++ b/scripts/lovable-gateway.models.json @@ -12,15 +12,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -85,15 +78,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -150,15 +136,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -222,12 +201,8 @@ "context_window": 8192, "max_tokens": 16384, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -265,12 +240,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -308,15 +279,8 @@ "max_tokens": 65536, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -406,12 +370,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -449,15 +409,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -522,15 +475,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -595,15 +541,8 @@ "max_tokens": 32768, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -668,15 +607,8 @@ "max_tokens": 65000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -740,15 +672,8 @@ "context_window": 65536, "max_tokens": 4096, "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text", - "image" - ] + "input": ["text", "image", "video"], + "output": ["text", "image"] }, "pricing": { "input": { @@ -813,12 +738,8 @@ "max_tokens": 16384, "knowledge": "2025-01", "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -856,15 +777,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -954,15 +868,8 @@ "max_tokens": 64000, "knowledge": "2025-01", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1027,15 +934,8 @@ "max_tokens": 64000, "knowledge": "2026-03", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1100,12 +1000,8 @@ "max_tokens": 0, "knowledge": "2025-05", "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -1133,15 +1029,8 @@ "max_tokens": 0, "knowledge": "2025-11", "modalities": { - "input": [ - "text", - "image", - "audio", - "video" - ], - "output": [ - "text" - ] + "input": ["text", "image", "audio", "video"], + "output": ["text"] }, "pricing": { "input": { @@ -1192,13 +1081,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1226,13 +1110,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1260,13 +1139,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "video" - ] + "input": ["text", "image"], + "output": ["video"] }, "pricing": { "video_duration": { @@ -1291,13 +1165,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1342,12 +1211,8 @@ "context_window": 2000, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "audio" - ] + "input": ["text"], + "output": ["audio"] }, "pricing": { "input": { @@ -1384,13 +1249,8 @@ "context_window": 16000, "max_tokens": 2000, "modalities": { - "input": [ - "text", - "audio" - ], - "output": [ - "text" - ] + "input": ["text", "audio"], + "output": ["text"] }, "pricing": { "input": { @@ -1436,13 +1296,8 @@ "max_tokens": 128000, "knowledge": "2024-09-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1526,13 +1381,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1616,13 +1466,8 @@ "max_tokens": 128000, "knowledge": "2024-05-30", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1671,13 +1516,8 @@ "max_tokens": 128000, "knowledge": "2024-10", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1761,13 +1601,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1866,13 +1701,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -1956,13 +1786,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2011,13 +1836,8 @@ "max_tokens": 128000, "knowledge": "2025-08-31", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2081,13 +1901,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2186,13 +2001,8 @@ "max_tokens": 128000, "knowledge": "2025-12-01", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2256,13 +2066,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2361,13 +2166,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2466,13 +2266,8 @@ "max_tokens": 128000, "knowledge": "2026-02-16", "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "text" - ] + "input": ["text", "image"], + "output": ["text"] }, "pricing": { "input": { @@ -2570,13 +2365,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2621,13 +2411,8 @@ "context_window": 0, "max_tokens": 0, "modalities": { - "input": [ - "text", - "image" - ], - "output": [ - "image" - ] + "input": ["text", "image"], + "output": ["image"] }, "pricing": { "input": { @@ -2672,12 +2457,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": { @@ -2704,12 +2485,8 @@ "context_window": 8191, "max_tokens": 0, "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] + "input": ["text"], + "output": ["text"] }, "pricing": { "input": {