Skip to content

Commit af99dde

Browse files
committed
fix(ai): stop stripping fields — passthrough allows all extras
@ag-ui/core BaseEventSchema uses .passthrough() so extra fields are allowed. Only strip the deprecated nested error object from RUN_ERROR (conflicts with spec's flat message/code). Everything else passes through: model, content, toolName, stepId, usage, finishReason, result, input, args, etc.
1 parent 9046480 commit af99dde

3 files changed

Lines changed: 42 additions & 221 deletions

File tree

packages/typescript/ai/src/strip-to-spec-middleware.ts

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,27 @@ import type { ChatMiddleware } from './activities/chat/middleware/types'
22
import type { StreamChunk } from './types'
33

44
/**
5-
* Fields to always strip from events.
5+
* Strip only the deprecated nested `error` object from RUN_ERROR events.
6+
* The flat `message`/`code` fields are the spec-compliant form.
67
*
7-
* - `rawEvent`: Debug-only provider payload, potentially large. Not for wire.
8-
*/
9-
const ALWAYS_STRIP = new Set(['rawEvent'])
10-
11-
/**
12-
* Per-event-type fields to strip. Only deprecated aliases and fields that
13-
* conflict with the spec are removed. Extra fields are allowed by @ag-ui/core's
14-
* BaseEventSchema (.passthrough()), so we keep useful extensions like `model`,
15-
* `content`, `usage`, `finishReason`, `input`, `result`, etc.
16-
*/
17-
const STRIP_BY_TYPE: Record<string, Set<string>> = {
18-
TOOL_CALL_START: new Set(['toolName']),
19-
TOOL_CALL_END: new Set(['toolName']),
20-
RUN_ERROR: new Set(['error']),
21-
STEP_STARTED: new Set(['stepId']),
22-
STEP_FINISHED: new Set(['stepId']),
23-
STATE_SNAPSHOT: new Set(['state']),
24-
}
25-
26-
/**
27-
* Strip deprecated aliases and debug fields from a StreamChunk.
28-
*
29-
* @ag-ui/core's BaseEventSchema uses `.passthrough()`, so extra fields
30-
* (model, content, usage, finishReason, etc.) are allowed and won't break
31-
* spec validation. We only strip:
32-
* - Deprecated field aliases (toolName, stepId, state) to nudge consumers
33-
* toward spec names (toolCallName, stepName, snapshot)
34-
* - The deprecated nested `error` object on RUN_ERROR (spec uses flat message/code)
35-
* - `rawEvent` (debug payload, potentially large)
8+
* All other fields pass through unchanged. @ag-ui/core's BaseEventSchema
9+
* uses `.passthrough()`, so extra fields (model, content, usage,
10+
* finishReason, toolName, stepId, etc.) are allowed and won't break
11+
* spec validation or verifyEvents.
3612
*/
3713
export function stripToSpec(chunk: StreamChunk): StreamChunk {
38-
const typeStrip = STRIP_BY_TYPE[chunk.type]
39-
if (!typeStrip && ALWAYS_STRIP.size === 0) return chunk
40-
41-
const result: Record<string, unknown> = {}
42-
43-
for (const [key, value] of Object.entries(chunk)) {
44-
if (ALWAYS_STRIP.has(key)) continue
45-
if (typeStrip?.has(key)) continue
46-
result[key] = value
14+
// Only strip the deprecated nested error object from RUN_ERROR
15+
if (chunk.type === 'RUN_ERROR' && 'error' in chunk) {
16+
const { error: _deprecated, ...rest } = chunk as Record<string, unknown>
17+
return rest as StreamChunk
4718
}
48-
49-
return result as StreamChunk
19+
return chunk
5020
}
5121

5222
/**
53-
* Middleware that strips deprecated aliases and debug fields from events.
54-
* Should always be the LAST middleware in the chain.
23+
* Middleware that ensures events are AG-UI spec compliant.
24+
* Currently only strips the deprecated nested `error` object from RUN_ERROR.
25+
* All other fields pass through unchanged (passthrough allowed by spec).
5526
*/
5627
export function stripToSpecMiddleware(): ChatMiddleware {
5728
return {

packages/typescript/ai/tests/chat.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,7 +1319,7 @@ describe('chat()', () => {
13191319
expect((runFinished as any).threadId).toBe('thread-1')
13201320
})
13211321

1322-
it('should strip deprecated toolName but keep extras on yielded events', async () => {
1322+
it('should include both toolCallName (spec) and toolName (deprecated) on TOOL_CALL_START', async () => {
13231323
const { adapter } = createMockAdapter({
13241324
iterations: [
13251325
[
@@ -1353,10 +1353,9 @@ describe('chat()', () => {
13531353

13541354
const toolStartChunks = chunks.filter((c) => c.type === 'TOOL_CALL_START')
13551355
for (const chunk of toolStartChunks) {
1356-
// toolCallName should be present (spec field)
1356+
// Both spec and deprecated field present (passthrough)
13571357
expect((chunk as any).toolCallName).toBe('get_weather')
1358-
// toolName should be stripped
1359-
expect('toolName' in chunk).toBe(false)
1358+
expect((chunk as any).toolName).toBe('get_weather')
13601359
}
13611360
})
13621361

packages/typescript/ai/tests/strip-to-spec-middleware.test.ts

Lines changed: 25 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -2,104 +2,14 @@ import { describe, it, expect } from 'vitest'
22
import { stripToSpec } from '../src/strip-to-spec-middleware'
33
import type { StreamChunk } from '../src/types'
44

5-
/**
6-
* Helper to create a StreamChunk with the given type and fields.
7-
*/
8-
function makeChunk(type: string, fields: Record<string, unknown>): StreamChunk {
5+
function makeChunk(
6+
type: string,
7+
fields: Record<string, unknown>,
8+
): StreamChunk {
99
return { type, timestamp: Date.now(), ...fields } as unknown as StreamChunk
1010
}
1111

1212
describe('stripToSpec', () => {
13-
// =========================================================================
14-
// Always stripped: rawEvent (debug payload, potentially large)
15-
// =========================================================================
16-
17-
it('strips rawEvent from all events', () => {
18-
const chunk = makeChunk('TEXT_MESSAGE_START', {
19-
messageId: 'msg-1',
20-
role: 'assistant',
21-
rawEvent: { some: 'raw data' },
22-
model: 'gpt-4o',
23-
})
24-
const result = stripToSpec(chunk) as Record<string, unknown>
25-
expect(result).not.toHaveProperty('rawEvent')
26-
// model is kept (passthrough)
27-
expect(result).toHaveProperty('model', 'gpt-4o')
28-
expect(result).toHaveProperty('messageId', 'msg-1')
29-
})
30-
31-
// =========================================================================
32-
// Deprecated aliases stripped: toolName, stepId, state, error (nested)
33-
// =========================================================================
34-
35-
it('strips deprecated toolName from TOOL_CALL_START, keeps toolCallName', () => {
36-
const chunk = makeChunk('TOOL_CALL_START', {
37-
toolCallId: 'tc-1',
38-
toolCallName: 'getTodos',
39-
toolName: 'getTodos',
40-
index: 0,
41-
providerMetadata: { foo: 'bar' },
42-
model: 'gpt-4o',
43-
})
44-
const result = stripToSpec(chunk) as Record<string, unknown>
45-
expect(result).not.toHaveProperty('toolName')
46-
// These extras are kept (passthrough)
47-
expect(result).toHaveProperty('toolCallName', 'getTodos')
48-
expect(result).toHaveProperty('index', 0)
49-
expect(result).toHaveProperty('providerMetadata')
50-
expect(result).toHaveProperty('model', 'gpt-4o')
51-
})
52-
53-
it('strips deprecated toolName from TOOL_CALL_END, keeps extras', () => {
54-
const chunk = makeChunk('TOOL_CALL_END', {
55-
toolCallId: 'tc-1',
56-
toolName: 'getTodos',
57-
toolCallName: 'getTodos',
58-
input: { userId: '123' },
59-
result: '[{"id":"1","title":"Buy milk"}]',
60-
model: 'gpt-4o',
61-
})
62-
const result = stripToSpec(chunk) as Record<string, unknown>
63-
expect(result).not.toHaveProperty('toolName')
64-
// These extras are kept (passthrough)
65-
expect(result).toHaveProperty('toolCallName', 'getTodos')
66-
expect(result).toHaveProperty('input')
67-
expect(result).toHaveProperty('result')
68-
expect(result).toHaveProperty('model', 'gpt-4o')
69-
})
70-
71-
it('strips deprecated stepId from STEP_STARTED, keeps stepName and extras', () => {
72-
const chunk = makeChunk('STEP_STARTED', {
73-
stepName: 'thinking',
74-
stepId: 'step-1',
75-
stepType: 'thinking',
76-
model: 'gpt-4o',
77-
})
78-
const result = stripToSpec(chunk) as Record<string, unknown>
79-
expect(result).not.toHaveProperty('stepId')
80-
// These extras are kept (passthrough)
81-
expect(result).toHaveProperty('stepName', 'thinking')
82-
expect(result).toHaveProperty('stepType', 'thinking')
83-
expect(result).toHaveProperty('model', 'gpt-4o')
84-
})
85-
86-
it('strips deprecated stepId from STEP_FINISHED, keeps extras', () => {
87-
const chunk = makeChunk('STEP_FINISHED', {
88-
stepName: 'thinking',
89-
stepId: 'step-1',
90-
delta: 'some thinking',
91-
content: 'accumulated thinking',
92-
model: 'gpt-4o',
93-
})
94-
const result = stripToSpec(chunk) as Record<string, unknown>
95-
expect(result).not.toHaveProperty('stepId')
96-
// These extras are kept (passthrough)
97-
expect(result).toHaveProperty('stepName', 'thinking')
98-
expect(result).toHaveProperty('delta', 'some thinking')
99-
expect(result).toHaveProperty('content', 'accumulated thinking')
100-
expect(result).toHaveProperty('model', 'gpt-4o')
101-
})
102-
10313
it('strips deprecated nested error from RUN_ERROR, keeps flat message/code', () => {
10414
const chunk = makeChunk('RUN_ERROR', {
10515
message: 'Something went wrong',
@@ -114,106 +24,47 @@ describe('stripToSpec', () => {
11424
expect(result).toHaveProperty('model', 'gpt-4o')
11525
})
11626

117-
it('strips deprecated state from STATE_SNAPSHOT, keeps snapshot', () => {
118-
const chunk = makeChunk('STATE_SNAPSHOT', {
119-
snapshot: { count: 42 },
120-
state: { count: 42 },
121-
model: 'gpt-4o',
122-
})
123-
const result = stripToSpec(chunk) as Record<string, unknown>
124-
expect(result).not.toHaveProperty('state')
125-
expect(result).toHaveProperty('snapshot')
126-
expect(result).toHaveProperty('model', 'gpt-4o')
127-
})
128-
129-
// =========================================================================
130-
// Extras preserved (passthrough allows them)
131-
// =========================================================================
132-
133-
it('keeps model, content on TEXT_MESSAGE_CONTENT', () => {
134-
const chunk = makeChunk('TEXT_MESSAGE_CONTENT', {
135-
messageId: 'msg-1',
136-
delta: 'Hello',
137-
content: 'Hello World',
138-
model: 'gpt-4o',
139-
})
140-
const result = stripToSpec(chunk) as Record<string, unknown>
141-
expect(result).toHaveProperty('delta', 'Hello')
142-
expect(result).toHaveProperty('content', 'Hello World')
143-
expect(result).toHaveProperty('model', 'gpt-4o')
144-
})
145-
146-
it('keeps args on TOOL_CALL_ARGS', () => {
147-
const chunk = makeChunk('TOOL_CALL_ARGS', {
27+
it('passes through all other events unchanged', () => {
28+
const chunk = makeChunk('TOOL_CALL_START', {
14829
toolCallId: 'tc-1',
149-
delta: '{"userId":',
150-
args: '{"userId":',
30+
toolCallName: 'getTodos',
31+
toolName: 'getTodos',
32+
index: 0,
33+
providerMetadata: { foo: 'bar' },
15134
model: 'gpt-4o',
15235
})
153-
const result = stripToSpec(chunk) as Record<string, unknown>
154-
expect(result).toHaveProperty('args', '{"userId":')
155-
expect(result).toHaveProperty('delta', '{"userId":')
156-
expect(result).toHaveProperty('model', 'gpt-4o')
36+
const result = stripToSpec(chunk)
37+
expect(result).toBe(chunk) // same reference, no copy
15738
})
15839

159-
it('keeps finishReason and usage on RUN_FINISHED', () => {
40+
it('keeps model, content, finishReason, usage, result, etc.', () => {
16041
const chunk = makeChunk('RUN_FINISHED', {
16142
runId: 'run-1',
43+
threadId: 'thread-1',
16244
model: 'gpt-4o',
16345
finishReason: 'stop',
16446
usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 },
16547
})
16648
const result = stripToSpec(chunk) as Record<string, unknown>
49+
expect(result).toHaveProperty('model', 'gpt-4o')
16750
expect(result).toHaveProperty('finishReason', 'stop')
16851
expect(result).toHaveProperty('usage')
169-
expect(result).toHaveProperty('model', 'gpt-4o')
170-
})
171-
172-
it('keeps model on RUN_STARTED', () => {
173-
const chunk = makeChunk('RUN_STARTED', {
174-
runId: 'run-1',
175-
threadId: 'thread-1',
176-
model: 'gpt-4o',
177-
})
178-
const result = stripToSpec(chunk) as Record<string, unknown>
179-
expect(result).toHaveProperty('model', 'gpt-4o')
180-
expect(result).toHaveProperty('threadId', 'thread-1')
181-
})
182-
183-
it('passes through REASONING events unchanged (except rawEvent)', () => {
184-
const chunk = makeChunk('REASONING_MESSAGE_CONTENT', {
185-
messageId: 'msg-1',
186-
delta: 'Let me think...',
187-
model: 'gpt-4o',
188-
})
189-
const result = stripToSpec(chunk) as Record<string, unknown>
190-
expect(result).toHaveProperty('delta', 'Let me think...')
191-
expect(result).toHaveProperty('model', 'gpt-4o')
19252
})
19353

194-
it('passes through TOOL_CALL_RESULT unchanged (except rawEvent)', () => {
195-
const chunk = makeChunk('TOOL_CALL_RESULT', {
54+
it('keeps toolName, stepId, and other deprecated aliases (passthrough)', () => {
55+
const chunk = makeChunk('TOOL_CALL_END', {
19656
toolCallId: 'tc-1',
197-
messageId: 'msg-result-1',
198-
content: '{"items":[]}',
199-
role: 'tool',
57+
toolCallName: 'getTodos',
58+
toolName: 'getTodos',
59+
input: { userId: '123' },
60+
result: '{"items":[]}',
20061
model: 'gpt-4o',
20162
})
20263
const result = stripToSpec(chunk) as Record<string, unknown>
64+
expect(result).toHaveProperty('toolName', 'getTodos')
65+
expect(result).toHaveProperty('toolCallName', 'getTodos')
66+
expect(result).toHaveProperty('input')
67+
expect(result).toHaveProperty('result')
20368
expect(result).toHaveProperty('model', 'gpt-4o')
204-
expect(result).toHaveProperty('content', '{"items":[]}')
205-
})
206-
207-
it('strips rawEvent even when combined with type-specific strips', () => {
208-
const chunk = makeChunk('TOOL_CALL_START', {
209-
toolCallId: 'tc-1',
210-
toolCallName: 'foo',
211-
toolName: 'foo',
212-
rawEvent: { originalPayload: true },
213-
})
214-
const result = stripToSpec(chunk) as Record<string, unknown>
215-
expect(result).not.toHaveProperty('rawEvent')
216-
expect(result).not.toHaveProperty('toolName')
217-
expect(result).toHaveProperty('toolCallName', 'foo')
21869
})
21970
})

0 commit comments

Comments
 (0)