🐛 fix(model-runtime): handle null content in anthropic message builder (#11756)

* 🐛 fix(model-runtime): handle null content in anthropic message builder

Fix TypeError when building Anthropic messages with null content:
- Handle assistant messages with tool_calls but null content
- Handle tool messages with null or empty string content
- Use '<empty_content>' placeholder for null/empty content

Add 3 test cases covering the null content scenarios.

Closes: LOBE-4201, LOBE-2715

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 🐛 fix(model-runtime): handle array content in tool messages

Tool messages may have array content, not just string. Use
buildArrayContent to properly process array content in tool results.

Add test case for tool message with array content.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 🐛 fix(model-runtime): filter out null/empty content in assistant messages

When assistant message has tool_calls but null/empty content, filter
out the empty text block instead of using placeholder. Only tool_use
blocks remain in the content array.

Add test case for empty string content scenario.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

*  test(model-runtime): add test case for tool message with image content

Add test case to verify tool message with array content containing
both text and image is correctly processed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

*  test(model-runtime): add tests for orphan tool message with null/empty content

Add test cases for tool messages without corresponding assistant
tool_call when content is null or empty string.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-01-24 01:57:33 +08:00
committed by GitHub
parent fdc8f957bc
commit 539753aa75
2 changed files with 388 additions and 5 deletions

View File

@@ -281,6 +281,62 @@ describe('anthropicHelpers', () => {
const result = await buildAnthropicMessage(message);
expect(result).toBeUndefined();
});
it('should handle assistant message with tool_calls but null content', async () => {
const message: OpenAIChatMessage = {
content: null as any,
role: 'assistant',
tool_calls: [
{
id: 'call1',
type: 'function',
function: {
name: 'search_people',
arguments: '{"location":"Singapore"}',
},
},
],
};
const result = await buildAnthropicMessage(message);
expect(result!.role).toBe('assistant');
// null content should be filtered out, only tool_use remains
expect(result!.content).toEqual([
{
id: 'call1',
input: { location: 'Singapore' },
name: 'search_people',
type: 'tool_use',
},
]);
});
it('should handle assistant message with tool_calls but empty string content', async () => {
const message: OpenAIChatMessage = {
content: '',
role: 'assistant',
tool_calls: [
{
id: 'call1',
type: 'function',
function: {
name: 'search_people',
arguments: '{"location":"Singapore"}',
},
},
],
};
const result = await buildAnthropicMessage(message);
expect(result!.role).toBe('assistant');
// empty string content should be filtered out, only tool_use remains
expect(result!.content).toEqual([
{
id: 'call1',
input: { location: 'Singapore' },
name: 'search_people',
type: 'tool_use',
},
]);
});
});
describe('buildAnthropicMessages', () => {
@@ -526,6 +582,320 @@ describe('anthropicHelpers', () => {
]);
});
it('should handle tool message with null content', async () => {
const messages: OpenAIChatMessage[] = [
{
content: '搜索人员',
role: 'user',
},
{
content: '正在搜索...',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"location": "Singapore"}',
name: 'search_people',
},
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'function',
},
],
},
{
content: null as any,
name: 'search_people',
role: 'tool',
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{ content: '搜索人员', role: 'user' },
{
content: [
{ text: '正在搜索...', type: 'text' },
{
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
input: { location: 'Singapore' },
name: 'search_people',
type: 'tool_use',
},
],
role: 'assistant',
},
{
content: [
{
content: [{ text: '<empty_content>', type: 'text' }],
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'tool_result',
},
],
role: 'user',
},
]);
});
it('should handle tool message with empty string content', async () => {
const messages: OpenAIChatMessage[] = [
{
content: '搜索人员',
role: 'user',
},
{
content: '正在搜索...',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"location": "Singapore"}',
name: 'search_people',
},
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'function',
},
],
},
{
content: '',
name: 'search_people',
role: 'tool',
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{ content: '搜索人员', role: 'user' },
{
content: [
{ text: '正在搜索...', type: 'text' },
{
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
input: { location: 'Singapore' },
name: 'search_people',
type: 'tool_use',
},
],
role: 'assistant',
},
{
content: [
{
content: [{ text: '<empty_content>', type: 'text' }],
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'tool_result',
},
],
role: 'user',
},
]);
});
it('should handle tool message with array content', async () => {
const messages: OpenAIChatMessage[] = [
{
content: '搜索人员',
role: 'user',
},
{
content: '正在搜索...',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"location": "Singapore"}',
name: 'search_people',
},
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'function',
},
],
},
{
content: [
{ type: 'text', text: 'Found 5 candidates' },
{ type: 'text', text: 'Result details here' },
] as any,
name: 'search_people',
role: 'tool',
tool_call_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{ content: '搜索人员', role: 'user' },
{
content: [
{ text: '正在搜索...', type: 'text' },
{
id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
input: { location: 'Singapore' },
name: 'search_people',
type: 'tool_use',
},
],
role: 'assistant',
},
{
content: [
{
content: [
{ type: 'text', text: 'Found 5 candidates' },
{ type: 'text', text: 'Result details here' },
],
tool_use_id: 'toolu_01CnXPcBEqsGGbvRriem3Rth',
type: 'tool_result',
},
],
role: 'user',
},
]);
});
it('should handle tool message with array content containing image', async () => {
vi.mocked(parseDataUri).mockReturnValueOnce({
mimeType: 'image/png',
base64: 'screenshotBase64Data',
type: 'base64',
});
const messages: OpenAIChatMessage[] = [
{
content: '截图分析',
role: 'user',
},
{
content: '正在截图...',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{"url": "https://example.com"}',
name: 'screenshot',
},
id: 'toolu_screenshot_123',
type: 'function',
},
],
},
{
content: [
{ type: 'text', text: 'Screenshot captured' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,screenshotBase64Data' },
},
] as any,
name: 'screenshot',
role: 'tool',
tool_call_id: 'toolu_screenshot_123',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{ content: '截图分析', role: 'user' },
{
content: [
{ text: '正在截图...', type: 'text' },
{
id: 'toolu_screenshot_123',
input: { url: 'https://example.com' },
name: 'screenshot',
type: 'tool_use',
},
],
role: 'assistant',
},
{
content: [
{
content: [
{ type: 'text', text: 'Screenshot captured' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'screenshotBase64Data',
},
},
],
tool_use_id: 'toolu_screenshot_123',
type: 'tool_result',
},
],
role: 'user',
},
]);
});
it('should handle orphan tool message with null content', async () => {
// Tool message without corresponding assistant tool_call
const messages: OpenAIChatMessage[] = [
{
content: null as any,
name: 'some_tool',
role: 'tool',
tool_call_id: 'orphan_tool_call_id',
},
{
content: 'Continue',
role: 'user',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{
content: '<empty_content>',
role: 'user',
},
{
content: 'Continue',
role: 'user',
},
]);
});
it('should handle orphan tool message with empty string content', async () => {
// Tool message without corresponding assistant tool_call
const messages: OpenAIChatMessage[] = [
{
content: '',
name: 'some_tool',
role: 'tool',
tool_call_id: 'orphan_tool_call_id',
},
{
content: 'Continue',
role: 'user',
},
];
const contents = await buildAnthropicMessages(messages);
expect(contents).toEqual([
{
content: '<empty_content>',
role: 'user',
},
{
content: 'Continue',
role: 'user',
},
]);
});
it('should work well starting with tool message', async () => {
const messages: OpenAIChatMessage[] = [
{

View File

@@ -114,10 +114,13 @@ export const buildAnthropicMessage = async (
// if there is tool_calls , we need to covert the tool_calls to tool_use content block
// refs: https://docs.anthropic.com/claude/docs/tool-use#tool-use-and-tool-result-content-blocks
if (message.tool_calls && message.tool_calls.length > 0) {
// Handle content: string with text, array, null/undefined/empty -> filter out
const rawContent =
typeof content === 'string'
? ([{ text: message.content, type: 'text' }] as UserMessageContentPart[])
: content;
typeof content === 'string' && content.trim()
? ([{ text: content, type: 'text' }] as UserMessageContentPart[])
: Array.isArray(content)
? content
: []; // null/undefined/empty string -> empty array (will be filtered)
const messageContent = await buildArrayContent(rawContent);
@@ -180,10 +183,17 @@ export const buildAnthropicMessages = async (
// refs: https://docs.anthropic.com/claude/docs/tool-use#tool-use-and-tool-result-content-blocks
if (message.role === 'tool') {
// Handle different content types in tool messages
const toolResultContent = Array.isArray(message.content)
? await buildArrayContent(message.content)
: !message.content
? [{ text: '<empty_content>', type: 'text' as const }]
: [{ text: message.content, type: 'text' as const }];
// 检查这个工具消息是否有对应的 assistant 工具调用
if (message.tool_call_id && validToolCallIds.has(message.tool_call_id)) {
pendingToolResults.push({
content: [{ text: message.content as string, type: 'text' }],
content: toolResultContent as Anthropic.ToolResultBlockParam['content'],
tool_use_id: message.tool_call_id,
type: 'tool_result',
});
@@ -198,8 +208,11 @@ export const buildAnthropicMessages = async (
}
} else {
// 如果工具消息没有对应的 assistant 工具调用,则作为普通文本处理
const fallbackContent = Array.isArray(message.content)
? JSON.stringify(message.content)
: message.content || '<empty_content>';
messages.push({
content: message.content as string,
content: fallbackContent,
role: 'user',
});
}