mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 09:47:47 +00:00
Headless enhancement: add stream-json as input-format/output-format to support programmatically use (#926)
This commit is contained in:
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
1571
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
1228
packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts
Normal file
File diff suppressed because it is too large
Load Diff
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
791
packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts
Normal file
@@ -0,0 +1,791 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('JsonOutputAdapter', () => {
|
||||
let adapter: JsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.startAssistantMessage(); // Start second message
|
||||
// Should not throw
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const event2: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
};
|
||||
adapter.processEvent(event2);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent;
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should finalize pending blocks on Finished event', () => {
|
||||
// Add some text first
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const event: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: undefined },
|
||||
};
|
||||
adapter.processEvent(event);
|
||||
|
||||
// Should not throw when finalizing
|
||||
expect(() => adapter.finalizeAssistantMessage()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const storedMessages = (adapter as unknown as { messages: unknown[] })
|
||||
.messages;
|
||||
const assistantMessages = storedMessages.filter(
|
||||
(
|
||||
msg,
|
||||
): msg is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof msg !== 'object' ||
|
||||
msg === null ||
|
||||
!('type' in msg) ||
|
||||
(msg as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in msg)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (msg as { message?: unknown }).message;
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'content' in message &&
|
||||
Array.isArray((message as { content?: unknown }).content)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
for (const assistant of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
assistant.message.content.map((block) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new JsonOutputAdapter(mockConfig);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result as JSON array', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage).toBeDefined();
|
||||
expect(resultMessage.is_error).toBe(false);
|
||||
expect(resultMessage.subtype).toBe('success');
|
||||
expect(resultMessage.result).toBe('Response text');
|
||||
expect(resultMessage.duration_ms).toBe(1000);
|
||||
expect(resultMessage.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.is_error).toBe(true);
|
||||
expect(resultMessage.subtype).toBe('error_during_execution');
|
||||
expect(resultMessage.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should include stats when provided', () => {
|
||||
const stats = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 5,
|
||||
totalSuccess: 4,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 1000,
|
||||
totalDecisions: {
|
||||
accept: 3,
|
||||
reject: 1,
|
||||
modify: 0,
|
||||
auto_accept: 1,
|
||||
},
|
||||
byName: {},
|
||||
},
|
||||
files: {
|
||||
totalLinesAdded: 10,
|
||||
totalLinesRemoved: 5,
|
||||
},
|
||||
};
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
stats,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const resultMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'result',
|
||||
);
|
||||
|
||||
expect(resultMessage.stats).toEqual(stats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
it('should add user message to collection', () => {
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
expect(userMessage).toBeDefined();
|
||||
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', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const userMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user',
|
||||
);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(userMessage.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
it('should emit tool result message', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content) &&
|
||||
msg.message.content[0] &&
|
||||
typeof msg.message.content[0] === 'object' &&
|
||||
'type' in msg.message.content[0] &&
|
||||
msg.message.content[0].type === 'tool_result',
|
||||
);
|
||||
|
||||
expect(toolResult).toBeDefined();
|
||||
const block = toolResult.message.content[0] as {
|
||||
type: 'tool_result';
|
||||
tool_use_id: string;
|
||||
content?: string;
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const toolResult = parsed.find(
|
||||
(
|
||||
msg: unknown,
|
||||
): msg is { type: 'user'; message: { content: unknown[] } } =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'user' &&
|
||||
'message' in msg &&
|
||||
typeof msg.message === 'object' &&
|
||||
msg.message !== null &&
|
||||
'content' in msg.message &&
|
||||
Array.isArray(msg.message.content),
|
||||
);
|
||||
|
||||
const block = toolResult.message.content[0] as {
|
||||
is_error?: boolean;
|
||||
};
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
it('should add system message to collection', () => {
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
const systemMessage = parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
msg.type === 'system',
|
||||
);
|
||||
|
||||
expect(systemMessage).toBeDefined();
|
||||
expect(systemMessage.subtype).toBe('test_subtype');
|
||||
expect(systemMessage.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple messages in collection', () => {
|
||||
it('should collect all messages and emit as array', () => {
|
||||
adapter.emitSystemMessage('init', {});
|
||||
adapter.emitUserMessage([{ text: 'User input' }]);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Assistant response',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(Array.isArray(parsed)).toBe(true);
|
||||
expect(parsed.length).toBeGreaterThanOrEqual(3);
|
||||
const systemMsg = parsed[0] as { type?: string };
|
||||
const userMsg = parsed[1] as { type?: string };
|
||||
expect(systemMsg.type).toBe('system');
|
||||
expect(userMsg.type).toBe('user');
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'assistant',
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
parsed.find(
|
||||
(msg: unknown) =>
|
||||
typeof msg === 'object' &&
|
||||
msg !== null &&
|
||||
'type' in msg &&
|
||||
(msg as { type?: string }).type === 'result',
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
81
packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { CLIAssistantMessage, CLIMessage } from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type JsonOutputAdapterInterface,
|
||||
type ResultOptions,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* JSON output adapter that collects all messages and emits them
|
||||
* as a single JSON array at the end of the turn.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class JsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
private readonly messages: CLIMessage[] = [];
|
||||
|
||||
constructor(config: Config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message to the messages array (batch mode).
|
||||
* Tracks the last assistant message for efficient result text extraction.
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage): void {
|
||||
this.messages.push(message);
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON mode does not emit stream events.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const message = this.finalizeAssistantMessageInternal(
|
||||
this.mainAgentMessageState,
|
||||
null,
|
||||
);
|
||||
this.updateLastAssistantMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.messages.push(resultMessage);
|
||||
|
||||
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
|
||||
const json = JSON.stringify(this.messages);
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage): void {
|
||||
// In JSON mode, messages are collected in the messages array
|
||||
// This is called by the base class's finalizeAssistantMessageInternal
|
||||
// but can also be called directly for user/tool/system messages
|
||||
this.messages.push(message);
|
||||
}
|
||||
}
|
||||
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
215
packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
StreamJsonInputReader,
|
||||
StreamJsonParseError,
|
||||
type StreamJsonInputMessage,
|
||||
} from './StreamJsonInputReader.js';
|
||||
|
||||
describe('StreamJsonInputReader', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
/**
|
||||
* Test parsing all supported message types in a single test
|
||||
*/
|
||||
it('should parse valid messages of all types', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
},
|
||||
{
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
},
|
||||
{
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: 'req-1',
|
||||
response: { initialized: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'control_cancel_request',
|
||||
request_id: 'req-1',
|
||||
},
|
||||
];
|
||||
|
||||
for (const msg of messages) {
|
||||
input.write(JSON.stringify(msg) + '\n');
|
||||
}
|
||||
input.end();
|
||||
|
||||
const parsed: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
parsed.push(msg);
|
||||
}
|
||||
|
||||
expect(parsed).toHaveLength(messages.length);
|
||||
expect(parsed).toEqual(messages);
|
||||
});
|
||||
|
||||
it('should parse multiple messages', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message1 = {
|
||||
type: 'control_request',
|
||||
request_id: 'req-1',
|
||||
request: { subtype: 'initialize' },
|
||||
};
|
||||
|
||||
const message2 = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write(JSON.stringify(message1) + '\n');
|
||||
input.write(JSON.stringify(message2) + '\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toEqual(message1);
|
||||
expect(messages[1]).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should skip empty lines and trim whitespace', async () => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
const message = {
|
||||
type: 'user',
|
||||
session_id: 'test-session',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
input.write('\n');
|
||||
input.write(' ' + JSON.stringify(message) + ' \n');
|
||||
input.write(' \n');
|
||||
input.write('\t\n');
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toEqual(message);
|
||||
});
|
||||
|
||||
/**
|
||||
* Consolidated error handling test cases
|
||||
*/
|
||||
it.each([
|
||||
{
|
||||
name: 'invalid JSON',
|
||||
input: '{"invalid": json}\n',
|
||||
expectedError: 'Failed to parse stream-json line',
|
||||
},
|
||||
{
|
||||
name: 'missing type field',
|
||||
input:
|
||||
JSON.stringify({ session_id: 'test-session', message: 'hello' }) +
|
||||
'\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (string)',
|
||||
input: '"just a string"\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'non-object value (null)',
|
||||
input: 'null\n',
|
||||
expectedError: 'Parsed value is not an object',
|
||||
},
|
||||
{
|
||||
name: 'array value',
|
||||
input: '[1, 2, 3]\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
{
|
||||
name: 'type field not a string',
|
||||
input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n',
|
||||
expectedError: 'Missing required "type" field',
|
||||
},
|
||||
])(
|
||||
'should throw StreamJsonParseError for $name',
|
||||
async ({ input: inputLine, expectedError }) => {
|
||||
const input = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(input);
|
||||
|
||||
input.write(inputLine);
|
||||
input.end();
|
||||
|
||||
const messages: StreamJsonInputMessage[] = [];
|
||||
let error: unknown;
|
||||
|
||||
try {
|
||||
for await (const msg of reader.read()) {
|
||||
messages.push(msg);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(messages).toHaveLength(0);
|
||||
expect(error).toBeInstanceOf(StreamJsonParseError);
|
||||
expect((error as StreamJsonParseError).message).toContain(
|
||||
expectedError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('should use process.stdin as default input', () => {
|
||||
const reader = new StreamJsonInputReader();
|
||||
// Access private field for testing constructor default parameter
|
||||
expect((reader as unknown as { input: typeof process.stdin }).input).toBe(
|
||||
process.stdin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided input stream', () => {
|
||||
const customInput = new PassThrough();
|
||||
const reader = new StreamJsonInputReader(customInput);
|
||||
// Access private field for testing constructor parameter
|
||||
expect((reader as unknown as { input: typeof customInput }).input).toBe(
|
||||
customInput,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
73
packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import type { Readable } from 'node:stream';
|
||||
import process from 'node:process';
|
||||
import type {
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
CLIMessage,
|
||||
ControlCancelRequest,
|
||||
} from '../types.js';
|
||||
|
||||
export type StreamJsonInputMessage =
|
||||
| CLIMessage
|
||||
| CLIControlRequest
|
||||
| CLIControlResponse
|
||||
| ControlCancelRequest;
|
||||
|
||||
export class StreamJsonParseError extends Error {}
|
||||
|
||||
export class StreamJsonInputReader {
|
||||
private readonly input: Readable;
|
||||
|
||||
constructor(input: Readable = process.stdin) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
async *read(): AsyncGenerator<StreamJsonInputMessage> {
|
||||
const rl = createInterface({
|
||||
input: this.input,
|
||||
crlfDelay: Number.POSITIVE_INFINITY,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const rawLine of rl) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield this.parse(line);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
private parse(line: string): StreamJsonInputMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as StreamJsonInputMessage;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new StreamJsonParseError('Parsed value is not an object');
|
||||
}
|
||||
if (!('type' in parsed) || typeof parsed.type !== 'string') {
|
||||
throw new StreamJsonParseError('Missing required "type" field');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof StreamJsonParseError) {
|
||||
throw error;
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new StreamJsonParseError(
|
||||
`Failed to parse stream-json line: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type {
|
||||
Config,
|
||||
ServerGeminiStreamEvent,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { GeminiEventType } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js';
|
||||
|
||||
function createMockConfig(): Config {
|
||||
return {
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('StreamJsonOutputAdapter', () => {
|
||||
let adapter: StreamJsonOutputAdapter;
|
||||
let mockConfig: Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let stdoutWriteSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = createMockConfig();
|
||||
stdoutWriteSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('with partial messages enabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
});
|
||||
|
||||
describe('startAssistantMessage', () => {
|
||||
it('should reset state for new message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Second',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Second',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent with stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit stream events for text deltas', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
|
||||
const deltaEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaEventCall).toBeDefined();
|
||||
const parsed = JSON.parse(deltaEventCall![0] as string);
|
||||
expect(parsed.event.type).toBe('content_block_delta');
|
||||
expect(parsed.event.delta).toMatchObject({
|
||||
type: 'text_delta',
|
||||
text: 'Hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit message_start event on first content', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'First',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit content_block_start for new blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const blockStartCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_start'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(blockStartCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit thinking delta events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking',
|
||||
},
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const deltaCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta' &&
|
||||
parsed.event.delta.type === 'thinking_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('should emit message_stop on finalization', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const messageStopCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'message_stop'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(messageStopCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with partial messages disabled', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should not emit stream events', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const streamEventCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'stream_event';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(streamEventCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still emit final assistant message', () => {
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
const assistantCall = calls.find((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return parsed.type === 'assistant';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(assistantCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('processEvent', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should append text content from Content events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append citation content from Citation events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 'Citation text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: expect.stringContaining('Citation text'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-string citation values', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Citation,
|
||||
value: 123,
|
||||
} as unknown as ServerGeminiStreamEvent);
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should append thinking from Thought events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
thinking: 'Planning: Thinking about the task',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinking with only subject', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'thinking',
|
||||
signature: 'Planning',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append tool use from ToolCallRequest events', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'tool_use',
|
||||
id: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
input: { param1: 'value1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains only tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains text blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Some text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to null when message contains thinking blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: {
|
||||
subject: 'Planning',
|
||||
description: 'Thinking about the task',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.stop_reason).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-1',
|
||||
name: 'test_tool_1',
|
||||
args: { param1: 'value1' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'tool-call-2',
|
||||
name: 'test_tool_2',
|
||||
args: { param2: 'value2' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(2);
|
||||
expect(
|
||||
message.message.content.every((block) => block.type === 'tool_use'),
|
||||
).toBe(true);
|
||||
expect(message.message.stop_reason).toBe('tool_use');
|
||||
});
|
||||
|
||||
it('should update usage from Finished event', () => {
|
||||
const usageMetadata = {
|
||||
promptTokenCount: 100,
|
||||
candidatesTokenCount: 50,
|
||||
cachedContentTokenCount: 10,
|
||||
totalTokenCount: 160,
|
||||
};
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: undefined,
|
||||
usageMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.usage).toMatchObject({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 10,
|
||||
total_tokens: 160,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore events after finalization', () => {
|
||||
adapter.finalizeAssistantMessage();
|
||||
const originalContent =
|
||||
adapter.finalizeAssistantMessage().message.content;
|
||||
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Should be ignored',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toEqual(originalContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeAssistantMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should build and emit a complete assistant message', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test response',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message.type).toBe('assistant');
|
||||
expect(message.uuid).toBeTruthy();
|
||||
expect(message.session_id).toBe('test-session-id');
|
||||
expect(message.parent_tool_use_id).toBeNull();
|
||||
expect(message.message.role).toBe('assistant');
|
||||
expect(message.message.model).toBe('test-model');
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should emit message to stdout immediately', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.type).toBe('assistant');
|
||||
});
|
||||
|
||||
it('should store message in lastAssistantMessage', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
// Access protected property for testing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((adapter as any).lastAssistantMessage).toEqual(message);
|
||||
});
|
||||
|
||||
it('should return same message on subsequent calls', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Test',
|
||||
});
|
||||
|
||||
const message1 = adapter.finalizeAssistantMessage();
|
||||
const message2 = adapter.finalizeAssistantMessage();
|
||||
|
||||
expect(message1).toEqual(message2);
|
||||
});
|
||||
|
||||
it('should split different block types into separate assistant messages', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0].type).toBe('thinking');
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(payload: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string }> };
|
||||
}) => payload.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking']);
|
||||
for (const payload of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
payload.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw if message not started', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
expect(() => adapter.finalizeAssistantMessage()).toThrow(
|
||||
'Message not started',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Response text',
|
||||
});
|
||||
adapter.finalizeAssistantMessage();
|
||||
});
|
||||
|
||||
it('should emit success result immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('result');
|
||||
expect(parsed.is_error).toBe(false);
|
||||
expect(parsed.subtype).toBe('success');
|
||||
expect(parsed.result).toBe('Response text');
|
||||
expect(parsed.duration_ms).toBe(1000);
|
||||
expect(parsed.num_turns).toBe(1);
|
||||
});
|
||||
|
||||
it('should emit error result', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: true,
|
||||
errorMessage: 'Test error',
|
||||
durationMs: 500,
|
||||
apiDurationMs: 300,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.is_error).toBe(true);
|
||||
expect(parsed.subtype).toBe('error_during_execution');
|
||||
expect(parsed.error?.message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should use provided summary over extracted text', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
summary: 'Custom summary',
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('Custom summary');
|
||||
});
|
||||
|
||||
it('should include usage information', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
total_tokens: 150,
|
||||
};
|
||||
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
usage,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.usage).toEqual(usage);
|
||||
});
|
||||
|
||||
it('should handle result without assistant message', () => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitResult({
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
apiDurationMs: 800,
|
||||
numTurns: 1,
|
||||
});
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitUserMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit user message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const parts: Part[] = [{ text: 'Hello user' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('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', () => {
|
||||
const parts: Part[] = [{ text: 'Tool response' }];
|
||||
adapter.emitUserMessage(parts);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
// emitUserMessage currently sets parent_tool_use_id to null
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitToolResult', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit tool result message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: 'Tool executed successfully',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('user');
|
||||
expect(parsed.parent_tool_use_id).toBeNull();
|
||||
const block = parsed.message.content[0];
|
||||
expect(block).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Tool executed successfully',
|
||||
is_error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark error tool results', () => {
|
||||
const request = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
};
|
||||
const response = {
|
||||
callId: 'tool-1',
|
||||
responseParts: [],
|
||||
resultDisplay: undefined,
|
||||
error: new Error('Tool failed'),
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
adapter.emitToolResult(request, response);
|
||||
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
const block = parsed.message.content[0];
|
||||
expect(block.is_error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitSystemMessage', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should emit system message immediately', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.emitSystemMessage('test_subtype', { data: 'value' });
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalled();
|
||||
const output = stdoutWriteSpy.mock.calls[0][0] as string;
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.type).toBe('system');
|
||||
expect(parsed.subtype).toBe('test_subtype');
|
||||
expect(parsed.data).toEqual({ data: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId and getModel', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
});
|
||||
|
||||
it('should return session ID from config', () => {
|
||||
expect(adapter.getSessionId()).toBe('test-session-id');
|
||||
expect(mockConfig.getSessionId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return model from config', () => {
|
||||
expect(adapter.getModel()).toBe('test-model');
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message_id in stream events', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, true);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should include message_id in stream events after message starts', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text',
|
||||
});
|
||||
// Process another event to ensure messageStarted is true
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More',
|
||||
});
|
||||
|
||||
const calls = stdoutWriteSpy.mock.calls;
|
||||
// Find all delta events
|
||||
const deltaCalls = calls.filter((call: unknown[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(call[0] as string);
|
||||
return (
|
||||
parsed.type === 'stream_event' &&
|
||||
parsed.event.type === 'content_block_delta'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(deltaCalls.length).toBeGreaterThan(0);
|
||||
// The second delta event should have message_id (after messageStarted becomes true)
|
||||
// message_id is added to the event object, so check parsed.event.message_id
|
||||
if (deltaCalls.length > 1) {
|
||||
const secondDelta = JSON.parse(
|
||||
(deltaCalls[1] as unknown[])[0] as string,
|
||||
);
|
||||
// message_id is on the enriched event object
|
||||
expect(
|
||||
secondDelta.event.message_id || secondDelta.message_id,
|
||||
).toBeTruthy();
|
||||
} else {
|
||||
// If only one delta, check if message_id exists
|
||||
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
|
||||
// message_id is added when messageStarted is true
|
||||
// First event may or may not have it, but subsequent ones should
|
||||
expect(delta.event.message_id || delta.message_id).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple text blocks', () => {
|
||||
beforeEach(() => {
|
||||
adapter = new StreamJsonOutputAdapter(mockConfig, false);
|
||||
adapter.startAssistantMessage();
|
||||
});
|
||||
|
||||
it('should split assistant messages when block types change repeatedly', () => {
|
||||
stdoutWriteSpy.mockClear();
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Text content',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Thought,
|
||||
value: { subject: 'Thinking', description: 'Thought' },
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'More text',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'More text',
|
||||
});
|
||||
|
||||
const assistantMessages = stdoutWriteSpy.mock.calls
|
||||
.map((call: unknown[]) => JSON.parse(call[0] as string))
|
||||
.filter(
|
||||
(
|
||||
payload: unknown,
|
||||
): payload is {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
} => {
|
||||
if (
|
||||
typeof payload !== 'object' ||
|
||||
payload === null ||
|
||||
!('type' in payload) ||
|
||||
(payload as { type?: string }).type !== 'assistant' ||
|
||||
!('message' in payload)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (
|
||||
typeof message !== 'object' ||
|
||||
message === null ||
|
||||
!('content' in message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const content = (message as { content?: unknown }).content;
|
||||
return (
|
||||
Array.isArray(content) &&
|
||||
content.length > 0 &&
|
||||
content.every(
|
||||
(block: unknown) =>
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
expect(assistantMessages).toHaveLength(3);
|
||||
const observedTypes = assistantMessages.map(
|
||||
(msg: {
|
||||
type: string;
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
}) => msg.message.content[0]?.type ?? '',
|
||||
);
|
||||
expect(observedTypes).toEqual(['text', 'thinking', 'text']);
|
||||
for (const msg of assistantMessages) {
|
||||
const uniqueTypes = new Set(
|
||||
msg.message.content.map((block: { type: string }) => block.type),
|
||||
);
|
||||
expect(uniqueTypes.size).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge consecutive text fragments', () => {
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: ' ',
|
||||
});
|
||||
adapter.processEvent({
|
||||
type: GeminiEventType.Content,
|
||||
value: 'World',
|
||||
});
|
||||
|
||||
const message = adapter.finalizeAssistantMessage();
|
||||
expect(message.message.content).toHaveLength(1);
|
||||
expect(message.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello World',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
300
packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
CLIAssistantMessage,
|
||||
CLIMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
ControlMessage,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
} from '../types.js';
|
||||
import {
|
||||
BaseJsonOutputAdapter,
|
||||
type MessageState,
|
||||
type ResultOptions,
|
||||
type JsonOutputAdapterInterface,
|
||||
} from './BaseJsonOutputAdapter.js';
|
||||
|
||||
/**
|
||||
* Stream JSON output adapter that emits messages immediately
|
||||
* as they are completed during the streaming process.
|
||||
* Supports both main agent and subagent messages through distinct APIs.
|
||||
*/
|
||||
export class StreamJsonOutputAdapter
|
||||
extends BaseJsonOutputAdapter
|
||||
implements JsonOutputAdapterInterface
|
||||
{
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly includePartialMessages: boolean,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits message immediately to stdout (stream mode).
|
||||
*/
|
||||
protected emitMessageImpl(message: CLIMessage | ControlMessage): void {
|
||||
// Track assistant messages for result generation
|
||||
if (
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
'type' in message &&
|
||||
message.type === 'assistant'
|
||||
) {
|
||||
this.updateLastAssistantMessage(message as CLIAssistantMessage);
|
||||
}
|
||||
|
||||
// Emit messages immediately in stream mode
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream mode emits stream events when includePartialMessages is enabled.
|
||||
*/
|
||||
protected shouldEmitStreamEvents(): boolean {
|
||||
return this.includePartialMessages;
|
||||
}
|
||||
|
||||
finalizeAssistantMessage(): CLIAssistantMessage {
|
||||
const state = this.mainAgentMessageState;
|
||||
if (state.finalized) {
|
||||
return this.buildMessage(null);
|
||||
}
|
||||
state.finalized = true;
|
||||
|
||||
this.finalizePendingBlocks(state, null);
|
||||
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
for (const index of orderedOpenBlocks) {
|
||||
this.onBlockClosed(state, index, null);
|
||||
this.closeBlock(state, index);
|
||||
}
|
||||
|
||||
if (state.messageStarted && this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
|
||||
}
|
||||
|
||||
const message = this.buildMessage(null);
|
||||
this.updateLastAssistantMessage(message);
|
||||
this.emitMessageImpl(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
emitResult(options: ResultOptions): void {
|
||||
const resultMessage = this.buildResultMessage(
|
||||
options,
|
||||
this.lastAssistantMessage,
|
||||
);
|
||||
this.emitMessageImpl(resultMessage);
|
||||
}
|
||||
|
||||
emitMessage(message: CLIMessage | ControlMessage): void {
|
||||
// In stream mode, emit immediately
|
||||
this.emitMessageImpl(message);
|
||||
}
|
||||
|
||||
send(message: CLIMessage | ControlMessage): void {
|
||||
this.emitMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text block is created.
|
||||
*/
|
||||
protected override onTextBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: TextBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when text is appended.
|
||||
*/
|
||||
protected override onTextAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'text_delta', text: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking block is created.
|
||||
*/
|
||||
protected override onThinkingBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ThinkingBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when thinking is appended.
|
||||
*/
|
||||
protected override onThinkingAppended(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
fragment: string,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'thinking_delta', thinking: fragment },
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use block is created.
|
||||
*/
|
||||
protected override onToolUseBlockCreated(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
block: ToolUseBlock,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when tool_use input is set.
|
||||
*/
|
||||
protected override onToolUseInputSet(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
input: unknown,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(input),
|
||||
},
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit stream event when block is closed.
|
||||
*/
|
||||
protected override onBlockClosed(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (this.includePartialMessages) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
},
|
||||
parentToolUseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides base class hook to emit message_start event when message is started.
|
||||
* Only emits for main agent, not for subagents.
|
||||
*/
|
||||
protected override onEnsureMessageStarted(
|
||||
state: MessageState,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
// Only emit message_start for main agent, not for subagents
|
||||
if (parentToolUseId === null) {
|
||||
this.emitStreamEventIfEnabled(
|
||||
{
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: state.messageId!,
|
||||
role: 'assistant',
|
||||
model: this.config.getModel(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits stream events when partial messages are enabled.
|
||||
* This is a private method specific to StreamJsonOutputAdapter.
|
||||
* @param event - Stream event to emit
|
||||
* @param parentToolUseId - null for main agent, string for subagent
|
||||
*/
|
||||
private emitStreamEventIfEnabled(
|
||||
event: StreamEvent,
|
||||
parentToolUseId: string | null,
|
||||
): void {
|
||||
if (!this.includePartialMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.getMessageState(parentToolUseId);
|
||||
const enrichedEvent = state.messageStarted
|
||||
? ({ ...event, message_id: state.messageId } as StreamEvent & {
|
||||
message_id: string;
|
||||
})
|
||||
: event;
|
||||
|
||||
const partial: CLIPartialAssistantMessage = {
|
||||
type: 'stream_event',
|
||||
uuid: randomUUID(),
|
||||
session_id: this.getSessionId(),
|
||||
parent_tool_use_id: parentToolUseId,
|
||||
event: enrichedEvent,
|
||||
};
|
||||
this.emitMessageImpl(partial);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user