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
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1841,6 +1841,8 @@ export default {
'Press Ctrl+O to show full tool output',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Set a goal — keep working until the condition is met':
'Set a goal — keep working until the condition is met',
'Exited plan mode. Previous approval mode restored.':
'Exited plan mode. Previous approval mode restored.',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,8 @@ export default {
'緊湊模式下隱藏工具輸出和思考過程,界面更簡潔(Ctrl+O 切換)。',
'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看詳細工具調用結果',
'Switch to plan mode or exit plan mode': '切換到計劃模式或退出計劃模式',
'Set a goal — keep working until the condition is met':
'設定目標 — 持續工作直到條件滿足',
'Exited plan mode. Previous approval mode restored.':
'已退出計劃模式,已恢復之前的審批模式。',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,8 @@ export default {
'紧凑模式下隐藏工具输出和思考过程,界面更简洁(Ctrl+O 切换)。',
'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果',
'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式',
'Set a goal — keep working until the condition is met':
'设定目标 — 持续工作直到条件满足',
'Exited plan mode. Previous approval mode restored.':
'已退出计划模式,已恢复之前的审批模式。',
'Enabled plan mode. The agent will analyze and plan without executing tools.':
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { exportCommand } from '../ui/commands/exportCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { goalCommand } from '../ui/commands/goalCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { hooksCommand } from '../ui/commands/hooksCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
Expand Down Expand Up @@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(this.config?.getManagedAutoMemoryEnabled()
? [dreamCommand, forgetCommand]
: []),
goalCommand,
memoryCommand,
modelCommand,
manageModelsCommand,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { loadLowlight } from './utils/lowlightLoader.js';
import { restoreGoalFromHistory } from './utils/restoreGoal.js';
import {
getStickyTodos,
getStickyTodoMaxVisibleItems,
Expand Down Expand Up @@ -490,6 +491,13 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);

// Re-arm any `/goal` that was active when the prior session ended.
try {
restoreGoalFromHistory(historyItems, config, historyManager.addItem);
} catch {
// Restore is best-effort — never block resume on it.
}

const recovered = await config.loadPausedBackgroundAgents(
config.getSessionId(),
);
Expand Down
332 changes: 332 additions & 0 deletions packages/cli/src/ui/commands/goalCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { goalCommand } from './goalCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
__resetActiveGoalStoreForTests,
clearActiveGoal,
notifyGoalTerminal,
} from '@qwen-code/qwen-code-core';

function makeConfig(overrides: Partial<Config> = {}): Config {
return {
getSessionId: vi.fn().mockReturnValue('sess-1'),
isTrustedFolder: vi.fn().mockReturnValue(true),
getDisableAllHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue({
addFunctionHook: vi.fn().mockReturnValue('hook-1'),
removeFunctionHook: vi.fn().mockReturnValue(true),
}),
...overrides,
} as unknown as Config;
}

describe('goalCommand', () => {
beforeEach(() => __resetActiveGoalStoreForTests());
afterEach(() => __resetActiveGoalStoreForTests());

it('is currently limited to interactive mode', () => {
expect(goalCommand.supportedModes).toEqual(['interactive']);
});

it('rejects when config is missing', async () => {
const ctx = createMockCommandContext();
const result = await goalCommand.action!(ctx, 'do x');
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
});
});

it('shows status (no goal) for empty args', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
const result = await goalCommand.action!(ctx, '');
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
});
expect((result as { content: string }).content).toMatch(/no goal set/i);
});

it('blocks /goal in untrusted folder', async () => {
const ctx = createMockCommandContext({
services: {
config: makeConfig({
isTrustedFolder: vi.fn().mockReturnValue(false),
} as unknown as Partial<Config>),
},
});
const result = await goalCommand.action!(ctx, 'do x');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(/trusted/i);
});

it('blocks /goal when hooks are disabled by policy', async () => {
const ctx = createMockCommandContext({
services: {
config: makeConfig({
getDisableAllHooks: vi.fn().mockReturnValue(true),
} as unknown as Partial<Config>),
},
});
const result = await goalCommand.action!(ctx, 'do x');
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(/disabled/i);
});

it('rejects oversized conditions', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
const result = await goalCommand.action!(ctx, 'x'.repeat(4001));
expect(result).toMatchObject({ type: 'message', messageType: 'error' });
expect((result as { content: string }).content).toMatch(/limited/i);
});

it('clears existing goal on clear keyword and emits a cleared card', async () => {
const cfg = makeConfig();
const ctx = createMockCommandContext({
services: { config: cfg as unknown as Config },
});
await goalCommand.action!(ctx, 'write hello');
const before = (ctx.ui.addItem as ReturnType<typeof vi.fn>).mock.calls
.length;
const result = await goalCommand.action!(ctx, 'clear');
expect(result).toBeUndefined();
const after = (ctx.ui.addItem as ReturnType<typeof vi.fn>).mock.calls
.length;
expect(after).toBe(before + 1);
const lastItem = (ctx.ui.addItem as ReturnType<typeof vi.fn>).mock.calls[
after - 1
][0];
expect(lastItem).toMatchObject({
type: 'goal_status',
kind: 'cleared',
condition: 'write hello',
});
});

it('returns info when clearing a non-existent goal', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
const result = await goalCommand.action!(ctx, 'cancel');
expect(result).toMatchObject({
type: 'message',
messageType: 'info',
content: 'No goal set.',
});
});

