From 49b101833766a70ef01263b3a1260afa320836bd Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 5 Nov 2025 17:09:37 +0800 Subject: [PATCH] test(nonInteractiveCli): add tests and remove unused cost info --- .../io/BaseJsonOutputAdapter.test.ts | 1479 +++++++++++++++++ .../io/BaseJsonOutputAdapter.ts | 4 - .../io/JsonOutputAdapter.test.ts | 3 - .../io/StreamJsonOutputAdapter.test.ts | 3 - packages/cli/src/nonInteractive/session.ts | 1 - packages/cli/src/nonInteractive/types.ts | 3 - packages/cli/src/nonInteractiveCli.test.ts | 130 +- packages/cli/src/nonInteractiveCli.ts | 15 +- .../src/utils/nonInteractiveHelpers.test.ts | 1150 +++++++++++++ .../cli/src/utils/nonInteractiveHelpers.ts | 43 +- 10 files changed, 2746 insertions(+), 85 deletions(-) create mode 100644 packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts create mode 100644 packages/cli/src/utils/nonInteractiveHelpers.test.ts diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts new file mode 100644 index 00000000..6cbbea0d --- /dev/null +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -0,0 +1,1479 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + GeminiEventType, + type Config, + type ServerGeminiStreamEvent, + type ToolCallRequestInfo, + type TaskResultDisplay, +} from '@qwen-code/qwen-code-core'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { + CLIMessage, + CLIAssistantMessage, + ContentBlock, +} from '../types.js'; +import { + BaseJsonOutputAdapter, + type MessageState, + type ResultOptions, + partsToString, + toolResultContent, + extractTextFromBlocks, + createExtendedUsage, +} from './BaseJsonOutputAdapter.js'; + +/** + * Test implementation of BaseJsonOutputAdapter for unit testing. + * Captures emitted messages for verification. + */ +class TestJsonOutputAdapter extends BaseJsonOutputAdapter { + readonly emittedMessages: CLIMessage[] = []; + + protected emitMessageImpl(message: CLIMessage): void { + this.emittedMessages.push(message); + } + + protected shouldEmitStreamEvents(): boolean { + return false; + } + + finalizeAssistantMessage(): CLIAssistantMessage { + return this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.emitMessageImpl(resultMessage); + } + + // Expose protected methods for testing + exposeGetMessageState(parentToolUseId: string | null): MessageState { + return this.getMessageState(parentToolUseId); + } + + exposeCreateMessageState(): MessageState { + return this.createMessageState(); + } + + exposeCreateUsage(metadata?: GenerateContentResponseUsageMetadata | null) { + return this.createUsage(metadata); + } + + exposeBuildMessage(parentToolUseId: string | null): CLIAssistantMessage { + return this.buildMessage(parentToolUseId); + } + + exposeFinalizePendingBlocks( + state: MessageState, + parentToolUseId?: string | null, + ): void { + this.finalizePendingBlocks(state, parentToolUseId); + } + + exposeOpenBlock(state: MessageState, index: number, block: unknown): void { + this.openBlock(state, index, block as ContentBlock); + } + + exposeCloseBlock(state: MessageState, index: number): void { + this.closeBlock(state, index); + } + + exposeEnsureBlockTypeConsistency( + state: MessageState, + targetType: 'text' | 'thinking' | 'tool_use', + parentToolUseId: string | null, + ): void { + this.ensureBlockTypeConsistency(state, targetType, parentToolUseId); + } + + exposeStartAssistantMessageInternal(state: MessageState): void { + this.startAssistantMessageInternal(state); + } + + exposeFinalizeAssistantMessageInternal( + state: MessageState, + parentToolUseId: string | null, + ): CLIAssistantMessage { + return this.finalizeAssistantMessageInternal(state, parentToolUseId); + } + + exposeAppendText( + state: MessageState, + fragment: string, + parentToolUseId: string | null, + ): void { + this.appendText(state, fragment, parentToolUseId); + } + + exposeAppendThinking( + state: MessageState, + subject?: string, + description?: string, + parentToolUseId?: string | null, + ): void { + this.appendThinking(state, subject, description, parentToolUseId); + } + + exposeAppendToolUse( + state: MessageState, + request: { callId: string; name: string; args: unknown }, + parentToolUseId: string | null, + ): void { + this.appendToolUse(state, request as ToolCallRequestInfo, parentToolUseId); + } + + exposeEnsureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + this.ensureMessageStarted(state, parentToolUseId); + } + + exposeCreateSubagentToolUseBlock( + state: MessageState, + toolCall: NonNullable[number], + parentToolUseId: string, + ) { + return this.createSubagentToolUseBlock(state, toolCall, parentToolUseId); + } + + exposeBuildResultMessage(options: ResultOptions) { + return this.buildResultMessage(options, this.lastAssistantMessage); + } + + exposeBuildSubagentErrorResult(errorMessage: string, numTurns: number) { + return this.buildSubagentErrorResult(errorMessage, numTurns); + } +} + +function createMockConfig(): Config { + return { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getModel: vi.fn().mockReturnValue('test-model'), + } as unknown as Config; +} + +describe('BaseJsonOutputAdapter', () => { + let adapter: TestJsonOutputAdapter; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = createMockConfig(); + adapter = new TestJsonOutputAdapter(mockConfig); + }); + + describe('createMessageState', () => { + it('should create a new message state with default values', () => { + const state = adapter.exposeCreateMessageState(); + + expect(state.messageId).toBeNull(); + expect(state.blocks).toEqual([]); + expect(state.openBlocks).toBeInstanceOf(Set); + expect(state.openBlocks.size).toBe(0); + expect(state.usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + expect(state.messageStarted).toBe(false); + expect(state.finalized).toBe(false); + expect(state.currentBlockType).toBeNull(); + }); + }); + + describe('getMessageState', () => { + it('should return main agent state for null parentToolUseId', () => { + const state = adapter.exposeGetMessageState(null); + expect(state).toBe(adapter['mainAgentMessageState']); + }); + + it('should create and return subagent state for non-null parentToolUseId', () => { + const parentToolUseId = 'parent-tool-1'; + const state1 = adapter.exposeGetMessageState(parentToolUseId); + const state2 = adapter.exposeGetMessageState(parentToolUseId); + + expect(state1).toBe(state2); + expect(state1).not.toBe(adapter['mainAgentMessageState']); + expect(adapter['subagentMessageStates'].has(parentToolUseId)).toBe(true); + }); + + it('should create separate states for different parentToolUseIds', () => { + const state1 = adapter.exposeGetMessageState('parent-1'); + const state2 = adapter.exposeGetMessageState('parent-2'); + + expect(state1).not.toBe(state2); + }); + }); + + describe('createUsage', () => { + it('should create usage with default values when metadata is not provided', () => { + const usage = adapter.exposeCreateUsage(); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should create usage with null metadata', () => { + const usage = adapter.exposeCreateUsage(null); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should extract usage from metadata', () => { + const metadata: GenerateContentResponseUsageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + totalTokenCount: 160, + }; + + const usage = adapter.exposeCreateUsage(metadata); + + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 10, + total_tokens: 160, + }); + }); + + it('should handle partial metadata', () => { + const metadata: GenerateContentResponseUsageMetadata = { + promptTokenCount: 100, + // candidatesTokenCount missing + }; + + const usage = adapter.exposeCreateUsage(metadata); + + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 0, + }); + }); + }); + + describe('buildMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should throw error if message not started', () => { + // Manipulate the actual main agent state used by buildMessage + const state = adapter['mainAgentMessageState']; + state.messageId = null; // Explicitly set to null to test error case + state.blocks = [{ type: 'text', text: 'test' }]; + + expect(() => adapter.exposeBuildMessage(null)).toThrow( + 'Message not started', + ); + }); + + it('should build message with text blocks', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello world', + }); + + const message = adapter.exposeBuildMessage(null); + + 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); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Hello world', + }); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.exposeBuildMessage(null); + + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should enforce single block type constraint', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + state.messageId = 'test-id'; + state.blocks = [ + { type: 'text', text: 'text' }, + { type: 'thinking', thinking: 'thinking', signature: 'sig' }, + ]; + + expect(() => adapter.exposeBuildMessage(null)).toThrow( + 'Assistant message must contain only one type of ContentBlock', + ); + }); + }); + + describe('finalizePendingBlocks', () => { + it('should finalize text blocks', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [{ type: 'text', text: 'test' }]; + const index = 0; + adapter.exposeOpenBlock(state, index, state.blocks[0]); + + adapter.exposeFinalizePendingBlocks(state); + + expect(state.openBlocks.has(index)).toBe(false); + }); + + it('should finalize thinking blocks', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [{ type: 'thinking', thinking: 'test', signature: 'sig' }]; + const index = 0; + adapter.exposeOpenBlock(state, index, state.blocks[0]); + + adapter.exposeFinalizePendingBlocks(state); + + expect(state.openBlocks.has(index)).toBe(false); + }); + + it('should do nothing if no blocks', () => { + const state = adapter.exposeCreateMessageState(); + + expect(() => adapter.exposeFinalizePendingBlocks(state)).not.toThrow(); + }); + + it('should do nothing if last block is not text or thinking', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [ + { + type: 'tool_use', + id: 'tool-1', + name: 'test', + input: {}, + }, + ]; + + expect(() => adapter.exposeFinalizePendingBlocks(state)).not.toThrow(); + }); + }); + + describe('openBlock and closeBlock', () => { + it('should add block index to openBlocks', () => { + const state = adapter.exposeCreateMessageState(); + const block = { type: 'text', text: 'test' }; + + adapter.exposeOpenBlock(state, 0, block); + + expect(state.openBlocks.has(0)).toBe(true); + }); + + it('should remove block index from openBlocks', () => { + const state = adapter.exposeCreateMessageState(); + const block = { type: 'text', text: 'test' }; + adapter.exposeOpenBlock(state, 0, block); + + adapter.exposeCloseBlock(state, 0); + + expect(state.openBlocks.has(0)).toBe(false); + }); + + it('should not throw when closing non-existent block', () => { + const state = adapter.exposeCreateMessageState(); + + expect(() => adapter.exposeCloseBlock(state, 0)).not.toThrow(); + }); + }); + + describe('ensureBlockTypeConsistency', () => { + it('should set currentBlockType if null', () => { + const state = adapter.exposeCreateMessageState(); + state.currentBlockType = null; + + adapter.exposeEnsureBlockTypeConsistency(state, 'text', null); + + expect(state.currentBlockType).toBe('text'); + }); + + it('should do nothing if currentBlockType matches target', () => { + const state = adapter.exposeCreateMessageState(); + state.currentBlockType = 'text'; + state.messageId = 'test-id'; + state.blocks = [{ type: 'text', text: 'test' }]; + + adapter.exposeEnsureBlockTypeConsistency(state, 'text', null); + + expect(state.currentBlockType).toBe('text'); + expect(state.blocks).toHaveLength(1); + }); + + it('should finalize and start new message when block type changes', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'text', + }); + + adapter.exposeEnsureBlockTypeConsistency(state, 'thinking', null); + + expect(state.currentBlockType).toBe('thinking'); + expect(state.blocks.length).toBe(0); + }); + }); + + describe('startAssistantMessageInternal', () => { + it('should reset message state', () => { + const state = adapter.exposeCreateMessageState(); + state.messageId = 'old-id'; + state.blocks = [{ type: 'text', text: 'old' }]; + state.openBlocks.add(0); + state.usage = { input_tokens: 100, output_tokens: 50 }; + state.messageStarted = true; + state.finalized = true; + state.currentBlockType = 'text'; + + adapter.exposeStartAssistantMessageInternal(state); + + expect(state.messageId).toBeTruthy(); + expect(state.messageId).not.toBe('old-id'); + expect(state.blocks).toEqual([]); + expect(state.openBlocks.size).toBe(0); + expect(state.usage).toEqual({ input_tokens: 0, output_tokens: 0 }); + expect(state.messageStarted).toBe(false); + expect(state.finalized).toBe(false); + expect(state.currentBlockType).toBeNull(); + }); + }); + + describe('finalizeAssistantMessageInternal', () => { + it('should return same message if already finalized', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + const message1 = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + const message2 = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + + expect(message1).toEqual(message2); + expect(state.finalized).toBe(true); + }); + + it('should finalize pending blocks and emit message', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + const message = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + + expect(message).toBeDefined(); + expect(state.finalized).toBe(true); + expect(adapter.emittedMessages).toContain(message); + }); + + it('should close all open blocks', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + state.openBlocks.add(0); + + adapter.exposeFinalizeAssistantMessageInternal(state, null); + + expect(state.openBlocks.size).toBe(0); + }); + }); + + describe('appendText', () => { + it('should create new text block if none exists', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, 'Hello', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello', + }); + }); + + it('should append to existing text block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendText(state, 'Hello', null); + + adapter.exposeAppendText(state, ' World', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello World', + }); + }); + + it('should ignore empty fragments', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, '', null); + + expect(state.blocks).toHaveLength(0); + }); + + it('should ensure message is started', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, 'test', null); + + expect(state.messageStarted).toBe(true); + }); + }); + + describe('appendThinking', () => { + it('should create new thinking block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking( + state, + 'Planning', + 'Thinking about task', + null, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking about task', + signature: 'Planning', + }); + }); + + it('should append to existing thinking block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendThinking(state, 'Planning', 'First thought', null); + + adapter.exposeAppendThinking(state, 'Planning', 'Second thought', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0].type).toBe('thinking'); + const block = state.blocks[0] as { thinking: string }; + expect(block.thinking).toContain('First thought'); + expect(block.thinking).toContain('Second thought'); + }); + + it('should handle only subject', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking(state, 'Planning', '', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + signature: 'Planning', + }); + }); + + it('should ignore empty fragments', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking(state, '', '', null); + + expect(state.blocks).toHaveLength(0); + }); + }); + + describe('appendToolUse', () => { + it('should create tool_use block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendToolUse( + state, + { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + }, + null, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + }); + + it('should finalize pending blocks before appending tool_use', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendText(state, 'text', null); + + adapter.exposeAppendToolUse( + state, + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + }, + null, + ); + + expect(state.blocks.length).toBeGreaterThan(0); + const toolUseBlock = state.blocks.find((b) => b.type === 'tool_use'); + expect(toolUseBlock).toBeDefined(); + }); + }); + + describe('ensureMessageStarted', () => { + it('should set messageStarted to true', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeEnsureMessageStarted(state, null); + + expect(state.messageStarted).toBe(true); + }); + + it('should do nothing if already started', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + state.messageStarted = true; + + adapter.exposeEnsureMessageStarted(state, null); + + expect(state.messageStarted).toBe(true); + }); + }); + + describe('startAssistantMessage', () => { + it('should reset main agent message state', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + adapter.startAssistantMessage(); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(0); + expect(state.messageStarted).toBe(false); + }); + }); + + describe('processEvent', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should process Content events', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello', + }); + }); + + it('should process Citation events', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 'Citation text', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks[0].type).toBe('text'); + const block = state.blocks[0] as { text: string }; + expect(block.text).toContain('Citation text'); + }); + + it('should ignore non-string Citation values', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 123, + } as unknown as ServerGeminiStreamEvent); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(0); + }); + + it('should process Thought events', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking', + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking', + signature: 'Planning', + }); + }); + + it('should process ToolCallRequest events', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + }); + + it('should process Finished events with usage metadata', () => { + adapter.processEvent({ + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + }, + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + }); + }); + + it('should ignore events after finalization', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'First', + }); + adapter.finalizeAssistantMessage(); + + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Second', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'First', + }); + }); + }); + + describe('finalizeAssistantMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should build and return assistant message', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test response', + }); + + const message = adapter.finalizeAssistantMessage(); + + expect(message.type).toBe('assistant'); + expect(message.message.content).toHaveLength(1); + expect(adapter.emittedMessages).toContain(message); + }); + }); + + describe('emitUserMessage', () => { + it('should emit user message', () => { + const parts: Part[] = [{ text: 'Hello user' }]; + + adapter.emitUserMessage(parts); + + expect(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('user'); + if (message.type === 'user') { + expect(message.message.content).toBe('Hello user'); + expect(message.parent_tool_use_id).toBeNull(); + } + }); + + it('should handle multiple parts', () => { + 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'); + } + }); + + it('should handle non-text parts', () => { + const parts: Part[] = [ + { text: 'Hello' }, + { functionCall: { name: 'test' } }, + ]; + + adapter.emitUserMessage(parts); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user') { + expect(message.message.content).toContain('Hello'); + } + }); + }); + + describe('emitToolResult', () => { + it('should emit tool result message with content', () => { + 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(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('user'); + if (message.type === 'user') { + expect(message.message.content).toHaveLength(1); + const block = message.message.content[0]; + if (typeof block === 'object' && block !== null && 'type' in block) { + expect(block.type).toBe('tool_result'); + if (block.type === 'tool_result') { + expect(block.tool_use_id).toBe('tool-1'); + expect(block.content).toBe('Tool executed successfully'); + expect(block.is_error).toBe(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 message = adapter.emittedMessages[0]; + if (message.type === 'user') { + const block = message.message.content[0]; + if (typeof block === 'object' && block !== null && 'type' in block) { + if (block.type === 'tool_result') { + expect(block.is_error).toBe(true); + } + } + } + }); + + it('should handle parentToolUseId', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: 'Result', + error: undefined, + errorType: undefined, + }; + + adapter.emitToolResult(request, response, 'parent-tool-1'); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user') { + expect(message.parent_tool_use_id).toBe('parent-tool-1'); + } + }); + }); + + describe('emitSystemMessage', () => { + it('should emit system message', () => { + adapter.emitSystemMessage('test_subtype', { data: 'value' }); + + expect(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('system'); + if (message.type === 'system') { + expect(message.subtype).toBe('test_subtype'); + expect(message.data).toEqual({ data: 'value' }); + } + }); + + it('should handle system message without data', () => { + adapter.emitSystemMessage('test_subtype'); + + const message = adapter.emittedMessages[0]; + if (message.type === 'system') { + expect(message.subtype).toBe('test_subtype'); + } + }); + }); + + describe('buildResultMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Response text', + }); + const message = adapter.finalizeAssistantMessage(); + // Update lastAssistantMessage manually since test adapter doesn't do it automatically + adapter['lastAssistantMessage'] = message; + }); + + it('should build success result message', () => { + const options: ResultOptions = { + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.type).toBe('result'); + expect(result.is_error).toBe(false); + if (!result.is_error) { + expect(result.subtype).toBe('success'); + expect(result.result).toBe('Response text'); + expect(result.duration_ms).toBe(1000); + expect(result.duration_api_ms).toBe(800); + expect(result.num_turns).toBe(1); + } + }); + + it('should build error result message', () => { + const options: ResultOptions = { + isError: true, + errorMessage: 'Test error', + durationMs: 500, + apiDurationMs: 300, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.type).toBe('result'); + expect(result.is_error).toBe(true); + if (result.is_error) { + expect(result.subtype).toBe('error_during_execution'); + expect(result.error?.message).toBe('Test error'); + } + }); + + it('should use provided summary over extracted text', () => { + const options: ResultOptions = { + isError: false, + summary: 'Custom summary', + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error) { + expect(result.result).toBe('Custom summary'); + } + }); + + it('should include usage information', () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }; + const options: ResultOptions = { + isError: false, + usage, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.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, + }, + }; + const options: ResultOptions = { + isError: false, + stats, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error && 'stats' in result) { + expect(result['stats']).toEqual(stats); + } + }); + + it('should handle result without assistant message', () => { + adapter = new TestJsonOutputAdapter(mockConfig); + const options: ResultOptions = { + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error) { + expect(result.result).toBe(''); + } + }); + }); + + describe('startSubagentAssistantMessage', () => { + it('should start subagent message', () => { + const parentToolUseId = 'parent-tool-1'; + + adapter.startSubagentAssistantMessage(parentToolUseId); + + const state = adapter.exposeGetMessageState(parentToolUseId); + expect(state.messageId).toBeTruthy(); + expect(state.blocks).toEqual([]); + }); + }); + + describe('finalizeSubagentAssistantMessage', () => { + it('should finalize and return subagent message', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Subagent response', parentToolUseId); + + const message = adapter.finalizeSubagentAssistantMessage(parentToolUseId); + + expect(message.type).toBe('assistant'); + expect(message.parent_tool_use_id).toBe(parentToolUseId); + expect(message.message.content).toHaveLength(1); + }); + }); + + describe('emitSubagentErrorResult', () => { + it('should emit subagent error result', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + + adapter.emitSubagentErrorResult('Error occurred', 5, parentToolUseId); + + expect(adapter.emittedMessages.length).toBeGreaterThan(0); + const errorResult = adapter.emittedMessages.find( + (msg) => msg.type === 'result' && msg.is_error === true, + ); + expect(errorResult).toBeDefined(); + if ( + errorResult && + errorResult.type === 'result' && + errorResult.is_error + ) { + expect(errorResult.error?.message).toBe('Error occurred'); + expect(errorResult.num_turns).toBe(5); + } + }); + + it('should finalize pending assistant message before emitting error', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Partial response', parentToolUseId); + + adapter.emitSubagentErrorResult('Error', 1, parentToolUseId); + + const assistantMessage = adapter.emittedMessages.find( + (msg) => msg.type === 'assistant', + ); + expect(assistantMessage).toBeDefined(); + }); + }); + + describe('processSubagentToolCall', () => { + it('should process subagent tool call', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + status: 'success', + resultDisplay: 'Result', + }; + + adapter.processSubagentToolCall(toolCall, parentToolUseId); + + // processSubagentToolCall finalizes the message and starts a new one, + // so we should check the emitted messages instead of the state + const assistantMessages = adapter.emittedMessages.filter( + (msg) => + msg.type === 'assistant' && + msg.parent_tool_use_id === parentToolUseId, + ); + expect(assistantMessages.length).toBeGreaterThan(0); + const toolUseMessage = assistantMessages.find( + (msg) => + msg.type === 'assistant' && + msg.message.content.some((block) => block.type === 'tool_use'), + ); + expect(toolUseMessage).toBeDefined(); + }); + + it('should finalize text message before tool_use', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Text', parentToolUseId); + + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Result', + }; + + adapter.processSubagentToolCall(toolCall, parentToolUseId); + + const assistantMessages = adapter.emittedMessages.filter( + (msg) => msg.type === 'assistant', + ); + expect(assistantMessages.length).toBeGreaterThan(0); + }); + }); + + describe('createSubagentToolUseBlock', () => { + it('should create tool_use block for subagent', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + status: 'success', + resultDisplay: 'Result', + }; + + const { block, index } = adapter.exposeCreateSubagentToolUseBlock( + state, + toolCall, + 'parent-tool-1', + ); + + expect(block).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + expect(state.blocks[index]).toBe(block); + expect(state.openBlocks.has(index)).toBe(true); + }); + }); + + describe('buildSubagentErrorResult', () => { + it('should build subagent error result', () => { + const errorResult = adapter.exposeBuildSubagentErrorResult( + 'Error message', + 3, + ); + + expect(errorResult.type).toBe('result'); + expect(errorResult.is_error).toBe(true); + expect(errorResult.subtype).toBe('error_during_execution'); + expect(errorResult.error?.message).toBe('Error message'); + expect(errorResult.num_turns).toBe(3); + expect(errorResult.usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + }); + + 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('helper functions', () => { + describe('partsToString', () => { + it('should convert text parts to string', () => { + const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }]; + + const result = partsToString(parts); + + expect(result).toBe('Hello World'); + }); + + it('should handle non-text parts', () => { + const parts: Part[] = [ + { text: 'Hello' }, + { functionCall: { name: 'test' } }, + ]; + + const result = partsToString(parts); + + expect(result).toContain('Hello'); + expect(result).toContain('functionCall'); + }); + + it('should handle empty array', () => { + const result = partsToString([]); + + expect(result).toBe(''); + }); + }); + + describe('toolResultContent', () => { + it('should extract content from resultDisplay', () => { + const response = { + callId: 'tool-1', + resultDisplay: 'Tool result', + responseParts: [], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBe('Tool result'); + }); + + it('should extract content from responseParts', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [{ text: 'Result' }], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeTruthy(); + }); + + it('should extract error message', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [], + error: new Error('Tool failed'), + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBe('Tool failed'); + }); + + it('should return undefined if no content', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeUndefined(); + }); + + it('should ignore empty resultDisplay', () => { + const response = { + callId: 'tool-1', + resultDisplay: ' ', + responseParts: [{ text: 'Result' }], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeTruthy(); + expect(result).not.toBe(' '); + }); + }); + + describe('extractTextFromBlocks', () => { + it('should extract text from text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' World' }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe('Hello World'); + }); + + it('should ignore non-text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool_use', id: 'tool-1', name: 'test', input: {} }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe('Hello'); + }); + + it('should handle empty array', () => { + const result = extractTextFromBlocks([]); + + expect(result).toBe(''); + }); + + it('should handle array with no text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'tool_use', id: 'tool-1', name: 'test', input: {} }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe(''); + }); + }); + + describe('createExtendedUsage', () => { + it('should create extended usage with default values', () => { + const usage = createExtendedUsage(); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index d70a0636..3aaf0375 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -56,7 +56,6 @@ export interface ResultOptions { readonly apiDurationMs: number; readonly numTurns: number; readonly usage?: ExtendedUsage; - readonly totalCostUsd?: number; readonly stats?: SessionMetrics; readonly summary?: string; readonly subtype?: string; @@ -1020,7 +1019,6 @@ export abstract class BaseJsonOutputAdapter { duration_ms: options.durationMs, duration_api_ms: options.apiDurationMs, num_turns: options.numTurns, - total_cost_usd: options.totalCostUsd ?? 0, usage, permission_denials: [], error: { message: errorMessage }, @@ -1037,7 +1035,6 @@ export abstract class BaseJsonOutputAdapter { duration_api_ms: options.apiDurationMs, num_turns: options.numTurns, result: resultText, - total_cost_usd: options.totalCostUsd ?? 0, usage, permission_denials: [], }; @@ -1075,7 +1072,6 @@ export abstract class BaseJsonOutputAdapter { duration_ms: 0, duration_api_ms: 0, num_turns: numTurns, - total_cost_usd: 0, usage, permission_denials: [], error: { message: errorMessage }, diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts index 40bc35c8..2f244037 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts @@ -392,7 +392,6 @@ describe('JsonOutputAdapter', () => { durationMs: 1000, apiDurationMs: 800, numTurns: 1, - totalCostUsd: 0.01, }); expect(stdoutWriteSpy).toHaveBeenCalled(); @@ -414,7 +413,6 @@ describe('JsonOutputAdapter', () => { expect(resultMessage.result).toBe('Response text'); expect(resultMessage.duration_ms).toBe(1000); expect(resultMessage.num_turns).toBe(1); - expect(resultMessage.total_cost_usd).toBe(0.01); }); it('should emit error result', () => { @@ -424,7 +422,6 @@ describe('JsonOutputAdapter', () => { durationMs: 500, apiDurationMs: 300, numTurns: 1, - totalCostUsd: 0.005, }); const output = stdoutWriteSpy.mock.calls[0][0] as string; diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index 2c85738d..f7719d03 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -612,7 +612,6 @@ describe('StreamJsonOutputAdapter', () => { durationMs: 1000, apiDurationMs: 800, numTurns: 1, - totalCostUsd: 0.01, }); expect(stdoutWriteSpy).toHaveBeenCalled(); @@ -625,7 +624,6 @@ describe('StreamJsonOutputAdapter', () => { expect(parsed.result).toBe('Response text'); expect(parsed.duration_ms).toBe(1000); expect(parsed.num_turns).toBe(1); - expect(parsed.total_cost_usd).toBe(0.01); }); it('should emit error result', () => { @@ -636,7 +634,6 @@ describe('StreamJsonOutputAdapter', () => { durationMs: 500, apiDurationMs: 300, numTurns: 1, - totalCostUsd: 0.005, }); const output = stdoutWriteSpy.mock.calls[0][0] as string; diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index 529e12ae..8c4fd173 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -608,7 +608,6 @@ class SessionManager { apiDurationMs, numTurns, usage: undefined, - totalCostUsd: undefined, }); } diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 8c4a1270..a66c19af 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -36,7 +36,6 @@ export interface ModelUsage { cacheReadInputTokens: number; cacheCreationInputTokens: number; webSearchRequests: number; - costUSD: number; contextWindow: number; } @@ -162,7 +161,6 @@ export interface CLIResultMessageSuccess { duration_api_ms: number; num_turns: number; result: string; - total_cost_usd: number; usage: ExtendedUsage; modelUsage?: Record; permission_denials: CLIPermissionDenial[]; @@ -178,7 +176,6 @@ export interface CLIResultMessageError { duration_ms: number; duration_api_ms: number; num_turns: number; - total_cost_usd: number; usage: ExtendedUsage; modelUsage?: Record; permission_denials: CLIPermissionDenial[]; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index b5079b9f..b74261da 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -173,6 +173,45 @@ describe('runNonInteractive', () => { vi.restoreAllMocks(); }); + /** + * Creates a default mock SessionMetrics object. + * Can be overridden in individual tests if needed. + */ + function createMockMetrics( + overrides?: Partial, + ): SessionMetrics { + return { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + ...overrides, + }; + } + + /** + * Sets up the default mock for uiTelemetryService.getMetrics(). + * Should be called in beforeEach or at the start of tests that need metrics. + */ + function setupMetricsMock(overrides?: Partial): void { + const mockMetrics = createMockMetrics(overrides); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + } + async function* createStreamFromEvents( events: ServerGeminiStreamEvent[], ): AsyncGenerator { @@ -475,27 +514,7 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, - tools: { - totalCalls: 0, - totalSuccess: 0, - totalFail: 0, - totalDurationMs: 0, - totalDecisions: { - accept: 0, - reject: 0, - modify: 0, - auto_accept: 0, - }, - byName: {}, - }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + setupMetricsMock(); await runNonInteractive( mockConfig, @@ -527,7 +546,9 @@ describe('runNonInteractive', () => { ); expect(resultMessage).toBeTruthy(); expect(resultMessage?.result).toBe('Hello World'); - expect(resultMessage?.stats).toEqual(mockMetrics); + // Get the actual metrics that were used + const actualMetrics = vi.mocked(uiTelemetryService.getMetrics)(); + expect(resultMessage?.stats).toEqual(actualMetrics); }); it('should write JSON output with stats for tool-only commands (no text response)', async () => { @@ -568,8 +589,7 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, + setupMetricsMock({ tools: { totalCalls: 1, totalSuccess: 1, @@ -596,12 +616,7 @@ describe('runNonInteractive', () => { }, }, }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + }); await runNonInteractive( mockConfig, @@ -651,27 +666,7 @@ describe('runNonInteractive', () => { createStreamFromEvents(events), ); (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, - tools: { - totalCalls: 0, - totalSuccess: 0, - totalFail: 0, - totalDurationMs: 0, - totalDecisions: { - accept: 0, - reject: 0, - modify: 0, - auto_accept: 0, - }, - byName: {}, - }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + setupMetricsMock(); await runNonInteractive( mockConfig, @@ -703,11 +698,14 @@ describe('runNonInteractive', () => { ); expect(resultMessage).toBeTruthy(); expect(resultMessage?.result).toBe(''); - expect(resultMessage?.stats).toEqual(mockMetrics); + // Get the actual metrics that were used + const actualMetrics = vi.mocked(uiTelemetryService.getMetrics)(); + expect(resultMessage?.stats).toEqual(actualMetrics); }); it('should handle errors in JSON format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); const testError = new Error('Invalid input provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { @@ -753,6 +751,7 @@ describe('runNonInteractive', () => { it('should handle FatalInputError with custom exit code in JSON format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); const fatalError = new FatalInputError('Invalid command syntax provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { @@ -950,6 +949,7 @@ describe('runNonInteractive', () => { it('should emit stream-json envelopes when output format is stream-json', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1065,6 +1065,25 @@ describe('runNonInteractive', () => { it('should include usage metadata and API duration in stream-json result', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock({ + models: { + 'test-model': { + api: { + totalRequests: 1, + totalErrors: 0, + totalLatencyMs: 500, + }, + tokens: { + prompt: 11, + candidates: 5, + total: 16, + cached: 3, + thoughts: 0, + tool: 0, + }, + }, + }, + }); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1125,6 +1144,7 @@ describe('runNonInteractive', () => { it('should not emit user message when userMessage option is provided (stream-json input binding)', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1198,6 +1218,7 @@ describe('runNonInteractive', () => { it('should emit tool results as user messages in stream-json format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1297,6 +1318,7 @@ describe('runNonInteractive', () => { it('should emit tool errors in tool_result blocks in stream-json format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1390,6 +1412,7 @@ describe('runNonInteractive', () => { it('should emit partial messages when includePartialMessages is true', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(true); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1446,6 +1469,7 @@ describe('runNonInteractive', () => { it('should handle thinking blocks in stream-json format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1503,6 +1527,7 @@ describe('runNonInteractive', () => { it('should handle multiple tool calls in stream-json format', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { @@ -1613,6 +1638,7 @@ describe('runNonInteractive', () => { it('should handle userMessage with text content blocks in stream-json input mode', async () => { (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); const writes: string[] = []; processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index e099b481..2ff8ea03 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -36,10 +36,9 @@ import { import { normalizePartList, extractPartsFromUserMessage, - extractUsageFromGeminiClient, - calculateApproximateCost, buildSystemMessage, createTaskToolProgressHandler, + computeUsageFromMetrics, } from './utils/nonInteractiveHelpers.js'; /** @@ -315,8 +314,10 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { - const usage = extractUsageFromGeminiClient(geminiClient); + // For JSON and STREAM_JSON modes, compute usage from metrics if (adapter) { + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output const stats = outputFormat === OutputFormat.JSON @@ -328,20 +329,21 @@ export async function runNonInteractive( apiDurationMs: totalApiDurationMs, numTurns: turnCount, usage, - totalCostUsd: calculateApproximateCost(usage), stats, }); } else { - // Text output mode + // Text output mode - no usage needed process.stdout.write('\n'); } return; } } } catch (error) { - const usage = extractUsageFromGeminiClient(config.getGeminiClient()); + // For JSON and STREAM_JSON modes, compute usage from metrics const message = error instanceof Error ? error.message : String(error); if (adapter) { + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output const stats = outputFormat === OutputFormat.JSON @@ -354,7 +356,6 @@ export async function runNonInteractive( numTurns: turnCount, errorMessage: message, usage, - totalCostUsd: calculateApproximateCost(usage), stats, }); } diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts new file mode 100644 index 00000000..70df4e92 --- /dev/null +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -0,0 +1,1150 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { + Config, + SessionMetrics, + TaskResultDisplay, + ToolCallResponseInfo, +} from '@qwen-code/qwen-code-core'; +import { ToolErrorType } from '@qwen-code/qwen-code-core'; +import type { Part } from '@google/genai'; +import type { + CLIUserMessage, + PermissionMode, +} from '../nonInteractive/types.js'; +import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import { + normalizePartList, + extractPartsFromUserMessage, + extractUsageFromGeminiClient, + computeUsageFromMetrics, + buildSystemMessage, + createTaskToolProgressHandler, + functionResponsePartsToString, + toolResultContent, +} from './nonInteractiveHelpers.js'; + +// Mock dependencies +vi.mock('../services/CommandService.js', () => ({ + CommandService: { + create: vi.fn().mockResolvedValue({ + getCommands: vi + .fn() + .mockReturnValue([ + { name: 'help' }, + { name: 'commit' }, + { name: 'memory' }, + ]), + }), + }, +})); + +vi.mock('../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock('../ui/utils/computeStats.js', () => ({ + computeSessionStats: vi.fn().mockReturnValue({ + totalPromptTokens: 100, + totalCachedTokens: 20, + }), +})); + +describe('normalizePartList', () => { + it('should return empty array for null input', () => { + expect(normalizePartList(null)).toEqual([]); + }); + + it('should return empty array for undefined input', () => { + expect(normalizePartList(undefined as unknown as null)).toEqual([]); + }); + + it('should convert string to Part array', () => { + const result = normalizePartList('test string'); + expect(result).toEqual([{ text: 'test string' }]); + }); + + it('should convert array of strings to Part array', () => { + const result = normalizePartList(['hello', 'world']); + expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); + }); + + it('should convert array of mixed strings and Parts to Part array', () => { + const part: Part = { text: 'existing' }; + const result = normalizePartList(['new', part]); + expect(result).toEqual([{ text: 'new' }, part]); + }); + + it('should convert single Part object to array', () => { + const part: Part = { text: 'single part' }; + const result = normalizePartList(part); + expect(result).toEqual([part]); + }); + + it('should handle empty array', () => { + expect(normalizePartList([])).toEqual([]); + }); +}); + +describe('extractPartsFromUserMessage', () => { + it('should return null for undefined message', () => { + expect(extractPartsFromUserMessage(undefined)).toBeNull(); + }); + + it('should return null for null message', () => { + expect( + extractPartsFromUserMessage(null as unknown as undefined), + ).toBeNull(); + }); + + it('should extract string content', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: 'test message', + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBe('test message'); + }); + + it('should extract text blocks from content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'world' }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); + }); + + it('should skip invalid blocks in content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'valid' }, + null as unknown as { type: 'text'; text: string }, + { type: 'text', text: 'also valid' }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([{ text: 'valid' }, { text: 'also valid' }]); + }); + + it('should convert non-text blocks to JSON strings', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'text block' }, + { type: 'tool_use', id: '123', name: 'tool', input: {} }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([ + { text: 'text block' }, + { + text: JSON.stringify({ + type: 'tool_use', + id: '123', + name: 'tool', + input: {}, + }), + }, + ]); + }); + + it('should return null for empty content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [], + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBeNull(); + }); + + it('should return null when message has no content', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: undefined as unknown as string, + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBeNull(); + }); +}); + +describe('extractUsageFromGeminiClient', () => { + it('should return undefined for null client', () => { + expect(extractUsageFromGeminiClient(null)).toBeUndefined(); + }); + + it('should return undefined for non-object client', () => { + expect(extractUsageFromGeminiClient('not an object')).toBeUndefined(); + }); + + it('should return undefined when getChat is not a function', () => { + const client = { getChat: 'not a function' }; + expect(extractUsageFromGeminiClient(client)).toBeUndefined(); + }); + + it('should return undefined when chat does not have getDebugResponses', () => { + const client = { + getChat: vi.fn().mockReturnValue({}), + }; + expect(extractUsageFromGeminiClient(client)).toBeUndefined(); + }); + + it('should extract usage from latest response with usageMetadata', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { usageMetadata: { promptTokenCount: 50 } }, + { + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 200, + totalTokenCount: 300, + cachedContentTokenCount: 10, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + cache_read_input_tokens: 10, + }); + }); + + it('should return default values when metadata values are not numbers', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { + usageMetadata: { + promptTokenCount: 'not a number', + candidatesTokenCount: null, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should handle errors gracefully', () => { + const client = { + getChat: vi.fn().mockImplementation(() => { + throw new Error('Test error'); + }), + }; + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const result = extractUsageFromGeminiClient(client); + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should skip responses without usageMetadata', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { someOtherData: 'value' }, + { + usageMetadata: { + promptTokenCount: 50, + candidatesTokenCount: 75, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 50, + output_tokens: 75, + }); + }); +}); + +describe('computeUsageFromMetrics', () => { + it('should compute usage from SessionMetrics with single model', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 150, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 100, + cache_read_input_tokens: 20, + total_tokens: 150, + }); + }); + + it('should aggregate usage across multiple models', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 150, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + 'model-2': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 75, + candidates: 125, + total: 200, + cached: 15, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 225, + cache_read_input_tokens: 20, + total_tokens: 350, + }); + }); + + it('should not include total_tokens when it is 0', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 0, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).not.toHaveProperty('total_tokens'); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 100, + cache_read_input_tokens: 20, + }); + }); + + it('should handle empty models', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 0, + cache_read_input_tokens: 20, + }); + }); +}); + +describe('buildSystemMessage', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue({ + getAllToolNames: vi.fn().mockReturnValue(['tool1', 'tool2']), + }), + getMcpServers: vi.fn().mockReturnValue({ + 'mcp-server-1': {}, + 'mcp-server-2': {}, + }), + getTargetDir: vi.fn().mockReturnValue('/test/dir'), + getModel: vi.fn().mockReturnValue('test-model'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getDebugMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + }); + + it('should build system message with all fields', async () => { + const result = await buildSystemMessage( + mockConfig, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result).toEqual({ + type: 'system', + subtype: 'init', + uuid: 'test-session-id', + session_id: 'test-session-id', + cwd: '/test/dir', + tools: ['tool1', 'tool2'], + mcp_servers: [ + { name: 'mcp-server-1', status: 'connected' }, + { name: 'mcp-server-2', status: 'connected' }, + ], + model: 'test-model', + permissionMode: 'auto', + slash_commands: ['commit', 'help', 'memory'], + apiKeySource: 'none', + qwen_code_version: '1.0.0', + output_style: 'default', + agents: [], + skills: [], + }); + }); + + it('should handle empty tool registry', async () => { + const config = { + ...mockConfig, + getToolRegistry: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.tools).toEqual([]); + }); + + it('should handle empty MCP servers', async () => { + const config = { + ...mockConfig, + getMcpServers: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.mcp_servers).toEqual([]); + }); + + it('should use unknown version when getCliVersion returns null', async () => { + const config = { + ...mockConfig, + getCliVersion: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.qwen_code_version).toBe('unknown'); + }); +}); + +describe('createTaskToolProgressHandler', () => { + let mockAdapter: JsonOutputAdapterInterface; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getDebugMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + + mockAdapter = { + processSubagentToolCall: vi.fn(), + emitSubagentErrorResult: vi.fn(), + emitToolResult: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + }); + + it('should create handler that processes task tool calls', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: { arg1: 'value1' }, + status: 'executing', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + callId: 'tool-1', + name: 'test_tool', + status: 'executing', + }), + 'parent-tool-id', + ); + }); + + it('should emit tool_result when tool call completes', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: { arg1: 'value1' }, + status: 'success', + resultDisplay: 'Success result', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + callId: 'tool-1', + name: 'test_tool', + }), + expect.objectContaining({ + callId: 'tool-1', + resultDisplay: 'Success result', + }), + 'parent-tool-id', + ); + }); + + it('should not duplicate tool_use emissions', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'executing', + }, + ], + }; + + // Call handler twice with same tool call + handler('task-call-id', taskDisplay); + handler('task-call-id', taskDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledTimes(1); + }); + + it('should not duplicate tool_result emissions', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Result', + }, + ], + }; + + // Call handler twice with same completed tool call + handler('task-call-id', taskDisplay); + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledTimes(1); + }); + + it('should handle status transitions from executing to completed', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + // First: executing state + const executingDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'executing', + }, + ], + }; + + // Second: completed state + const completedDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Done', + }, + ], + }; + + handler('task-call-id', executingDisplay); + handler('task-call-id', completedDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledTimes(1); + expect(mockAdapter.emitToolResult).toHaveBeenCalledTimes(1); + }); + + it('should emit error result for failed task status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const runningDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + const failedDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'failed', + terminateReason: 'Task failed with error', + toolCalls: [], + }; + + handler('task-call-id', runningDisplay); + handler('task-call-id', failedDisplay); + + expect(mockAdapter.emitSubagentErrorResult).toHaveBeenCalledWith( + 'Task failed with error', + 0, + 'parent-tool-id', + ); + }); + + it('should emit error result for cancelled task status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const runningDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + const cancelledDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'cancelled', + toolCalls: [], + }; + + handler('task-call-id', runningDisplay); + handler('task-call-id', cancelledDisplay); + + expect(mockAdapter.emitSubagentErrorResult).toHaveBeenCalledWith( + 'Task was cancelled', + 0, + 'parent-tool-id', + ); + }); + + it('should not process non-task-execution displays', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const nonTaskDisplay = { + type: 'other', + content: 'some content', + }; + + handler('call-id', nonTaskDisplay as unknown as TaskResultDisplay); + + expect(mockAdapter.processSubagentToolCall).not.toHaveBeenCalled(); + expect(mockAdapter.emitToolResult).not.toHaveBeenCalled(); + }); + + it('should handle tool calls with failed status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'failed', + error: 'Tool execution failed', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + callId: 'tool-1', + error: expect.any(Error), + errorType: ToolErrorType.EXECUTION_FAILED, + }), + 'parent-tool-id', + ); + }); + + it('should handle tool calls without result content', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: '', + responseParts: [], + }, + ], + }; + + handler('task-call-id', taskDisplay); + + // Should not emit tool_result if no content + expect(mockAdapter.emitToolResult).not.toHaveBeenCalled(); + }); + + it('should work without adapter (non-JSON mode)', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + undefined, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + // Should not throw + expect(() => handler('task-call-id', taskDisplay)).not.toThrow(); + }); + + it('should work with adapter that does not support subagent APIs', () => { + const limitedAdapter = { + emitToolResult: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + limitedAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + // Should not throw + expect(() => handler('task-call-id', taskDisplay)).not.toThrow(); + }); +}); + +describe('functionResponsePartsToString', () => { + it('should extract output from functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe('function output'); + }); + + it('should handle multiple functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + response: { + output: 'output1', + }, + }, + }, + { + functionResponse: { + response: { + output: 'output2', + }, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe('output1output2'); + }); + + it('should return empty string for missing output', () => { + const parts: Part[] = [ + { + functionResponse: { + response: {}, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe(''); + }); + + it('should JSON.stringify non-functionResponse parts', () => { + const parts: Part[] = [ + { text: 'text part' }, + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ]; + const result = functionResponsePartsToString(parts); + expect(result).toContain('function output'); + expect(result).toContain('text part'); + }); + + it('should handle empty array', () => { + expect(functionResponsePartsToString([])).toBe(''); + }); + + it('should handle functionResponse with null response', () => { + const parts: Part[] = [ + { + functionResponse: { + response: null as unknown as Record, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe(''); + }); +}); + +describe('toolResultContent', () => { + it('should return resultDisplay string when available', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: 'Result content', + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Result content'); + }); + + it('should return undefined for empty resultDisplay string', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: ' ', + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBeUndefined(); + }); + + it('should use functionResponsePartsToString for responseParts', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('function output'); + }); + + it('should return error message when error is present', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [], + error: new Error('Test error message'), + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Test error message'); + }); + + it('should prefer resultDisplay over responseParts', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: 'Direct result', + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Direct result'); + }); + + it('should prefer responseParts over error', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + error: new Error('Error message'), + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('function output'); + }); + + it('should return undefined when no content is available', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts index 9fb1a5d3..fe71730b 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -11,19 +11,20 @@ import type { OutputUpdateHandler, ToolCallRequestInfo, ToolCallResponseInfo, + SessionMetrics, } from '@qwen-code/qwen-code-core'; import { ToolErrorType } from '@qwen-code/qwen-code-core'; import type { Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, Usage, - ExtendedUsage, PermissionMode, CLISystemMessage, } from '../nonInteractive/types.js'; import { CommandService } from '../services/CommandService.js'; import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js'; import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import { computeSessionStats } from '../ui/utils/computeStats.js'; /** * Normalizes various part list formats into a consistent Part[] array. @@ -147,20 +148,38 @@ export function extractUsageFromGeminiClient( } /** - * Calculates approximate cost for API usage. - * Currently returns 0 as a placeholder - cost calculation logic can be added here. + * Computes Usage information from SessionMetrics using computeSessionStats. + * Aggregates token usage across all models in the session. * - * @param usage - Usage information from API response - * @returns Approximate cost in USD or undefined if not calculable + * @param metrics - Session metrics from uiTelemetryService + * @returns Usage object with token counts */ -export function calculateApproximateCost( - usage: Usage | ExtendedUsage | undefined, -): number | undefined { - if (!usage) { - return undefined; +export function computeUsageFromMetrics(metrics: SessionMetrics): Usage { + const stats = computeSessionStats(metrics); + const { models } = metrics; + + // Sum up output tokens (candidates) and total tokens across all models + const totalOutputTokens = Object.values(models).reduce( + (acc, model) => acc + model.tokens.candidates, + 0, + ); + const totalTokens = Object.values(models).reduce( + (acc, model) => acc + model.tokens.total, + 0, + ); + + const usage: Usage = { + input_tokens: stats.totalPromptTokens, + output_tokens: totalOutputTokens, + cache_read_input_tokens: stats.totalCachedTokens, + }; + + // Only include total_tokens if it's greater than 0 + if (totalTokens > 0) { + usage.total_tokens = totalTokens; } - // TODO: Implement actual cost calculation based on token counts and model pricing - return 0; + + return usage; } /**