Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +124,24 @@ export class StreamJsonOutputAdapter
this.emitMessage(message);
}

override processEvent(event: ServerGeminiStreamEvent): void {
Comment thread
qqqys marked this conversation as resolved.
// 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.
*/
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/nonInteractive/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
ActiveGoal,
SubagentConfig,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
Expand Down Expand Up @@ -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';
Expand Down
92 changes: 92 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <condition>` (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',
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/ui/commands/goalCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
});

Expand Down Expand Up @@ -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 },
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/ui/commands/goalCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const goalCommand: SlashCommand = {
},
argumentHint: '[<condition> | clear]',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive'] as const,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (
context: CommandContext,
args: string,
Expand Down Expand Up @@ -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;
}

Expand Down
Loading