Skip to content

Commit 6f886e9

Browse files
authored
fix: content chunk issue fix (TanStack#272)
1 parent 4449b1b commit 6f886e9

4 files changed

Lines changed: 178 additions & 4 deletions

File tree

.changeset/fifty-dingos-mate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
fix issue with delta

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,10 @@ export class StreamProcessor {
483483
let nextText = currentText
484484

485485
// Prefer delta over content - delta is the incremental change
486-
if (chunk.delta !== '') {
486+
// Check for both undefined and empty string to avoid "undefined" string concatenation
487+
if (chunk.delta !== undefined && chunk.delta !== '') {
487488
nextText = currentText + chunk.delta
488-
} else if (chunk.content && chunk.content !== '') {
489+
} else if (chunk.content !== undefined && chunk.content !== '') {
489490
// Fallback: use content if delta is not provided
490491
if (chunk.content.startsWith(currentText)) {
491492
nextText = chunk.content

packages/typescript/ai/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -789,8 +789,8 @@ export interface TextMessageContentEvent extends BaseAGUIEvent {
789789
type: 'TEXT_MESSAGE_CONTENT'
790790
/** Message identifier */
791791
messageId: string
792-
/** The incremental content token */
793-
delta: string
792+
/** The incremental content token (may be undefined if only content is provided) */
793+
delta?: string
794794
/** Full accumulated content so far */
795795
content?: string
796796
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { StreamProcessor } from '../src/activities/chat/stream/processor'
3+
import type { StreamChunk } from '../src/types'
4+
5+
describe('StreamProcessor', () => {
6+
describe('handleTextMessageContentEvent', () => {
7+
it('should handle TEXT_MESSAGE_CONTENT with delta', () => {
8+
const processor = new StreamProcessor()
9+
processor.startAssistantMessage()
10+
11+
processor.processChunk({
12+
type: 'TEXT_MESSAGE_CONTENT',
13+
messageId: 'msg-1',
14+
delta: 'Hello',
15+
model: 'test',
16+
timestamp: Date.now(),
17+
} as StreamChunk)
18+
19+
processor.processChunk({
20+
type: 'TEXT_MESSAGE_CONTENT',
21+
messageId: 'msg-1',
22+
delta: ' world',
23+
model: 'test',
24+
timestamp: Date.now(),
25+
} as StreamChunk)
26+
27+
processor.processChunk({
28+
type: 'RUN_FINISHED',
29+
model: 'test',
30+
timestamp: Date.now(),
31+
finishReason: 'stop',
32+
} as StreamChunk)
33+
34+
const messages = processor.getMessages()
35+
expect(messages).toHaveLength(1)
36+
expect(messages[0]?.parts).toHaveLength(1)
37+
expect(messages[0]?.parts[0]).toEqual({
38+
type: 'text',
39+
content: 'Hello world',
40+
})
41+
})
42+
43+
it('should handle TEXT_MESSAGE_CONTENT with undefined delta (issue #257)', () => {
44+
const processor = new StreamProcessor()
45+
processor.startAssistantMessage()
46+
47+
// Simulate a chunk where delta is undefined (which can happen in practice)
48+
processor.processChunk({
49+
type: 'TEXT_MESSAGE_CONTENT',
50+
messageId: 'msg-1',
51+
delta: undefined,
52+
content: 'Hello',
53+
model: 'test',
54+
timestamp: Date.now(),
55+
} as unknown as StreamChunk)
56+
57+
processor.processChunk({
58+
type: 'TEXT_MESSAGE_CONTENT',
59+
messageId: 'msg-1',
60+
delta: undefined,
61+
content: 'Hello world',
62+
model: 'test',
63+
timestamp: Date.now(),
64+
} as unknown as StreamChunk)
65+
66+
processor.processChunk({
67+
type: 'RUN_FINISHED',
68+
model: 'test',
69+
timestamp: Date.now(),
70+
finishReason: 'stop',
71+
} as StreamChunk)
72+
73+
const messages = processor.getMessages()
74+
expect(messages).toHaveLength(1)
75+
expect(messages[0]?.parts).toHaveLength(1)
76+
// Should NOT contain "undefined" string
77+
expect(messages[0]?.parts[0]).toEqual({
78+
type: 'text',
79+
content: 'Hello world',
80+
})
81+
})
82+
83+
it('should handle TEXT_MESSAGE_CONTENT with empty delta', () => {
84+
const processor = new StreamProcessor()
85+
processor.startAssistantMessage()
86+
87+
// Empty delta should fall back to content
88+
processor.processChunk({
89+
type: 'TEXT_MESSAGE_CONTENT',
90+
messageId: 'msg-1',
91+
delta: '',
92+
content: 'Hello',
93+
model: 'test',
94+
timestamp: Date.now(),
95+
} as StreamChunk)
96+
97+
processor.processChunk({
98+
type: 'RUN_FINISHED',
99+
model: 'test',
100+
timestamp: Date.now(),
101+
finishReason: 'stop',
102+
} as StreamChunk)
103+
104+
const messages = processor.getMessages()
105+
expect(messages).toHaveLength(1)
106+
expect(messages[0]?.parts).toHaveLength(1)
107+
expect(messages[0]?.parts[0]).toEqual({
108+
type: 'text',
109+
content: 'Hello',
110+
})
111+
})
112+
113+
it('should handle TEXT_MESSAGE_CONTENT with only content (no delta)', () => {
114+
const processor = new StreamProcessor()
115+
processor.startAssistantMessage()
116+
117+
// Some servers may only send content without delta
118+
processor.processChunk({
119+
type: 'TEXT_MESSAGE_CONTENT',
120+
messageId: 'msg-1',
121+
content: 'Hello',
122+
model: 'test',
123+
timestamp: Date.now(),
124+
} as unknown as StreamChunk)
125+
126+
processor.processChunk({
127+
type: 'TEXT_MESSAGE_CONTENT',
128+
messageId: 'msg-1',
129+
content: 'Hello world',
130+
model: 'test',
131+
timestamp: Date.now(),
132+
} as unknown as StreamChunk)
133+
134+
processor.processChunk({
135+
type: 'RUN_FINISHED',
136+
model: 'test',
137+
timestamp: Date.now(),
138+
finishReason: 'stop',
139+
} as StreamChunk)
140+
141+
const messages = processor.getMessages()
142+
expect(messages).toHaveLength(1)
143+
expect(messages[0]?.parts).toHaveLength(1)
144+
expect(messages[0]?.parts[0]).toEqual({
145+
type: 'text',
146+
content: 'Hello world',
147+
})
148+
})
149+
150+
it('should have empty parts when no TEXT_MESSAGE_CONTENT is received', () => {
151+
const processor = new StreamProcessor()
152+
processor.startAssistantMessage()
153+
154+
// Only RUN_FINISHED without any text content
155+
processor.processChunk({
156+
type: 'RUN_FINISHED',
157+
model: 'test',
158+
timestamp: Date.now(),
159+
finishReason: 'stop',
160+
} as StreamChunk)
161+
162+
const messages = processor.getMessages()
163+
expect(messages).toHaveLength(1)
164+
// Parts should be empty when no content was received
165+
expect(messages[0]?.parts).toHaveLength(0)
166+
})
167+
})
168+
})

0 commit comments

Comments
 (0)