Skip to content
Closed
53 changes: 53 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
17 changes: 17 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,21 @@ export class StreamJsonOutputAdapter
this.emitMessage(message);
}

override processEvent(event: ServerGeminiStreamEvent): void {
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
107 changes: 105 additions & 2 deletions packages/cli/src/nonInteractiveCliCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getAvailableCommands,
handleSlashCommand,
} from './nonInteractiveCliCommands.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
__resetActiveGoalStoreForTests,
type Config,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from './config/settings.js';
import { CommandKind, type ExecutionMode } from './ui/commands/types.js';
import { filterCommandsForMode } from './services/commandUtils.js';
import { goalCommand } from './ui/commands/goalCommand.js';

// Mock the CommandService
const mockGetCommands = vi.hoisted(() => vi.fn());
Expand All @@ -32,6 +36,7 @@ describe('handleSlashCommand', () => {

beforeEach(() => {
vi.clearAllMocks();
__resetActiveGoalStoreForTests();
// getCommandsForMode applies real mode filtering on top of getCommands()
mockGetCommandsForMode.mockImplementation((mode: ExecutionMode) =>
filterCommandsForMode(mockGetCommands(), mode),
Expand All @@ -55,6 +60,12 @@ describe('handleSlashCommand', () => {
getFolderTrustFeature: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(false),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
isTrustedFolder: vi.fn().mockReturnValue(true),
getDisableAllHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue({
addFunctionHook: vi.fn().mockReturnValue('goal-hook-id'),
removeFunctionHook: vi.fn().mockReturnValue(true),
}),
setModelInvocableCommandsProvider: vi.fn(),
setModelInvocableCommandsExecutor: vi.fn(),
getDisabledSlashCommands: vi.fn().mockReturnValue([]),
Expand All @@ -71,6 +82,10 @@ describe('handleSlashCommand', () => {
abortController = new AbortController();
});

afterEach(() => {
__resetActiveGoalStoreForTests();
});

it('should return no_command for non-slash input', async () => {
const result = await handleSlashCommand(
'regular text',
Expand Down Expand Up @@ -199,6 +214,94 @@ describe('handleSlashCommand', () => {
}
});

it('should execute /goal in non-interactive mode as a submit_prompt command', async () => {
mockGetCommands.mockReturnValue([goalCommand]);

const result = await handleSlashCommand(
'/goal write a hello world script',
abortController,
mockConfig,
mockSettings,
);

expect(result.type).toBe('submit_prompt');
if (result.type === 'submit_prompt') {
expect(result.content).toEqual([
expect.objectContaining({
text: expect.stringContaining('write a hello world script'),
}),
]);
}
});

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 execute FILE commands in any mode without explicit supportedModes', async () => {
const mockFileCommand = {
name: 'custom',
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/ui/commands/goalCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ describe('goalCommand', () => {
beforeEach(() => __resetActiveGoalStoreForTests());
afterEach(() => __resetActiveGoalStoreForTests());

it('is currently limited to interactive mode', () => {
expect(goalCommand.supportedModes).toEqual(['interactive']);
it('is available in interactive and non-interactive modes', () => {
expect(goalCommand.supportedModes).toEqual([
'interactive',
'non_interactive',
]);
});

it('rejects when config is missing', async () => {
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'] as const,
supportedModes: ['interactive', 'non_interactive'] 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 === 'non_interactive') {
return infoMessage(`Goal cleared: ${cleared.condition}`);
}
return;
}

Expand Down
Loading
Loading