diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index 64448c8a6a..532cedb9a7 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -100,6 +100,59 @@ describe('StreamJsonOutputAdapter', () => { }); }); + it('should emit active goal stream events', () => { + adapter.processEvent({ + type: GeminiEventType.ActiveGoal, + value: { + condition: 'finish the refactor', + iterations: 2, + setAt: 123, + tokensAtStart: 456, + hookId: 'goal-hook-id', + lastReason: 'still missing verification', + }, + }); + + adapter.processEvent({ + type: GeminiEventType.ActiveGoal, + value: null, + }); + + const activeGoalEvents = stdoutWriteSpy.mock.calls + .map((call: unknown[]) => JSON.parse(call[0] as string)) + .filter( + (message: { type?: string; event?: { type?: string } }) => + message.type === 'stream_event' && + message.event?.type === 'active_goal', + ); + + expect(activeGoalEvents).toEqual([ + expect.objectContaining({ + session_id: 'test-session-id', + parent_tool_use_id: null, + event: { + type: 'active_goal', + active_goal: { + condition: 'finish the refactor', + iterations: 2, + setAt: 123, + tokensAtStart: 456, + hookId: 'goal-hook-id', + lastReason: 'still missing verification', + }, + }, + }), + expect.objectContaining({ + session_id: 'test-session-id', + parent_tool_use_id: null, + event: { + type: 'active_goal', + active_goal: null, + }, + }), + ]); + }); + it('should emit message_start event on first content', () => { adapter.processEvent({ type: GeminiEventType.Content, @@ -220,6 +273,35 @@ describe('StreamJsonOutputAdapter', () => { expect(streamEventCall).toBeUndefined(); }); + it('should not emit active goal stream events', () => { + adapter.processEvent({ + type: GeminiEventType.ActiveGoal, + value: { + condition: 'finish the refactor', + iterations: 0, + setAt: 123, + tokensAtStart: 456, + hookId: 'goal-hook-id', + }, + }); + + const activeGoalEventCall = stdoutWriteSpy.mock.calls.find( + (call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event?.type === 'active_goal' + ); + } catch { + return false; + } + }, + ); + + expect(activeGoalEventCall).toBeUndefined(); + }); + it('should still emit final assistant message', () => { adapter.startAssistantMessage(); adapter.processEvent({ diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index 58095221ac..5e9d803ce5 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -7,9 +7,11 @@ import { randomUUID } from 'node:crypto'; import type { Config, + ServerGeminiStreamEvent, ToolCallRequestInfo, McpToolProgressData, } from '@qwen-code/qwen-code-core'; +import { GeminiEventType } from '@qwen-code/qwen-code-core'; import type { CLIAssistantMessage, CLIMessage, @@ -122,6 +124,24 @@ export class StreamJsonOutputAdapter this.emitMessage(message); } + override processEvent(event: ServerGeminiStreamEvent): void { + // Active goal updates are session-level metadata, not message content. + // They intentionally bypass the base finalized guard so late goal state + // changes can still reach stream consumers. + if (event.type === GeminiEventType.ActiveGoal) { + this.emitStreamEventIfEnabled( + { + type: 'active_goal', + active_goal: event.value, + }, + null, + ); + return; + } + + super.processEvent(event); + } + /** * Overrides base class hook to emit stream event when text block is created. */ diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 84efda11ea..774bea2ba8 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { + ActiveGoal, SubagentConfig, McpToolProgressData, } from '@qwen-code/qwen-code-core'; @@ -246,13 +247,19 @@ export interface ToolProgressStreamEvent { content: McpToolProgressData; } +export interface ActiveGoalStreamEvent { + type: 'active_goal'; + active_goal: ActiveGoal | null; +} + export type StreamEvent = | MessageStartStreamEvent | ContentBlockStartEvent | ContentBlockDeltaEvent | ContentBlockStopEvent | MessageStopStreamEvent - | ToolProgressStreamEvent; + | ToolProgressStreamEvent + | ActiveGoalStreamEvent; export interface CLIPartialAssistantMessage { type: 'stream_event'; diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index ad81b24bed..a331099178 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -234,6 +234,98 @@ describe('handleSlashCommand', () => { } }); + it('should report no active goal for empty non-interactive /goal', async () => { + mockGetCommands.mockReturnValue([goalCommand]); + + const result = await handleSlashCommand( + '/goal', + abortController, + mockConfig, + mockSettings, + ); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'No goal set. Usage: `/goal ` (or `/goal clear`).', + }); + }); + + it('should report active goal status after setting a non-interactive /goal', async () => { + mockGetCommands.mockReturnValue([goalCommand]); + + await handleSlashCommand( + '/goal write a hello world script', + abortController, + mockConfig, + mockSettings, + ); + const result = await handleSlashCommand( + '/goal', + abortController, + mockConfig, + mockSettings, + ); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + if (result.type === 'message') { + expect(result.content).toContain( + 'Goal active: write a hello world script', + ); + expect(result.content).toContain('not yet evaluated'); + } + }); + + it('should report cleared goal for non-interactive /goal clear', async () => { + mockGetCommands.mockReturnValue([goalCommand]); + + await handleSlashCommand( + '/goal write a hello world script', + abortController, + mockConfig, + mockSettings, + ); + const result = await handleSlashCommand( + '/goal clear', + abortController, + mockConfig, + mockSettings, + ); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Goal cleared: write a hello world script', + }); + }); + + it('should report cleared goal for ACP /goal clear', async () => { + vi.mocked(mockConfig.getExperimentalZedIntegration).mockReturnValue(true); + mockGetCommands.mockReturnValue([goalCommand]); + + await handleSlashCommand( + '/goal write a hello world script', + abortController, + mockConfig, + mockSettings, + ); + const result = await handleSlashCommand( + '/goal clear', + abortController, + mockConfig, + mockSettings, + ); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Goal cleared: write a hello world script', + }); + }); + it('should execute FILE commands in any mode without explicit supportedModes', async () => { const mockFileCommand = { name: 'custom', diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index 286bf8f0a0..e34a507c24 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -31,10 +31,11 @@ describe('goalCommand', () => { beforeEach(() => __resetActiveGoalStoreForTests()); afterEach(() => __resetActiveGoalStoreForTests()); - it('is available in interactive and non-interactive modes', () => { + it('is available in interactive, non-interactive, and ACP modes', () => { expect(goalCommand.supportedModes).toEqual([ 'interactive', 'non_interactive', + 'acp', ]); }); @@ -117,6 +118,21 @@ describe('goalCommand', () => { }); }); + it('returns a clear message outside interactive mode', async () => { + const cfg = makeConfig(); + const ctx = createMockCommandContext({ + executionMode: 'acp', + services: { config: cfg as unknown as Config }, + }); + await goalCommand.action!(ctx, 'write hello'); + const result = await goalCommand.action!(ctx, 'clear'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + content: 'Goal cleared: write hello', + }); + }); + it('returns info when clearing a non-existent goal', async () => { const ctx = createMockCommandContext({ services: { config: makeConfig() as unknown as Config }, diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 63e0fb23b9..ece4c19f65 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -100,7 +100,7 @@ export const goalCommand: SlashCommand = { }, argumentHint: '[ | clear]', kind: CommandKind.BUILT_IN, - supportedModes: ['interactive', 'non_interactive'] as const, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, args: string, @@ -158,6 +158,9 @@ export const goalCommand: SlashCommand = { durationMs: Date.now() - cleared.setAt, }; context.ui.addItem(clearedItem, Date.now()); + if (context.executionMode !== 'interactive') { + return infoMessage(`Goal cleared: ${cleared.condition}`); + } return; }