it('registers the hook and submits an instructional prompt on set', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
const result = await goalCommand.action!(ctx, 'write a hello world script');
expect(result).toMatchObject({ type: 'submit_prompt' });
const submit = result as { content: Array<{ text: string }> };
expect(submit.content[0].text).toMatch(/Stop hook is now active/i);
expect(submit.content[0].text).toMatch(/write a hello world script/);

const setCall = (ctx.ui.addItem as ReturnType<typeof vi.fn>).mock
.calls[0][0];
expect(setCall).toMatchObject({
type: 'goal_status',
kind: 'set',
condition: 'write a hello world script',
});
});

it('shows active goal status when re-invoked with empty args', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
const result = await goalCommand.action!(ctx, '');
expect((result as { content: string }).content).toMatch(
/Goal active: do x/,
);
});

it('forwards core terminal events into a goal_status history item', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
const addItem = ctx.ui.addItem as ReturnType<typeof vi.fn>;
const beforeCount = addItem.mock.calls.length;

notifyGoalTerminal('sess-1', {
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 12_345,
lastReason: 'quoted evidence from transcript',
});

expect(addItem.mock.calls.length).toBe(beforeCount + 1);
const lastItem = addItem.mock.calls.at(-1)![0];
expect(lastItem).toMatchObject({
type: 'goal_status',
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 12_345,
lastReason: 'quoted evidence from transcript',
});
});

it('records terminal events through the chat recording service', async () => {
const recordSlashCommand = vi.fn();
const ctx = createMockCommandContext({
services: {
config: makeConfig({
getChatRecordingService: vi.fn().mockReturnValue({
recordSlashCommand,
}),
} as unknown as Partial<Config>) as unknown as Config,
},
});

await goalCommand.action!(ctx, 'do x');

notifyGoalTerminal('sess-1', {
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 12_345,
lastReason: 'quoted evidence from transcript',
});

expect(recordSlashCommand).toHaveBeenCalledWith({
phase: 'result',
rawCommand: '/goal',
outputHistoryItems: [
expect.objectContaining({
type: 'goal_status',
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 12_345,
lastReason: 'quoted evidence from transcript',
}),
],
});
});

it('after achievement, empty /goal shows the last completed summary', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
// Real flow: hook callback clears active goal BEFORE notifying.
clearActiveGoal('sess-1');
notifyGoalTerminal('sess-1', {
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 24_000,
lastReason: 'transcript shows completion',
});
const result = await goalCommand.action!(ctx, '');
const content = (result as { content: string }).content;
expect(content).toMatch(/Goal achieved/);
expect(content).toMatch(/3 turns/);
expect(content).toMatch(/24s/);
expect(content).toMatch(/Goal: do x/);
// `Last check:` line is preserved on the achieved summary so the
// empty-`/goal` re-display matches the inline terminal history card.
expect(content).toMatch(/Last check: transcript shows completion/);
});

it('strict claude alignment: `/goal clear` with no active goal does NOT dismiss the achievement summary', async () => {
// Claude Code's `woH` bails (`q.length===0 → return null`) when no active
// goal exists — it does NOT write a dismissal sentinel and does NOT wipe
// the cache. Subsequent empty `/goal` still surfaces the previous
// achievement via `findLastTerminalGoal`. We pin this behavior to prevent
// accidental divergence; users who want a true "forget" will need a
// separate dedicated keyword (out of scope for this alignment).
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
clearActiveGoal('sess-1');
notifyGoalTerminal('sess-1', {
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 1_000,
});

const addItem = ctx.ui.addItem as ReturnType<typeof vi.fn>;
const beforeClearCount = addItem.mock.calls.length;

// /goal clear with no active goal: pure no-op informational message
const clearResult = await goalCommand.action!(ctx, 'clear');
expect(clearResult).toMatchObject({
type: 'message',
messageType: 'info',
content: 'No goal set.',
});
expect(addItem.mock.calls.length).toBe(beforeClearCount);

// Cache survives — empty /goal still shows the achievement card.
const afterClear = await goalCommand.action!(ctx, '');
expect((afterClear as { content: string }).content).toMatch(
/Goal achieved/,
);
});

it('after abort, empty /goal shows the aborted summary', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
clearActiveGoal('sess-1');
notifyGoalTerminal('sess-1', {
kind: 'aborted',
condition: 'do x',
iterations: 50,
durationMs: 60_000,
systemMessage: 'Goal max iterations reached',
});
const result = await goalCommand.action!(ctx, '');
const content = (result as { content: string }).content;
expect(content).toMatch(/Goal aborted/);
expect(content).toMatch(/Goal: do x/);
// No more `Last check:` line — the `systemMessage`/`lastReason` content
// lives on the goal_status history item (see test below) but is dropped
// from the empty-/goal summary.
expect(content).not.toMatch(/Last check/);
});

it('falls back to systemMessage as lastReason on aborted events', async () => {
const ctx = createMockCommandContext({
services: { config: makeConfig() as unknown as Config },
});
await goalCommand.action!(ctx, 'do x');
const addItem = ctx.ui.addItem as ReturnType<typeof vi.fn>;

notifyGoalTerminal('sess-1', {
kind: 'aborted',
condition: 'do x',
iterations: 50,
durationMs: 60_000,
systemMessage: 'Goal max iterations reached',
});

const lastItem = addItem.mock.calls.at(-1)![0];
expect(lastItem).toMatchObject({
kind: 'aborted',
lastReason: 'Goal max iterations reached',
});
});
});
Loading
Loading