fix: emit subagent user message and correct systemMessage properties

This commit is contained in:
mingholy.lmh
2025-11-06 15:26:44 +08:00
parent edb4b36408
commit 38ea6e1c74
6 changed files with 231 additions and 23 deletions

View File

@@ -23,6 +23,7 @@ import {
type MessageState,
type ResultOptions,
partsToString,
partsToContentBlock,
toolResultContent,
extractTextFromBlocks,
createExtendedUsage,
@@ -853,7 +854,7 @@ describe('BaseJsonOutputAdapter', () => {
});
describe('emitUserMessage', () => {
it('should emit user message', () => {
it('should emit user message with ContentBlock array', () => {
const parts: Part[] = [{ text: 'Hello user' }];
adapter.emitUserMessage(parts);
@@ -862,23 +863,34 @@ describe('BaseJsonOutputAdapter', () => {
const message = adapter.emittedMessages[0];
expect(message.type).toBe('user');
if (message.type === 'user') {
expect(message.message.content).toBe('Hello user');
expect(Array.isArray(message.message.content)).toBe(true);
if (Array.isArray(message.message.content)) {
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
expect(message.parent_tool_use_id).toBeNull();
}
});
it('should handle multiple parts', () => {
it('should handle multiple parts and merge into single text block', () => {
const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }];
adapter.emitUserMessage(parts);
const message = adapter.emittedMessages[0];
if (message.type === 'user') {
expect(message.message.content).toBe('Hello World');
if (message.type === 'user' && Array.isArray(message.message.content)) {
expect(message.message.content).toHaveLength(1);
expect(message.message.content[0]).toEqual({
type: 'text',
text: 'Hello World',
});
}
});
it('should handle non-text parts', () => {
it('should handle non-text parts by converting to text blocks', () => {
const parts: Part[] = [
{ text: 'Hello' },
{ functionCall: { name: 'test' } },
@@ -887,8 +899,15 @@ describe('BaseJsonOutputAdapter', () => {
adapter.emitUserMessage(parts);
const message = adapter.emittedMessages[0];
if (message.type === 'user') {
expect(message.message.content).toContain('Hello');
if (message.type === 'user' && Array.isArray(message.message.content)) {
expect(message.message.content.length).toBeGreaterThan(0);
const textBlock = message.message.content.find(
(block) => block.type === 'text',
);
expect(textBlock).toBeDefined();
if (textBlock && textBlock.type === 'text') {
expect(textBlock.text).toContain('Hello');
}
}
});
});
@@ -1324,6 +1343,79 @@ describe('BaseJsonOutputAdapter', () => {
});
describe('helper functions', () => {
describe('partsToContentBlock', () => {
it('should convert text parts to TextBlock array', () => {
const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }];
const result = partsToContentBlock(parts);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
text: 'Hello World',
});
});
it('should handle functionResponse parts by extracting output', () => {
const parts: Part[] = [
{ text: 'Result: ' },
{
functionResponse: {
name: 'test',
response: { output: 'function output' },
},
},
];
const result = partsToContentBlock(parts);
expect(result).toHaveLength(1);
expect(result[0].type).toBe('text');
if (result[0].type === 'text') {
expect(result[0].text).toBe('Result: function output');
}
});
it('should handle non-text parts by converting to JSON string', () => {
const parts: Part[] = [
{ text: 'Hello' },
{ functionCall: { name: 'test' } },
];
const result = partsToContentBlock(parts);
expect(result.length).toBeGreaterThan(0);
const textBlock = result.find((block) => block.type === 'text');
expect(textBlock).toBeDefined();
if (textBlock && textBlock.type === 'text') {
expect(textBlock.text).toContain('Hello');
expect(textBlock.text).toContain('functionCall');
}
});
it('should handle empty array', () => {
const result = partsToContentBlock([]);
expect(result).toEqual([]);
});
it('should merge consecutive text parts into single block', () => {
const parts: Part[] = [
{ text: 'Part 1' },
{ text: 'Part 2' },
{ text: 'Part 3' },
];
const result = partsToContentBlock(parts);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'text',
text: 'Part 1Part 2Part 3',
});
});
});
describe('partsToString', () => {
it('should convert text parts to string', () => {
const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }];

View File

@@ -71,7 +71,7 @@ export interface ResultOptions {
*/
export interface MessageEmitter {
emitMessage(message: CLIMessage): void;
emitUserMessage(parts: Part[]): void;
emitUserMessage(parts: Part[], parentToolUseId?: string | null): void;
emitToolResult(
request: ToolCallRequestInfo,
response: ToolCallResponseInfo,
@@ -922,14 +922,15 @@ export abstract class BaseJsonOutputAdapter {
/**
* Emits a user message.
* @param parts - Array of Part objects
* @param parentToolUseId - Optional parent tool use ID for subagent messages
*/
emitUserMessage(parts: Part[]): void {
const content = partsToString(parts);
emitUserMessage(parts: Part[], parentToolUseId?: string | null): void {
const content = partsToContentBlock(parts);
const message: CLIUserMessage = {
type: 'user',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
parent_tool_use_id: parentToolUseId ?? null,
message: {
role: 'user',
content,
@@ -1100,8 +1101,63 @@ export abstract class BaseJsonOutputAdapter {
}
}
/**
* Converts Part array to ContentBlock array.
* Handles various Part types including text, functionResponse, and other types.
* For functionResponse parts, extracts the output content.
* For other non-text parts, converts them to text representation.
*
* @param parts - Array of Part objects
* @returns Array of ContentBlock objects (primarily TextBlock)
*/
export function partsToContentBlock(parts: Part[]): ContentBlock[] {
const blocks: ContentBlock[] = [];
let currentTextBlock: TextBlock | null = null;
for (const part of parts) {
let textContent: string | null = null;
// Handle text parts
if ('text' in part && typeof part.text === 'string') {
textContent = part.text;
}
// Handle functionResponse parts - extract output content
else if ('functionResponse' in part && part.functionResponse) {
const output =
part.functionResponse.response?.['output'] ??
part.functionResponse.response?.['content'] ??
'';
textContent =
typeof output === 'string' ? output : JSON.stringify(output);
}
// Handle other part types - convert to JSON string
else {
textContent = JSON.stringify(part);
}
// If we have text content, add it to the current text block or create a new one
if (textContent !== null && textContent.length > 0) {
if (currentTextBlock === null) {
currentTextBlock = {
type: 'text',
text: textContent,
};
blocks.push(currentTextBlock);
} else {
// Append to existing text block
currentTextBlock.text += textContent;
}
}
}
// Return blocks array, or empty array if no content
return blocks;
}
/**
* Converts Part array to string representation.
* This is a legacy function kept for backward compatibility.
* For new code, prefer using partsToContentBlock.
*
* @param parts - Array of Part objects
* @returns String representation

View File

@@ -556,7 +556,14 @@ describe('JsonOutputAdapter', () => {
);
expect(userMessage).toBeDefined();
expect(userMessage.message.content).toBe('Hello user');
expect(Array.isArray(userMessage.message.content)).toBe(true);
if (Array.isArray(userMessage.message.content)) {
expect(userMessage.message.content).toHaveLength(1);
expect(userMessage.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {

View File

@@ -714,7 +714,14 @@ describe('StreamJsonOutputAdapter', () => {
const parsed = JSON.parse(output);
expect(parsed.type).toBe('user');
expect(parsed.message.content).toBe('Hello user');
expect(Array.isArray(parsed.message.content)).toBe(true);
if (Array.isArray(parsed.message.content)) {
expect(parsed.message.content).toHaveLength(1);
expect(parsed.message.content[0]).toEqual({
type: 'text',
text: 'Hello user',
});
}
});
it('should handle parent_tool_use_id', () => {