Skip to content

Commit 8c4382f

Browse files
jerryliang64claude
andauthored
fix(agent-runtime): preserve non-text content blocks in MessageConverter (#426)
## Summary - **修复 `MessageConverter.toContentBlocks()` 有损转换问题**:原实现通过 `.filter(part => part.type === 'text')` 过滤掉了所有非 text 类型的 content block(如 `tool_use`、`tool_result`),导致基于 `@anthropic-ai/claude-agent-sdk` 实现 `execRun` 时,工具调用信息在转换后丢失 - **修复 `MessageConverter.toInputMessageObjects()` 同样的有损问题**:原实现将所有 content part 当作 text 处理,非 text 块的 `.text` 为 undefined - **扩展类型系统**:在 `ContentBlockType` 中新增 `ToolUse`、`ToolResult`,新增对应的输入/输出类型接口,并增加 `GenericContentBlock` 兜底未来新类型 - **提供 type guard 函数**:`isTextBlock()`、`isToolUseBlock()`、`isToolResultBlock()` 方便业务代码做类型收窄 ## Affected areas | 影响面 | 修复前 | 修复后 | |---|---|---| | Thread 历史消息 | tool_use 块被丢弃 | 完整保留 | | SSE 流式 delta | 不包含 tool_use 事件 | 包含所有 content block | | Run output | 缺少 tool_use block | 完整保留 | ## Test plan - [x] `toContentBlocks` 保留 tool_use content parts - [x] `toContentBlocks` 保留 tool_result content parts(含 is_error 标记) - [x] `toContentBlocks` 保留未知类型 content parts(generic 兜底) - [x] `toContentBlocks` 保持混合类型的顺序 - [x] `toMessageObject` 保留 tool_use blocks - [x] `extractFromStreamMessages` 正确提取包含 tool_use 的消息 - [x] `toInputMessageObjects` 保留 tool_use / tool_result blocks - [x] type guard 函数正确收窄类型 - [x] 所有 116 个测试通过 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for tool use and tool result content blocks in message handling. * Message conversion now preserves all content block types instead of filtering non-text content. * **Improvements** * Enhanced type safety with expanded content block type definitions. * Improved message processing to handle mixed content ordering across text, tool use, and tool result blocks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c77cd1 commit 8c4382f

5 files changed

Lines changed: 296 additions & 18 deletions

File tree

core/agent-runtime/src/MessageConverter.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,35 @@ import type {
22
CreateRunInput,
33
MessageObject,
44
MessageContentBlock,
5+
TextContentBlock,
6+
ToolUseContentBlock,
7+
ToolResultContentBlock,
58
AgentStreamMessage,
69
AgentStreamMessagePayload,
10+
TextInputContentPart,
711
} from '@eggjs/tegg-types/agent-runtime';
812
import { AgentObjectType, MessageRole, MessageStatus, ContentBlockType } from '@eggjs/tegg-types/agent-runtime';
913

14+
export function isTextBlock(block: MessageContentBlock): block is TextContentBlock {
15+
return block.type === ContentBlockType.Text;
16+
}
17+
18+
export function isToolUseBlock(block: MessageContentBlock): block is ToolUseContentBlock {
19+
return block.type === ContentBlockType.ToolUse;
20+
}
21+
22+
export function isToolResultBlock(block: MessageContentBlock): block is ToolResultContentBlock {
23+
return block.type === ContentBlockType.ToolResult;
24+
}
25+
1026
import { nowUnix, newMsgId } from './AgentStoreUtils';
1127
import type { RunUsage } from './RunBuilder';
1228

1329
export class MessageConverter {
1430
/**
15-
* Convert an AgentStreamMessage's message payload into OpenAI MessageContentBlock[].
31+
* Convert an AgentStreamMessage's message payload into MessageContentBlock[].
32+
* Text blocks are wrapped in { value, annotations } format.
33+
* Non-text blocks (tool_use, tool_result, etc.) are passed through as-is.
1634
*/
1735
static toContentBlocks(msg: AgentStreamMessagePayload): MessageContentBlock[] {
1836
if (!msg) return [];
@@ -21,9 +39,15 @@ export class MessageConverter {
2139
return [{ type: ContentBlockType.Text, text: { value: content, annotations: [] } }];
2240
}
2341
if (Array.isArray(content)) {
24-
return content
25-
.filter(part => part.type === ContentBlockType.Text)
26-
.map(part => ({ type: ContentBlockType.Text, text: { value: part.text, annotations: [] } }));
42+
return content.map(part => {
43+
if (part.type === ContentBlockType.Text) {
44+
return {
45+
type: ContentBlockType.Text,
46+
text: { value: (part as TextInputContentPart).text, annotations: [] },
47+
} as MessageContentBlock;
48+
}
49+
return part as MessageContentBlock;
50+
});
2751
}
2852
return [];
2953
}
@@ -123,7 +147,15 @@ export class MessageConverter {
123147
content:
124148
typeof m.content === 'string'
125149
? [{ type: ContentBlockType.Text, text: { value: m.content, annotations: [] } }]
126-
: m.content.map(p => ({ type: ContentBlockType.Text, text: { value: p.text, annotations: [] } })),
150+
: m.content.map(p => {
151+
if (p.type === ContentBlockType.Text) {
152+
return {
153+
type: ContentBlockType.Text,
154+
text: { value: (p as TextInputContentPart).text, annotations: [] },
155+
} as MessageContentBlock;
156+
}
157+
return p as MessageContentBlock;
158+
}),
127159
}));
128160
}
129161
}

core/agent-runtime/test/AgentRuntime.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
MessageStatus,
1010
ContentBlockType,
1111
AgentNotFoundError, AgentConflictError } from '@eggjs/tegg-types/agent-runtime';
12+
13+
import { isTextBlock } from '../src/MessageConverter';
1214
import type { RunRecord, RunObject, CreateRunInput, AgentStreamMessage } from '@eggjs/tegg-types/agent-runtime';
1315

1416
import { AgentRuntime } from '../src/AgentRuntime';
@@ -180,6 +182,7 @@ describe('test/AgentRuntime.test.ts', () => {
180182
assert.equal(result.output![0].status, MessageStatus.Completed);
181183
const content = result.output![0].content;
182184
assert.equal(content[0].type, ContentBlockType.Text);
185+
assert(isTextBlock(content[0]));
183186
assert.equal(content[0].text.value, 'Hello 1 messages');
184187
assert(Array.isArray(content[0].text.annotations));
185188
assert.equal(result.usage!.promptTokens, 10);
@@ -332,6 +335,7 @@ describe('test/AgentRuntime.test.ts', () => {
332335
const run = await store.getRun(result.id);
333336
assert.equal(run.status, RunStatus.Completed);
334337
const outputContent = run.output![0].content;
338+
assert(isTextBlock(outputContent[0]));
335339
assert.equal(outputContent[0].text.value, 'Hello 1 messages');
336340
});
337341

core/agent-runtime/test/MessageConverter.test.ts

Lines changed: 190 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
import assert from 'node:assert';
22

3-
import type { AgentStreamMessage, AgentStreamMessagePayload } from '@eggjs/tegg-types/agent-runtime';
4-
import { MessageRole, MessageStatus, AgentObjectType, ContentBlockType } from '@eggjs/tegg-types/agent-runtime';
3+
import type {
4+
AgentStreamMessage,
5+
AgentStreamMessagePayload,
6+
ToolUseContentBlock,
7+
ToolResultContentBlock,
8+
} from '@eggjs/tegg-types/agent-runtime';
9+
import {
10+
MessageRole,
11+
MessageStatus,
12+
AgentObjectType,
13+
ContentBlockType,
14+
} from '@eggjs/tegg-types/agent-runtime';
515

6-
import { MessageConverter } from '../src/MessageConverter';
16+
import { MessageConverter, isTextBlock, isToolUseBlock, isToolResultBlock } from '../src/MessageConverter';
717

818
describe('test/MessageConverter.test.ts', () => {
919
describe('toContentBlocks', () => {
@@ -17,6 +27,7 @@ describe('test/MessageConverter.test.ts', () => {
1727
const result = MessageConverter.toContentBlocks(payload);
1828
assert.equal(result.length, 1);
1929
assert.equal(result[0].type, ContentBlockType.Text);
30+
assert(isTextBlock(result[0]));
2031
assert.equal(result[0].text.value, 'hello world');
2132
assert.deepStrictEqual(result[0].text.annotations, []);
2233
});
@@ -30,20 +41,89 @@ describe('test/MessageConverter.test.ts', () => {
3041
};
3142
const result = MessageConverter.toContentBlocks(payload);
3243
assert.equal(result.length, 2);
44+
assert(isTextBlock(result[0]));
3345
assert.equal(result[0].text.value, 'part1');
46+
assert(isTextBlock(result[1]));
3447
assert.equal(result[1].text.value, 'part2');
3548
});
3649

37-
it('should filter out non-text content parts', () => {
38-
const payload: AgentStreamMessagePayload = {
50+
it('should preserve tool_use content parts', () => {
51+
const payload = {
3952
content: [
40-
{ type: 'text', text: 'keep' },
41-
{ type: 'image' as 'text', text: 'discard' },
53+
{ type: 'text', text: 'Let me call a tool' },
54+
{ type: 'tool_use', id: 'toolu_123', name: 'get_weather', input: { city: 'beijing' } },
4255
],
43-
};
56+
} as unknown as AgentStreamMessagePayload;
57+
const result = MessageConverter.toContentBlocks(payload);
58+
assert.equal(result.length, 2);
59+
60+
assert(isTextBlock(result[0]));
61+
assert.equal(result[0].text.value, 'Let me call a tool');
62+
63+
assert(isToolUseBlock(result[1]));
64+
assert.equal(result[1].id, 'toolu_123');
65+
assert.equal(result[1].name, 'get_weather');
66+
assert.deepStrictEqual(result[1].input, { city: 'beijing' });
67+
});
68+
69+
it('should preserve tool_result content parts', () => {
70+
const payload = {
71+
content: [
72+
{ type: 'tool_result', tool_use_id: 'toolu_123', content: 'sunny, 25°C' },
73+
],
74+
} as unknown as AgentStreamMessagePayload;
75+
const result = MessageConverter.toContentBlocks(payload);
76+
assert.equal(result.length, 1);
77+
78+
assert(isToolResultBlock(result[0]));
79+
assert.equal(result[0].tool_use_id, 'toolu_123');
80+
assert.equal(result[0].content, 'sunny, 25°C');
81+
});
82+
83+
it('should preserve tool_result with is_error flag', () => {
84+
const payload = {
85+
content: [
86+
{ type: 'tool_result', tool_use_id: 'toolu_456', content: 'Tool not found', is_error: true },
87+
],
88+
} as unknown as AgentStreamMessagePayload;
89+
const result = MessageConverter.toContentBlocks(payload);
90+
assert.equal(result.length, 1);
91+
92+
assert(isToolResultBlock(result[0]));
93+
assert.equal((result[0] as ToolResultContentBlock).is_error, true);
94+
});
95+
96+
it('should preserve unknown content types as generic blocks', () => {
97+
const payload = {
98+
content: [
99+
{ type: 'thinking', thinking: 'let me think...' },
100+
],
101+
} as unknown as AgentStreamMessagePayload;
44102
const result = MessageConverter.toContentBlocks(payload);
45103
assert.equal(result.length, 1);
46-
assert.equal(result[0].text.value, 'keep');
104+
assert.equal(result[0].type, 'thinking');
105+
assert.equal((result[0] as any).thinking, 'let me think...');
106+
});
107+
108+
it('should preserve mixed content types in order', () => {
109+
const payload = {
110+
content: [
111+
{ type: 'text', text: 'I will search for you' },
112+
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'test' } },
113+
{ type: 'text', text: 'Here are the results' },
114+
],
115+
} as unknown as AgentStreamMessagePayload;
116+
const result = MessageConverter.toContentBlocks(payload);
117+
assert.equal(result.length, 3);
118+
119+
assert(isTextBlock(result[0]));
120+
assert.equal(result[0].text.value, 'I will search for you');
121+
122+
assert(isToolUseBlock(result[1]));
123+
assert.equal(result[1].name, 'search');
124+
125+
assert(isTextBlock(result[2]));
126+
assert.equal(result[2].text.value, 'Here are the results');
47127
});
48128

49129
it('should return empty array for non-string non-array content', () => {
@@ -66,6 +146,7 @@ describe('test/MessageConverter.test.ts', () => {
66146
assert.equal(typeof msg.createdAt, 'number');
67147
const content = msg.content;
68148
assert.equal(content.length, 1);
149+
assert(isTextBlock(content[0]));
69150
assert.equal(content[0].text.value, 'reply');
70151
});
71152

@@ -74,6 +155,20 @@ describe('test/MessageConverter.test.ts', () => {
74155
const msg = MessageConverter.toMessageObject(payload);
75156
assert.equal(msg.runId, undefined);
76157
});
158+
159+
it('should preserve tool_use blocks in message object', () => {
160+
const payload = {
161+
content: [
162+
{ type: 'text', text: 'calling tool' },
163+
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'test' } },
164+
],
165+
} as unknown as AgentStreamMessagePayload;
166+
const msg = MessageConverter.toMessageObject(payload, 'run_1');
167+
assert.equal(msg.content.length, 2);
168+
assert(isTextBlock(msg.content[0]));
169+
assert(isToolUseBlock(msg.content[1]));
170+
assert.equal((msg.content[1] as ToolUseContentBlock).name, 'search');
171+
});
77172
});
78173

79174
describe('createStreamMessage', () => {
@@ -99,14 +194,34 @@ describe('test/MessageConverter.test.ts', () => {
99194
const { output, usage } = MessageConverter.extractFromStreamMessages(messages, 'run_1');
100195

101196
assert.equal(output.length, 2);
197+
assert(isTextBlock(output[0].content[0]));
102198
assert.equal(output[0].content[0].text.value, 'chunk1');
199+
assert(isTextBlock(output[1].content[0]));
103200
assert.equal(output[1].content[0].text.value, 'chunk2');
104201
assert.ok(usage);
105202
assert.equal(usage.promptTokens, 10);
106203
assert.equal(usage.completionTokens, 13);
107204
assert.equal(usage.totalTokens, 23);
108205
});
109206

207+
it('should extract messages with tool_use content', () => {
208+
const messages: AgentStreamMessage[] = [
209+
{
210+
message: {
211+
content: [
212+
{ type: 'text', text: 'let me search' },
213+
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'test' } },
214+
] as any,
215+
},
216+
},
217+
];
218+
const { output } = MessageConverter.extractFromStreamMessages(messages, 'run_1');
219+
assert.equal(output.length, 1);
220+
assert.equal(output[0].content.length, 2);
221+
assert(isTextBlock(output[0].content[0]));
222+
assert(isToolUseBlock(output[0].content[1]));
223+
});
224+
110225
it('should return undefined usage when no usage info', () => {
111226
const messages: AgentStreamMessage[] = [{ message: { content: 'data' } }];
112227
const { output, usage } = MessageConverter.extractFromStreamMessages(messages);
@@ -143,6 +258,7 @@ describe('test/MessageConverter.test.ts', () => {
143258
assert.equal(result[1].role, MessageRole.Assistant);
144259

145260
const content0 = result[0].content;
261+
assert(isTextBlock(content0[0]));
146262
assert.equal(content0[0].text.value, 'hi');
147263
});
148264

@@ -169,14 +285,79 @@ describe('test/MessageConverter.test.ts', () => {
169285
const result = MessageConverter.toInputMessageObjects(messages);
170286
const content = result[0].content;
171287
assert.equal(content.length, 2);
288+
assert(isTextBlock(content[0]));
172289
assert.equal(content[0].text.value, 'part1');
290+
assert(isTextBlock(content[1]));
173291
assert.equal(content[1].text.value, 'part2');
174292
});
175293

294+
it('should preserve tool_use blocks in input messages', () => {
295+
const messages = [
296+
{
297+
role: MessageRole.Assistant as MessageRole,
298+
content: [
299+
{ type: 'text', text: 'I will search for you' },
300+
{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'test' } },
301+
] as any,
302+
},
303+
];
304+
const result = MessageConverter.toInputMessageObjects(messages);
305+
assert.equal(result[0].content.length, 2);
306+
assert(isTextBlock(result[0].content[0]));
307+
assert.equal(result[0].content[0].text.value, 'I will search for you');
308+
assert(isToolUseBlock(result[0].content[1]));
309+
assert.equal((result[0].content[1] as ToolUseContentBlock).name, 'search');
310+
});
311+
312+
it('should preserve tool_result blocks in input messages', () => {
313+
const messages = [
314+
{
315+
role: MessageRole.User as MessageRole,
316+
content: [
317+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'search result here' },
318+
] as any,
319+
},
320+
];
321+
const result = MessageConverter.toInputMessageObjects(messages);
322+
assert.equal(result[0].content.length, 1);
323+
assert(isToolResultBlock(result[0].content[0]));
324+
assert.equal((result[0].content[0] as ToolResultContentBlock).tool_use_id, 'toolu_1');
325+
assert.equal((result[0].content[0] as ToolResultContentBlock).content, 'search result here');
326+
});
327+
176328
it('should work without threadId', () => {
177329
const messages = [{ role: MessageRole.User as MessageRole, content: 'hi' }];
178330
const result = MessageConverter.toInputMessageObjects(messages);
179331
assert.equal(result[0].threadId, undefined);
180332
});
181333
});
334+
335+
describe('type guards', () => {
336+
it('isTextBlock should narrow to TextContentBlock', () => {
337+
const block = { type: ContentBlockType.Text, text: { value: 'hello', annotations: [] } };
338+
assert(isTextBlock(block));
339+
assert.equal(block.text.value, 'hello');
340+
});
341+
342+
it('isToolUseBlock should narrow to ToolUseContentBlock', () => {
343+
const block = { type: ContentBlockType.ToolUse, id: 'toolu_1', name: 'search', input: { q: 'test' } };
344+
assert(isToolUseBlock(block));
345+
assert.equal(block.name, 'search');
346+
});
347+
348+
it('isToolResultBlock should narrow to ToolResultContentBlock', () => {
349+
const block = { type: ContentBlockType.ToolResult, tool_use_id: 'toolu_1', content: 'result' };
350+
assert(isToolResultBlock(block));
351+
assert.equal(block.tool_use_id, 'toolu_1');
352+
});
353+
354+
it('type guards should return false for non-matching blocks', () => {
355+
const textBlock = { type: ContentBlockType.Text, text: { value: 'hi', annotations: [] } };
356+
const toolUseBlock = { type: ContentBlockType.ToolUse, id: 'id', name: 'n', input: {} };
357+
358+
assert(!isToolUseBlock(textBlock));
359+
assert(!isToolResultBlock(textBlock));
360+
assert(!isTextBlock(toolUseBlock));
361+
});
362+
});
182363
});

core/tegg/agent.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,19 @@ export type {
1818
MessageObject,
1919
InputMessage,
2020
InputContentPart,
21+
TextInputContentPart,
22+
ToolUseInputContentPart,
23+
ToolResultInputContentPart,
24+
GenericInputContentPart,
2125
MessageContentBlock,
2226
TextContentBlock,
27+
ToolUseContentBlock,
28+
ToolResultContentBlock,
29+
GenericContentBlock,
2330
MessageDeltaObject,
2431
AgentRunConfig,
2532
AgentRunUsage,
2633
RunStatus,
2734
} from '@eggjs/agent-runtime';
35+
36+
export { isTextBlock, isToolUseBlock, isToolResultBlock } from '@eggjs/agent-runtime';

0 commit comments

Comments
 (0)