feat(cli): adopt Goal v3 in interactive TUI (#8005)

* feat(cli): adopt Goal v3 in interactive TUI

* fix(cli): restore Goal CI contracts

* fix(cli): stop a queued message from stalling the Goal loop (#8005)

Reserve the next Goal turn only for a user batch the queue will actually
release: peekNextUserBatchKey now mirrors the two-lane drain gate, so an
active Goal turn no longer binds a permit to a held plain message and the
loop keeps running.

Also release the Goal turn binding on the background-capacity early
return, drop a redundant getSteerInput spread, recognise the legacy
/goal clear aliases in interactive mode, and restore the rollback
assertion for loadPausedBackgroundAgents.

* fix(cli): address review feedback on Goal queue and retry hint (#8005)

* fix(cli): restore Goal trust gate and address review feedback (#8005)

* fix(cli): address Goal review feedback on cancel release, icons, claim gate (#8005)

* fix(cli): address Goal review feedback on non-interactive guard, icons, and drain retry (#8005)

* fix(cli): address review feedback on Goal v3 TUI (#8005)

- Strip 'set' keyword before forwarding to legacy path in non-interactive
  /goal set, preventing the keyword from leaking into the goal condition
- Add goalTerminalErrorRef to prevent post-stream cleanup from wiping
  Goal turn terminal errors
- Add 50-turn continuation budget to Goal runtime, matching the legacy
  MAX_GOAL_ITERATIONS cap
- Display verifier rejection status cards (verifier_reject cause)
- Clear stale lastReason when editing a goal objective

* fix(test): align client-goal turn count with continuation budget (#8005)

* fix(cli): address Goal set-clear and delivery-error review feedback (#8005)

Non-interactive `/goal set <clear-keyword>` now sets a literal objective
instead of clearing the active goal, by bypassing the clear-keyword check
for explicit set operations. Goal-turn stream errors now fire
onDeliveryFailed rather than onDelivered by including goalTerminalErrorRef
in the post-stream delivery dispatch.

* fix(cli): rename misleading missingActiveGoalContext variable (#8005)

* fix(cli): stop Goal queue from stranding input and orphaning prompts (#8005)

Only an active Goal turn holds ordinary input now; paused, blocked and
usage_limited states drain the queue normally so a user whose Goal is
merely paused (e.g. via Escape) is never stranded waiting for /goal clear.

A cancelled Goal continuation turn also strips its synthetic "no new real
user input" prompt from the chat history. Previously the auto-restore
branch bailed before its orphan strip ran (Goal turns add no UI user
item), so the preamble survived and appendCuratedContent merged the user's
next real message into it.

* fix(core): address Goal resume-budget and mid-turn clear review feedback (#8005)

Resuming a Goal that exhausted its 50-turn continuation budget was
accepted and reported as `active`, then immediately re-transitioned to
`usage_limited` without running a turn: the resume branch kept the
exhausted `turnCount`, which `queueContinuation` re-checks. Resume now
resets `turnCount` so an explicit resume grants a fresh continuation
budget and the reported outcome matches the settled one.

A mid-turn `/goal clear` with no active Goal was silently swallowed
because its causeless `goal_control` result rendered only when idle. The
handler now renders any causeless result (a `status` read or a no-Goal
`clear`), which never broadcasts and so cannot double-render.

* test(cli): cover Goal tool-batch fail-close guards (#8005)

* fix(core,cli): guard budget-exhaustion identity and pass goalContext to tool-result recording (#8005)

The budget-exhaustion callback in queueContinuation only checked
goal status, not identity. A replace dispatched during the journal
append could create a fresh goal that the stale callback then
incorrectly usage-limited. Capture goalId/revision at enqueue time
and return early on mismatch, matching the existing identity-guard
pattern used by handleStartFailure and recordVerificationOutcome.

The TUI recorded tool results without goalContext, so the evidence
catalog never admitted tool-result records and any Goal whose
objective depended on external state could not be verified. Pass
request.goalContext at both recordToolResult call sites in
useGeminiStream, tagging get_goal/update_goal results as
goal_runtime to match the CoreToolScheduler pattern.

---------

Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
This commit is contained in:
qqqys 2026-08-01 19:21:53 +08:00 committed by GitHub
parent 933d78a089
commit 4aa7d1ec0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 5654 additions and 1199 deletions

View file

@ -2473,6 +2473,7 @@ export default {
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.',
'Set a goal — keep working until the condition is met':
'Set a goal — keep working until the condition is met',
'Set or control a session goal': 'Set or control a session goal',
'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.':

View file

@ -2010,6 +2010,7 @@ export default {
'設定具備推理能力的模型思考的強度({{tiers}});依各供應商進行映射與鉗制。',
'Set a goal — keep working until the condition is met':
'設定目標 — 持續工作直到條件滿足',
'Set or control a session goal': '設定或控制工作階段目標',
'Exited plan mode. Previous approval mode restored.':
'已退出計劃模式,已恢復之前的審批模式。',
'Enabled plan mode. The agent will analyze and plan without executing tools.':

View file

@ -2216,6 +2216,7 @@ export default {
'设置具备推理能力的模型思考的强度({{tiers}});按各提供方进行映射与钳制。',
'Set a goal — keep working until the condition is met':
'设定目标 — 持续工作直到条件满足',
'Set or control a session goal': '设定或控制会话目标',
'Exited plan mode. Previous approval mode restored.':
'已退出计划模式,已恢复之前的审批模式。',
'Enabled plan mode. The agent will analyze and plan without executing tools.':

View file

@ -187,6 +187,13 @@ function handleCommandResult(
originalType: 'confirm_action',
};
case 'goal_control':
return {
type: 'unsupported',
reason: 'Goal control is not supported in non-interactive mode yet.',
originalType: 'goal_control',
};
default: {
// Exhaustiveness check
const _exhaustive: never = result;

View file

@ -32,6 +32,7 @@ import {
type Mock,
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
import { renderHook } from '@testing-library/react';
import { useContext, useState, useReducer, useEffect, act } from 'react';
import {
AppContainer,
@ -43,6 +44,7 @@ import {
mergeStartupWarnings,
shouldAutoOpenSkillReview,
shouldDrainMessageQueue,
useQueuedSubmissionDrain,
} from './AppContainer.js';
import {
formatSessionWindowTitle,
@ -54,6 +56,7 @@ import {
makeFakeConfig,
SendMessageType,
type GeminiClient,
type GoalTurnHost,
type SubagentManager,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
@ -347,6 +350,7 @@ describe('AppContainer State Management', () => {
restartReason: 'NONE',
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -534,6 +538,7 @@ describe('AppContainer State Management', () => {
});
const addMessage = vi.fn();
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage,
clearQueue: vi.fn(),
@ -1356,6 +1361,338 @@ describe('AppContainer State Management', () => {
).toBe(true);
});
it('binds one Goal host that enqueues, preempts, and cleans up', async () => {
const enqueueGoalTurn = vi.fn();
const removeGoalTurns = vi.fn().mockReturnValue([]);
const preemptGoalTurn = vi.fn();
const submitQuery = vi.fn();
const unbind = vi.fn();
let host: GoalTurnHost | undefined;
vi.spyOn(mockConfig, 'bindGoalTurnHost').mockImplementation(
(nextHost) => {
host = nextHost;
return unbind;
},
);
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
pendingSubmissionCount: 0,
addMessage: vi.fn(),
enqueueGoalTurn,
peekNextUserBatchKey: vi.fn(),
hasQueuedUserMessages: vi.fn().mockReturnValue(false),
getPendingSubmissionCount: vi.fn().mockReturnValue(0),
claimGoalTurn: vi.fn(),
claimDirectUserAdmission: vi.fn(),
removeGoalTurns,
popNextSubmission: vi.fn().mockReturnValue(null),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
restoreMessages: vi.fn(),
drainQueue: vi.fn().mockReturnValue([]),
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery,
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
preemptGoalTurn,
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
});
const view = render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
expect(mockConfig.bindGoalTurnHost).toHaveBeenCalledTimes(1);
await act(async () => {
await host!.startGoalTurn({
permit: { goalId: 'goal-1', revision: 2, turnId: 'turn-1' },
continuationContext: 'continue automatically',
verifierFeedback: 'collect evidence',
});
});
expect(enqueueGoalTurn).toHaveBeenCalledWith({
permit: { goalId: 'goal-1', revision: 2, turnId: 'turn-1' },
continuationContext: 'continue automatically',
verifierFeedback: 'collect evidence',
});
expect(submitQuery).not.toHaveBeenCalled();
act(() => {
host!.preemptGoalTurn('goal edited');
});
expect(removeGoalTurns).toHaveBeenCalledTimes(1);
expect(preemptGoalTurn).toHaveBeenCalledWith('goal edited');
view.unmount();
expect(unbind).toHaveBeenCalledTimes(1);
});
it('holds ordinary input while the Goal is active and drains it once paused', async () => {
let goalStatus: 'active' | 'paused' = 'active';
let goalListener: (() => void) | undefined;
const unsubscribe = vi.fn();
const goalRuntime = {
getSnapshot: vi.fn(() => ({
goal: { status: goalStatus },
})),
subscribe: vi.fn((listener: () => void) => {
goalListener = listener;
return unsubscribe;
}),
} as unknown as ReturnType<Config['getGoalRuntime']>;
vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime);
const submitQuery = vi.fn().mockResolvedValue(undefined);
let userPopped = false;
const popNextSubmission = vi.fn((mode = 'normal') => {
// 'priority' (active Goal) holds the plain user batch; 'normal'
// (paused) drains it.
if (mode !== 'normal' || userPopped) return null;
userPopped = true;
return {
kind: 'user' as const,
modelText: 'held user work',
turnKey: 'message-queue:held-user',
};
});
const view = renderHook(() =>
useQueuedSubmissionDrain({
config: mockConfig,
isConfigInitialized: true,
streamingState: StreamingState.Idle,
isProcessing: false,
dialogsVisible: false,
pendingSubmissionCount: 1,
getPendingSubmissionCount: () => (userPopped ? 0 : 1),
popNextSubmission,
enqueueGoalTurn: vi.fn(),
restoreMessages: vi.fn(),
submitQuery,
submissionInFlightRef: { current: false },
submissionSettledRevision: 0,
}),
);
// While the Goal is active the drain selects 'priority' and the plain
// user batch stays held (criterion #2).
await vi.waitFor(() => {
expect(popNextSubmission).toHaveBeenCalledWith('priority');
});
expect(submitQuery).not.toHaveBeenCalled();
// Pausing the Goal releases the held input: the drain switches to
// 'normal' and the user work is delivered.
goalStatus = 'paused';
act(() => {
goalListener?.();
});
await vi.waitFor(() => {
expect(popNextSubmission).toHaveBeenCalledWith('normal');
expect(submitQuery).toHaveBeenCalledWith(
'held user work',
SendMessageType.UserQuery,
undefined,
expect.objectContaining({
userAdmission: { turnKey: 'message-queue:held-user' },
}),
);
});
view.unmount();
expect(unsubscribe).toHaveBeenCalledOnce();
});
it('treats paused, blocked and usage_limited Goals as drain-eligible', async () => {
const getGoalRuntimeSpy = vi.spyOn(mockConfig, 'getGoalRuntime');
for (const status of ['paused', 'blocked', 'usage_limited'] as const) {
getGoalRuntimeSpy.mockReturnValue({
getSnapshot: () => ({ goal: { status } }),
subscribe: () => vi.fn(),
} as unknown as ReturnType<Config['getGoalRuntime']>);
const submitQuery = vi.fn().mockResolvedValue(undefined);
let popped = false;
const popNextSubmission = vi.fn(() => {
if (popped) return null;
popped = true;
return {
kind: 'user' as const,
modelText: 'ordinary user work',
turnKey: `message-queue:${status}`,
};
});
const view = renderHook(() =>
useQueuedSubmissionDrain({
config: mockConfig,
isConfigInitialized: true,
streamingState: StreamingState.Idle,
isProcessing: false,
dialogsVisible: false,
pendingSubmissionCount: 1,
getPendingSubmissionCount: () => (popped ? 0 : 1),
popNextSubmission,
enqueueGoalTurn: vi.fn(),
restoreMessages: vi.fn(),
submitQuery,
submissionInFlightRef: { current: false },
submissionSettledRevision: 0,
}),
);
// No turn is running in these states, so the queue drains in 'normal'
// mode and the ordinary message is delivered instead of being held.
await vi.waitFor(() => {
expect(popNextSubmission).toHaveBeenCalledWith('normal');
expect(submitQuery).toHaveBeenCalledWith(
'ordinary user work',
SendMessageType.UserQuery,
undefined,
expect.objectContaining({
userAdmission: { turnKey: `message-queue:${status}` },
}),
);
});
view.unmount();
}
});
it('does not hot-loop a queued submission whose admission keeps failing', async () => {
const goalRuntime = {
getSnapshot: () => ({ goal: { status: 'active' } }),
subscribe: () => vi.fn(),
} as unknown as ReturnType<Config['getGoalRuntime']>;
vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime);
let synchronousPendingCount = 3;
const popNextSubmission = vi.fn(() => {
synchronousPendingCount = 0;
return {
kind: 'user' as const,
modelText: 'persistent failure batch',
turnKey: 'message-queue:persistent',
};
});
const restoreMessages = vi.fn(() => {
synchronousPendingCount = 1;
});
const submitQuery = vi.fn(async (...args: unknown[]) => {
const metadata = args[3] as
| { onAdmissionFailed?: () => void }
| undefined;
metadata?.onAdmissionFailed?.();
throw new Error('persistent prepare failure');
}) as unknown as ReturnType<typeof useGeminiStream>['submitQuery'];
const { rerender } = renderHook(
({ pendingSubmissionCount, submissionSettledRevision }) =>
useQueuedSubmissionDrain({
config: mockConfig,
isConfigInitialized: true,
streamingState: StreamingState.Idle,
isProcessing: false,
dialogsVisible: false,
pendingSubmissionCount,
getPendingSubmissionCount: () => synchronousPendingCount,
popNextSubmission,
enqueueGoalTurn: vi.fn(),
restoreMessages,
submitQuery,
submissionInFlightRef: { current: false },
submissionSettledRevision,
}),
{
initialProps: {
pendingSubmissionCount: 3,
submissionSettledRevision: 0,
},
},
);
await vi.waitFor(() => expect(submitQuery).toHaveBeenCalledOnce());
expect(restoreMessages).toHaveBeenCalledOnce();
rerender({
pendingSubmissionCount: 1,
submissionSettledRevision: 1,
});
await new Promise<void>((resolve) => setTimeout(resolve, 25));
expect(submitQuery).toHaveBeenCalledOnce();
synchronousPendingCount = 2;
rerender({
pendingSubmissionCount: 2,
submissionSettledRevision: 1,
});
await vi.waitFor(() => expect(submitQuery).toHaveBeenCalledTimes(2));
});
it('drains after preprocessing settlement releases the shared lock', async () => {
const goalRuntime = {
getSnapshot: () => ({ goal: { status: 'active' } }),
subscribe: () => vi.fn(),
} as unknown as ReturnType<Config['getGoalRuntime']>;
vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime);
let popped = false;
const popNextSubmission = vi.fn(() => {
if (popped) return null;
popped = true;
return {
kind: 'user' as const,
modelText: 'queued during preprocessing',
turnKey: 'message-queue:during-preprocessing',
};
});
const submitQuery = vi.fn().mockResolvedValue(undefined);
const submissionInFlightRef = { current: true };
const { rerender } = renderHook(
({ submissionSettledRevision }) =>
useQueuedSubmissionDrain({
config: mockConfig,
isConfigInitialized: true,
streamingState: StreamingState.Idle,
isProcessing: false,
dialogsVisible: false,
pendingSubmissionCount: 1,
getPendingSubmissionCount: () => (popped ? 0 : 1),
popNextSubmission,
enqueueGoalTurn: vi.fn(),
restoreMessages: vi.fn(),
submitQuery,
submissionInFlightRef,
submissionSettledRevision,
}),
{ initialProps: { submissionSettledRevision: 0 } },
);
expect(popNextSubmission).not.toHaveBeenCalled();
submissionInFlightRef.current = false;
rerender({ submissionSettledRevision: 1 });
await vi.waitFor(() => {
expect(submitQuery).toHaveBeenCalledWith(
'queued during preprocessing',
SendMessageType.UserQuery,
undefined,
expect.objectContaining({
userAdmission: {
turnKey: 'message-queue:during-preprocessing',
},
}),
);
});
});
it('marks Ctrl+Q submissions to wait for the idle boundary', () => {
const mockQueueMessage = vi.fn();
const mockSubmitQuery = vi.fn();
@ -1372,6 +1709,7 @@ describe('AppContainer State Management', () => {
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1419,6 +1757,7 @@ describe('AppContainer State Management', () => {
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1526,6 +1865,7 @@ describe('AppContainer State Management', () => {
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1574,6 +1914,7 @@ describe('AppContainer State Management', () => {
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1631,6 +1972,7 @@ describe('AppContainer State Management', () => {
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(modelText),
removeGoalTurns: vi.fn().mockReturnValue([]),
popAllMessages: vi.fn().mockReturnValue({
modelText,
submittedPrompt: 'review this',
@ -1680,6 +2022,7 @@ describe('AppContainer State Management', () => {
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(modelText),
removeGoalTurns: vi.fn().mockReturnValue([]),
popAllMessages: vi.fn().mockReturnValue({
modelText,
submittedPrompt: 'review this',
@ -1783,6 +2126,7 @@ describe('AppContainer State Management', () => {
},
);
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1843,6 +2187,7 @@ describe('AppContainer State Management', () => {
},
);
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage,
clearQueue: vi.fn(),
@ -1883,6 +2228,7 @@ describe('AppContainer State Management', () => {
it('does not create provenance for a whitespace-only submission', () => {
const mockQueueMessage = vi.fn();
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1911,6 +2257,7 @@ describe('AppContainer State Management', () => {
it('captures trimmed multiline Unicode input as provenance', () => {
const mockQueueMessage = vi.fn();
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1943,6 +2290,7 @@ describe('AppContainer State Management', () => {
it('uses the explicit pre-attachment text as provenance', () => {
const mockQueueMessage = vi.fn();
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -1989,6 +2337,7 @@ describe('AppContainer State Management', () => {
},
);
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2037,6 +2386,7 @@ describe('AppContainer State Management', () => {
vimMode: 'INSERT',
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2083,6 +2433,7 @@ describe('AppContainer State Management', () => {
};
mockedUseVimModeState.mockImplementation(useMockVimModeState);
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2131,6 +2482,7 @@ describe('AppContainer State Management', () => {
confirmationRequest: null,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2181,6 +2533,7 @@ describe('AppContainer State Management', () => {
isReceivingContent: false,
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2223,6 +2576,7 @@ describe('AppContainer State Management', () => {
} | null;
canUndoLastLoggedUserMessage: boolean;
turnProducedMeaningfulContent: boolean;
wasGoalTurn?: boolean;
}) => void;
let capturedOnCancelSubmit: CapturedCancelSubmit | null = null;
@ -2290,6 +2644,7 @@ describe('AppContainer State Management', () => {
setText: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2359,6 +2714,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2416,6 +2772,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: ['queued follow-up'],
addMessage: vi.fn(),
clearQueue: mockClearQueue,
@ -2451,6 +2808,56 @@ describe('AppContainer State Management', () => {
expect(mockClearQueue).not.toHaveBeenCalled();
});
it('releases queued Goal turn reservations on cancel using goal-turn keys', async () => {
const releaseTurn = vi.fn().mockResolvedValue(undefined);
const goalRuntime = {
releaseTurn,
} as unknown as ReturnType<Config['getGoalRuntime']>;
vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime);
const removeGoalTurns = vi.fn().mockReturnValue(['goal-runtime:turn-1']);
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
});
installCancelCapture({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
removeGoalTurns,
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextTurn: vi.fn().mockReturnValue(null),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await Promise.resolve();
await Promise.resolve();
triggerCancel();
expect(removeGoalTurns).toHaveBeenCalledTimes(1);
await vi.waitFor(() =>
expect(releaseTurn).toHaveBeenCalledWith('goal-runtime:turn-1'),
);
});
it('auto-restores the just-submitted prompt when cancelling before any meaningful output', async () => {
// claude-code parity: ESC immediately after submit (model produced
// nothing) rewinds the user item + trailing INFO and pulls the prompt
@ -2499,6 +2906,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2580,6 +2988,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2612,6 +3021,80 @@ describe('AppContainer State Management', () => {
expect(mockRemoveLastUserMessage).not.toHaveBeenCalled();
});
it('strips the orphaned continuation prompt when a Goal turn is cancelled', async () => {
const mockStripOrphans = vi.fn();
const mockTruncateToItem = vi.fn();
mockedUseTextBuffer.mockReturnValue({
text: '',
setText: vi.fn(),
});
mockedUseHistory.mockReturnValue({
history: [{ id: 1, type: 'info', text: 'Request cancelled.' }],
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: mockTruncateToItem,
});
mockedUseLogger.mockReturnValue({
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
removeLastUserMessage: vi.fn().mockResolvedValue(true),
});
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
setTools: vi.fn().mockResolvedValue(undefined),
isInitialized: vi.fn().mockReturnValue(false),
stripOrphanedUserEntriesFromHistory: mockStripOrphans,
} as unknown as GeminiClient);
installCancelCapture({
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
getQueuedMessagesText: vi.fn().mockReturnValue(''),
popAllMessages: vi.fn().mockReturnValue(null),
drainQueue: vi.fn().mockReturnValue([]),
popNextTurn: vi.fn().mockReturnValue(null),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await Promise.resolve();
await Promise.resolve();
// A Goal continuation turn adds no UI user item, so lastTurnUserItem is
// null and the auto-restore branch (with its own orphan strip) bails.
// wasGoalTurn must trigger the strip independently so the synthetic
// "no new real user input" prompt can't merge into the next message.
triggerCancel({
pendingItem: null,
lastTurnUserItem: null,
canUndoLastLoggedUserMessage: false,
turnProducedMeaningfulContent: false,
wasGoalTurn: true,
});
expect(mockStripOrphans).toHaveBeenCalled();
// Auto-restore itself bailed: there was no user item to rewind.
expect(mockTruncateToItem).not.toHaveBeenCalled();
});
it('reuses the cancelled turn provenance on an unchanged resubmit', async () => {
const modelText =
'<system-reminder>\nmanaged context\n</system-reminder>\n\nreview this';
@ -2646,6 +3129,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: mockQueueMessage,
clearQueue: vi.fn(),
@ -2720,6 +3204,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2788,6 +3273,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2851,6 +3337,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2916,6 +3403,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -2991,6 +3479,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -3064,6 +3553,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -3134,6 +3624,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -3197,6 +3688,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: ['queued thought'],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -3276,6 +3768,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: [],
addMessage: vi.fn(),
clearQueue: vi.fn(),
@ -3351,6 +3844,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: ['/model', 'hi'],
addMessage: vi.fn(),
clearQueue: mockClearQueue,
@ -3401,6 +3895,7 @@ describe('AppContainer State Management', () => {
retryLastPrompt: vi.fn(),
});
mockedUseMessageQueue.mockReturnValue({
removeGoalTurns: vi.fn().mockReturnValue([]),
messageQueue: ['queued follow-up'],
addMessage: vi.fn(),
clearQueue: vi.fn(),

View file

@ -12,6 +12,7 @@ import {
useRef,
useLayoutEffect,
type Dispatch,
type RefObject,
type SetStateAction,
} from 'react';
import { type DOMElement, measureElement } from 'ink';
@ -70,6 +71,7 @@ import {
GitWorktreeService,
readWorktreeSessionMarker,
isSessionRuntimeActive,
type GoalTurnHost,
} from '@qwen-code/qwen-code-core';
import {
applyCollapsePolicyAndSummary,
@ -139,7 +141,7 @@ import {
computeApiTruncationIndex,
isRealUserTurn,
} from './utils/historyMapping.js';
import { restoreGoalFromHistory } from './utils/restoreGoal.js';
import { waitForGoalRuntime } from './utils/goal-runtime.js';
import {
useVimModeState,
useVimModeActions,
@ -193,7 +195,8 @@ import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import {
useMessageQueue,
type QueuedSubmission,
type QueuedUserSubmission,
type UseMessageQueueReturn,
} from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
@ -337,6 +340,172 @@ export function shouldDrainMessageQueue({
);
}
export function useQueuedSubmissionDrain({
config,
isConfigInitialized,
streamingState,
isProcessing,
dialogsVisible,
pendingSubmissionCount,
getPendingSubmissionCount,
popNextSubmission,
enqueueGoalTurn,
restoreMessages,
submitQuery,
submissionInFlightRef,
submissionSettledRevision,
}: {
config: Config;
isConfigInitialized: boolean;
streamingState: StreamingState;
isProcessing: boolean;
dialogsVisible: boolean;
pendingSubmissionCount: number;
getPendingSubmissionCount: UseMessageQueueReturn['getPendingSubmissionCount'];
popNextSubmission: UseMessageQueueReturn['popNextSubmission'];
enqueueGoalTurn: UseMessageQueueReturn['enqueueGoalTurn'];
restoreMessages: UseMessageQueueReturn['restoreMessages'];
submitQuery: ReturnType<typeof useGeminiStream>['submitQuery'];
submissionInFlightRef: RefObject<boolean>;
submissionSettledRevision: number;
}) {
const goalRuntimeSessionId = config.getSessionId();
const [goalQueueRevision, setGoalQueueRevision] = useState(0);
useEffect(() => {
try {
return config.getGoalRuntime().subscribe(() => {
setGoalQueueRevision((revision) => revision + 1);
});
} catch {
return undefined;
}
}, [config, goalRuntimeSessionId]);
const queueDrainingRef = useRef(false);
const admissionFailureRef = useRef<{
pendingSubmissionCount: number;
goalQueueRevision: number;
streamingState: StreamingState;
isProcessing: boolean;
} | null>(null);
const [queueDrainNonce, setQueueDrainNonce] = useState(0);
useEffect(() => {
if (queueDrainingRef.current || submissionInFlightRef.current) return;
const admissionFailure = admissionFailureRef.current;
if (admissionFailure) {
if (pendingSubmissionCount === 0) {
admissionFailureRef.current = null;
} else if (
pendingSubmissionCount <= admissionFailure.pendingSubmissionCount &&
goalQueueRevision === admissionFailure.goalQueueRevision &&
streamingState === admissionFailure.streamingState &&
isProcessing === admissionFailure.isProcessing
) {
return;
} else {
admissionFailureRef.current = null;
}
}
if (
!shouldDrainMessageQueue({
isConfigInitialized,
streamingState,
isProcessing,
dialogsVisible,
messageQueueLength: pendingSubmissionCount,
})
) {
return;
}
let goalControlMode: Parameters<typeof popNextSubmission>[0] = 'normal';
try {
const status = config.getGoalRuntime().getSnapshot().goal?.status;
// Only an actively-running Goal holds ordinary input: while a Goal turn
// is in flight the message can't be delivered, so it queues (criterion
// #2). In paused/blocked/usage_limited nothing is running, so the queue
// drains normally — holding input there stranded it until /goal clear.
if (status === 'active') {
goalControlMode = 'priority';
}
} catch {
// Goal persistence can be disabled for this session.
}
const submission = popNextSubmission(goalControlMode);
if (submission === null) return;
queueDrainingRef.current = true;
let admissionFailed = false;
const markAdmissionFailed = () => {
admissionFailed = true;
admissionFailureRef.current = {
pendingSubmissionCount: getPendingSubmissionCount(),
goalQueueRevision,
streamingState,
isProcessing,
};
};
const request =
submission.kind === 'goal'
? submitQuery(
submission.continuationContext,
SendMessageType.Goal,
undefined,
{
goal: submission,
onAdmissionFailed: () => {
enqueueGoalTurn(submission);
markAdmissionFailed();
},
},
)
: submitQuery(
submission.modelText,
SendMessageType.UserQuery,
undefined,
{
userAdmission: { turnKey: submission.turnKey },
...(submission.submittedPrompt === undefined
? {}
: { submittedPrompt: submission.submittedPrompt }),
onAdmissionFailed: () => {
restoreMessages(
[submission.modelText],
submission.submittedPrompt,
);
markAdmissionFailed();
},
},
);
void Promise.resolve(request)
.catch((error) => {
debugLogger.warn('Queued submission failed during admission', error);
})
.finally(() => {
queueDrainingRef.current = false;
if (!admissionFailed) {
setQueueDrainNonce((nonce) => nonce + 1);
}
});
}, [
config,
dialogsVisible,
enqueueGoalTurn,
goalQueueRevision,
getPendingSubmissionCount,
isConfigInitialized,
isProcessing,
pendingSubmissionCount,
popNextSubmission,
queueDrainNonce,
restoreMessages,
streamingState,
submissionInFlightRef,
submissionSettledRevision,
submitQuery,
]);
}
export function getSpeculativeToolResult(response: unknown): {
text: string;
status: ToolCallStatus;
@ -718,6 +887,7 @@ export const AppContainer = (props: AppContainerProps) => {
// handled by the global catch.
profileCheckpoint('config_initialize_start');
await config.initialize();
await waitForGoalRuntime(config);
setStartupWarnings((currentWarnings) =>
mergeStartupWarnings(currentWarnings, config.getWarnings()),
);
@ -772,13 +942,6 @@ export const AppContainer = (props: AppContainerProps) => {
seedPromptCount(userTurnCount);
}
// 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(),
);
@ -1038,7 +1201,10 @@ export const AppContainer = (props: AppContainerProps) => {
}, []);
const preferredEditor = usePreferredEditor();
const restoredSubmissionRef = useRef<QueuedSubmission | null>(null);
const restoredSubmissionRef = useRef<Pick<
QueuedUserSubmission,
'modelText' | 'submittedPrompt'
> | null>(null);
const submittedPromptProvenanceUnavailableRef = useRef(false);
const setBufferTextRef = useRef<
ReturnType<typeof useTextBuffer>['setText'] | null
@ -1840,8 +2006,35 @@ export const AppContainer = (props: AppContainerProps) => {
}, [config, historyManager, settings.merged]);
const cancelHandlerRef = useRef<(info?: CancelSubmitInfo) => void>(() => {});
const midTurnDrainRef = useRef<(() => string[]) | null>(null);
const midTurnDrainRef = useRef<UseMessageQueueReturn['drainQueue'] | null>(
null,
);
const midTurnRestoreRef = useRef<((messages: string[]) => void) | null>(null);
const goalQueueRef = useRef<
| (Pick<
UseMessageQueueReturn,
| 'peekNextUserBatchKey'
| 'claimDirectUserAdmission'
| 'claimGoalTurn'
| 'hasQueuedUserMessages'
| 'getPendingSubmissionCount'
> & {
waitForReservationSettlement: () => Promise<void>;
submissionInFlightRef: RefObject<boolean>;
onSubmissionSettled: () => void;
})
| null
>(null);
const goalReservationSettlementRef = useRef<Promise<void>>(Promise.resolve());
const submissionInFlightRef = useRef(false);
const [submissionSettledRevision, setSubmissionSettledRevision] = useState(0);
const onSubmissionSettled = useCallback(() => {
setSubmissionSettledRevision((revision) => revision + 1);
}, []);
const waitForReservationSettlement = useCallback(
() => goalReservationSettlementRef.current,
[],
);
const {
streamingState,
@ -1850,6 +2043,7 @@ export const AppContainer = (props: AppContainerProps) => {
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
preemptGoalTurn,
retryLastPrompt,
handleApprovalModeChange,
activePtyId,
@ -1882,6 +2076,7 @@ export const AppContainer = (props: AppContainerProps) => {
availableTerminalHeightRef,
terminalWidthRef,
midTurnRestoreRef,
goalQueueRef,
);
cancelOngoingRequestRef.current = cancelOngoingRequest;
@ -1991,48 +2186,90 @@ export const AppContainer = (props: AppContainerProps) => {
const {
messageQueue,
pendingSubmissionCount,
addMessage,
enqueueGoalTurn,
peekNextUserBatchKey,
hasQueuedUserMessages,
getPendingSubmissionCount,
claimGoalTurn,
claimDirectUserAdmission,
removeGoalTurns,
popNextSubmission,
popAllMessages,
restoreMessages,
drainQueue,
popNextTurn,
} = useMessageQueue();
const submitUserQuery = useCallback(
(submission: QueuedSubmission) =>
submitQuery(
submission.modelText,
SendMessageType.UserQuery,
undefined,
submission.submittedPrompt === undefined
? undefined
: { submittedPrompt: submission.submittedPrompt },
),
[submitQuery],
midTurnDrainRef.current = drainQueue;
midTurnRestoreRef.current = restoreMessages;
goalQueueRef.current = {
peekNextUserBatchKey,
claimDirectUserAdmission,
claimGoalTurn,
hasQueuedUserMessages,
getPendingSubmissionCount,
waitForReservationSettlement,
submissionInFlightRef,
onSubmissionSettled,
};
const releaseQueuedGoalReservations = useCallback(
(turnKeys: string[]) => {
let runtime;
try {
runtime = config.getGoalRuntime();
} catch {
return;
}
const previousSettlement = goalReservationSettlementRef.current;
const settlement = previousSettlement.then(async () => {
await Promise.all(
turnKeys.map((turnKey) => runtime.releaseTurn(turnKey)),
);
});
goalReservationSettlementRef.current = settlement.catch((error) => {
debugLogger.warn(
`Failed to release queued Goal turns: ${getErrorMessage(error)}`,
);
});
},
[config],
);
const popAllQueuedMessages = useCallback((): string | null => {
const goalTurnKeys = removeGoalTurns();
if (goalTurnKeys.length > 0) {
releaseQueuedGoalReservations(goalTurnKeys);
}
const submission = popAllMessages();
if (submission === null) return null;
restoredSubmissionRef.current = submission;
submittedPromptProvenanceUnavailableRef.current = false;
return submission.modelText;
}, [popAllMessages]);
}, [popAllMessages, releaseQueuedGoalReservations, removeGoalTurns]);
// Bridge message queue to mid-turn drain via ref.
// drainQueue reads the synchronous queueRef inside the hook, so it
// stays consistent with popNextTurn even before React re-renders.
midTurnDrainRef.current = drainQueue;
midTurnRestoreRef.current = restoreMessages;
useEffect(() => {
const host: GoalTurnHost = {
startGoalTurn: async (input) => {
enqueueGoalTurn(input);
},
preemptGoalTurn: (reason) => {
removeGoalTurns();
preemptGoalTurn(reason);
},
};
return config.bindGoalTurnHost(host);
}, [config, enqueueGoalTurn, preemptGoalTurn, removeGoalTurns]);
// Connect remote input watcher to submitQuery for bidirectional sync.
// When an external process writes a command to the input-file,
// the watcher calls submitQuery as if the user typed it in the TUI.
const remoteInput = useRemoteInput();
useEffect(() => {
if (!remoteInput) return;
remoteInput.setSubmitFn((text: string) => submitQuery(text));
}, [remoteInput, submitQuery]);
remoteInput.setSubmitFn((text: string) => {
addMessage(text);
return true;
});
}, [addMessage, remoteInput]);
// Notify remote input watcher when TUI becomes idle so it can
// retry queued commands that were deferred while TUI was busy.
@ -2272,9 +2509,15 @@ export const AppContainer = (props: AppContainerProps) => {
streamingState === StreamingState.Responding &&
isBtwCommand(submittedValue)
) {
void submitUserQuery({
modelText: submittedValue,
submittedPrompt,
void Promise.resolve(
submitQuery(
submittedValue,
SendMessageType.UserQuery,
undefined,
submittedPrompt === undefined ? undefined : { submittedPrompt },
),
).catch((error) => {
debugLogger.warn('Failed to admit /btw submission', error);
});
return;
}
@ -2398,9 +2641,15 @@ export const AppContainer = (props: AppContainerProps) => {
!isProcessing &&
isSlashCommand(submittedValue)
) {
void submitUserQuery({
modelText: submittedValue,
submittedPrompt,
void Promise.resolve(
submitQuery(
submittedValue,
SendMessageType.UserQuery,
undefined,
submittedPrompt === undefined ? undefined : { submittedPrompt },
),
).catch((error) => {
debugLogger.warn('Failed to admit slash command', error);
});
return;
}
@ -2412,7 +2661,7 @@ export const AppContainer = (props: AppContainerProps) => {
agentViewState,
streamingState,
isProcessing,
submitUserQuery,
submitQuery,
handleSlashCommand,
slashCommands,
config,
@ -2477,6 +2726,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Always drain the queue back into the buffer (claude-code parity:
// popAllEditable preserves queued text on every cancel path, including
// tool-execution cancels — never silently drop the user's queued work).
const goalTurnKeys = removeGoalTurns();
if (goalTurnKeys.length > 0) {
releaseQueuedGoalReservations(goalTurnKeys);
}
const popped = popAllMessages();
if (popped) {
restoredSubmissionRef.current = popped;
@ -2489,6 +2742,18 @@ export const AppContainer = (props: AppContainerProps) => {
);
}
// A cancelled Goal continuation turn appended its synthetic prompt to
// the chat history but has no UI user item (lastTurnUserItem is null),
// so the auto-restore branch below bails out before its orphan strip
// runs. Strip the orphaned prompt here; otherwise appendCuratedContent
// merges the user's NEXT real message into the "no new real user input"
// preamble. Safe even if the turn already produced a model response:
// the strip only pops trailing user entries, and a responded prompt is
// not trailing.
if (info?.wasGoalTurn) {
geminiClient?.stripOrphanedUserEntriesFromHistory?.();
}
// Restore-on-cancel: pull the just-submitted prompt back into the input
// box when it is safe to do so. If nothing meaningful was produced,
// also rewind the stranded "user prompt + Request cancelled." pair. If
@ -2650,6 +2915,8 @@ export const AppContainer = (props: AppContainerProps) => {
[
buffer,
popAllMessages,
releaseQueuedGoalReservations,
removeGoalTurns,
historyManager,
logger,
geminiClient,
@ -3971,42 +4238,21 @@ export const AppContainer = (props: AppContainerProps) => {
config,
]);
// Drain queued messages when idle. `queueDrainNonce` re-fires the effect
// after each submission settles so multi-step queues drain end-to-end.
const queueDrainingRef = useRef(false);
const [queueDrainNonce, setQueueDrainNonce] = useState(0);
useEffect(() => {
if (queueDrainingRef.current) return;
if (
!shouldDrainMessageQueue({
isConfigInitialized,
streamingState,
isProcessing,
dialogsVisible,
messageQueueLength: messageQueue.length,
})
) {
return;
}
// Two-phase: batch plain prompts as one turn, else pop next slash command.
const submission = popNextTurn();
if (submission === null) return;
queueDrainingRef.current = true;
Promise.resolve(submitUserQuery(submission)).finally(() => {
queueDrainingRef.current = false;
setQueueDrainNonce((n) => n + 1);
});
}, [
useQueuedSubmissionDrain({
config,
isConfigInitialized,
streamingState,
isProcessing,
dialogsVisible,
messageQueue,
popNextTurn,
submitUserQuery,
queueDrainNonce,
]);
pendingSubmissionCount,
getPendingSubmissionCount,
popNextSubmission,
enqueueGoalTurn,
restoreMessages,
submitQuery,
submissionInFlightRef,
submissionSettledRevision,
});
const nightly = props.version.includes('nightly');

View file

@ -4,33 +4,133 @@
* 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,
getActiveGoal,
notifyGoalTerminal,
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type {
Config,
GoalRuntime,
GoalSnapshotV2,
GoalStateResponse,
} from '@qwen-code/qwen-code-core';
import { goalCommand, parseGoalCommand } from './goalCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
function makeConfig(overrides: Partial<Config> = {}): Config {
const mockRegisterGoalHook = vi.hoisted(() => vi.fn());
const mockGetActiveGoal = vi.hoisted(() => vi.fn());
const mockGetLastGoalTerminal = vi.hoisted(() => vi.fn());
const mockUnregisterGoalHook = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
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;
...actual,
registerGoalHook: mockRegisterGoalHook,
getActiveGoal: mockGetActiveGoal,
getLastGoalTerminal: mockGetLastGoalTerminal,
unregisterGoalHook: mockUnregisterGoalHook,
};
});
function goalSnapshot(
overrides: Partial<NonNullable<GoalSnapshotV2['goal']>> = {},
): GoalSnapshotV2 {
return {
v: 2,
activity: 'idle',
goal: {
goalId: 'goal-1',
revision: 4,
objective: 'Ship Goal v3',
status: 'active',
evidenceCursor: { recordId: 'cursor-1' },
turnCount: 3,
activeTimeMs: 1_000,
createdAt: 10,
updatedAt: 20,
...overrides,
},
};
}
function noGoalSnapshot(): GoalSnapshotV2 {
return { v: 2, goal: null, activity: 'idle' };
}
function makeRuntime(
snapshot: GoalSnapshotV2,
response: GoalStateResponse = { snapshot },
) {
const getSnapshot = vi.fn(() => structuredClone(snapshot));
const dispatch = vi.fn().mockResolvedValue(structuredClone(response));
const runtime = { getSnapshot, dispatch } as unknown as GoalRuntime;
return { dispatch, getSnapshot, runtime };
}
function makeContext(runtime: GoalRuntime, { trusted = true } = {}) {
const getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime);
const isTrustedFolder = vi.fn(() => trusted);
const config = { getGoalRuntimeReady, isTrustedFolder } as unknown as Config;
const context = createMockCommandContext({ services: { config } });
return { context, getGoalRuntimeReady, isTrustedFolder };
}
describe('parseGoalCommand', () => {
it.each([
['', { kind: 'status' }],
[' ', { kind: 'status' }],
['ship Goal v3', { kind: 'set', objective: 'ship Goal v3' }],
['set ship Goal v3', { kind: 'set', objective: 'ship Goal v3' }],
['set pause', { kind: 'set', objective: 'pause' }],
['edit ship it better', { kind: 'edit', objective: 'ship it better' }],
['pause', { kind: 'pause' }],
['resume', { kind: 'resume' }],
['clear', { kind: 'clear' }],
['stop', { kind: 'clear' }],
['off', { kind: 'clear' }],
['reset', { kind: 'clear' }],
['none', { kind: 'clear' }],
['cancel', { kind: 'clear' }],
['cancel after tests', { kind: 'set', objective: 'cancel after tests' }],
['pause after tests', { kind: 'set', objective: 'pause after tests' }],
['/goal', { kind: 'status' }],
['/goal ship it', { kind: 'set', objective: 'ship it' }],
['/goal set ship it', { kind: 'set', objective: 'ship it' }],
['/goal set pause', { kind: 'set', objective: 'pause' }],
['/goal edit revised', { kind: 'edit', objective: 'revised' }],
['/goal pause', { kind: 'pause' }],
['/goal resume', { kind: 'resume' }],
['/goal clear', { kind: 'clear' }],
['/goal stop', { kind: 'clear' }],
] as const)('parses %j', (args, expected) => {
expect(parseGoalCommand(args)).toEqual(expected);
});
it.each(['set', 'set ', 'edit', ' edit\n\t'])(
'rejects an empty objective for %j',
(args) => {
expect(parseGoalCommand(args)).toMatchObject({
kind: 'error',
message: expect.stringMatching(/requires an objective/i),
});
},
);
it('does not impose an objective length cap', () => {
const objective = `${'x'.repeat(4_001)}-end`;
expect(parseGoalCommand(`set ${objective}`)).toEqual({
kind: 'set',
objective,
});
});
});
describe('goalCommand', () => {
beforeEach(() => __resetActiveGoalStoreForTests());
afterEach(() => __resetActiveGoalStoreForTests());
beforeEach(() => {
mockRegisterGoalHook.mockReset();
mockGetActiveGoal.mockReset();
mockGetLastGoalTerminal.mockReset();
mockUnregisterGoalHook.mockReset();
});
it('is available in interactive, non-interactive, and ACP modes', () => {
expect(goalCommand.supportedModes).toEqual([
@ -40,349 +140,347 @@ describe('goalCommand', () => {
]);
});
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.each(['interactive', 'non_interactive', 'acp'] as const)(
'accepts conditions longer than 4,000 characters in %s mode',
async (executionMode) => {
const ctx = createMockCommandContext({
executionMode,
services: { config: makeConfig() as unknown as Config },
it.each(['pause', 'resume', 'edit revised'] as const)(
'rejects /goal %s in non-interactive mode',
async (args) => {
const context = createMockCommandContext({
executionMode: 'non_interactive',
});
const condition = `${'x'.repeat(4_001)}-goal-condition-end`;
const result = await goalCommand.action!(ctx, condition);
expect(result).toMatchObject({ type: 'submit_prompt' });
const submit = result as { content: Array<{ text: string }> };
expect(submit.content[0].text).toContain(condition);
expect(getActiveGoal('sess-1')?.condition).toBe(condition);
expect(
(ctx.ui.addItem as ReturnType<typeof vi.fn>).mock.calls[0][0],
).toMatchObject({
type: 'goal_status',
kind: 'set',
condition,
const result = await goalCommand.action!(context, args);
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringMatching(/only available in interactive mode/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 },
it('strips the set keyword before forwarding to the legacy path in non-interactive mode', async () => {
mockRegisterGoalHook.mockReturnValue({
condition: 'Ship it',
setAt: Date.now(),
});
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',
const config = {
getSessionId: () => 'test-session',
isTrustedFolder: () => true,
getDisableAllHooks: () => false,
getHookSystem: () => ({}),
} as unknown as Config;
const context = createMockCommandContext({
executionMode: 'non_interactive',
services: { config },
});
});
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',
});
});
await goalCommand.action!(context, 'set Ship it');
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/,
expect(mockRegisterGoalHook).toHaveBeenCalledWith(
expect.objectContaining({ condition: 'Ship it' }),
);
});
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;
it.each(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])(
'sets a literal %j objective instead of clearing in non-interactive mode',
async (keyword) => {
mockRegisterGoalHook.mockReturnValue({
condition: keyword,
setAt: Date.now(),
});
const config = {
getSessionId: () => 'test-session',
isTrustedFolder: () => true,
getDisableAllHooks: () => false,
getHookSystem: () => ({}),
} as unknown as Config;
const context = createMockCommandContext({
executionMode: 'non_interactive',
services: { config },
});
notifyGoalTerminal('sess-1', {
kind: 'achieved',
condition: 'do x',
iterations: 3,
durationMs: 12_345,
lastReason: 'quoted evidence from transcript',
});
const result = await goalCommand.action!(context, `set ${keyword}`);
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',
});
});
expect(mockRegisterGoalHook).toHaveBeenCalledWith(
expect.objectContaining({ condition: keyword }),
);
expect(mockUnregisterGoalHook).not.toHaveBeenCalled();
expect(result).toMatchObject({ type: 'submit_prompt' });
},
);
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('keeps the latest terminal summary when `/goal clear` has no active goal', async () => {
// A no-op clear should not write a dismissal sentinel or wipe the cache.
// Subsequent empty `/goal` still surfaces the previous achievement
// summary.
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',
});
});
it('after impossible failure, empty /goal shows the failed 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: 'failed',
condition: 'do x',
it('still clears on a bare clear keyword in non-interactive mode', async () => {
mockUnregisterGoalHook.mockReturnValue({
condition: 'Old goal',
iterations: 2,
durationMs: 12_000,
lastReason: 'the required branch does not exist',
setAt: Date.now() - 1000,
});
const config = {
getSessionId: () => 'test-session',
isTrustedFolder: () => true,
getDisableAllHooks: () => false,
getHookSystem: () => ({}),
} as unknown as Config;
const context = createMockCommandContext({
executionMode: 'non_interactive',
services: { config },
});
const result = await goalCommand.action!(ctx, '');
const content = (result as { content: string }).content;
expect(content).toMatch(/Goal could not be achieved/);
expect(content).toMatch(/2 turns/);
expect(content).toMatch(/12s/);
expect(content).toMatch(/Goal: do x/);
expect(content).toMatch(/Last check: the required branch does not exist/);
const result = await goalCommand.action!(context, 'clear');
expect(mockUnregisterGoalHook).toHaveBeenCalled();
expect(mockRegisterGoalHook).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'message',
content: expect.stringMatching(/goal cleared/i),
});
});
it('rejects invalid set and edit commands before runtime admission', async () => {
const { runtime } = makeRuntime(noGoalSnapshot());
const { context, getGoalRuntimeReady } = makeContext(runtime);
for (const args of ['set', 'edit ']) {
const result = await goalCommand.action!(context, args);
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringMatching(/requires an objective/i),
});
}
expect(getGoalRuntimeReady).not.toHaveBeenCalled();
});
it('awaits runtime readiness and reads authoritative status without dispatch', async () => {
const snapshot = goalSnapshot({ status: 'paused' });
const { dispatch, getSnapshot, runtime } = makeRuntime(snapshot);
const { context, getGoalRuntimeReady } = makeContext(runtime);
const result = await goalCommand.action!(context, '');
expect(result).toEqual({
type: 'goal_control',
operation: { kind: 'status' },
response: { snapshot },
});
expect(getGoalRuntimeReady).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getGoalRuntimeReady.mock.invocationCallOrder[0]).toBeLessThan(
getSnapshot.mock.invocationCallOrder[0]!,
);
expect(dispatch).not.toHaveBeenCalled();
});
it('maps a set operation to create when no Goal exists', async () => {
const before = noGoalSnapshot();
const after = goalSnapshot({ objective: 'Ship it', revision: 1 });
const { dispatch, runtime } = makeRuntime(before, { snapshot: after });
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, 'Ship it');
expect(dispatch).toHaveBeenCalledWith({
action: 'create',
objective: 'Ship it',
});
expect(result).toEqual({
type: 'goal_control',
operation: { kind: 'set', objective: 'Ship it' },
response: { snapshot: after },
cause: 'create',
});
expect(result).not.toHaveProperty('content');
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it('maps set to a versioned replace when a Goal exists', async () => {
const before = goalSnapshot();
const after = goalSnapshot({
goalId: 'goal-2',
revision: 1,
objective: 'Replace it',
});
const { dispatch, runtime } = makeRuntime(before, { snapshot: after });
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, 'set Replace it');
expect(dispatch).toHaveBeenCalledWith({
action: 'replace',
objective: 'Replace it',
expectedGoalId: 'goal-1',
expectedRevision: 4,
});
expect(result).toEqual({
type: 'goal_control',
operation: { kind: 'set', objective: 'Replace it' },
response: { snapshot: after },
cause: 'replace',
});
});
it('dispatches versioned edit, pause, resume, and clear requests', async () => {
const cases = [
[
'edit Better objective',
{ kind: 'edit', objective: 'Better objective' },
{
action: 'edit',
objective: 'Better objective',
expectedGoalId: 'goal-1',
expectedRevision: 4,
},
],
[
'pause',
{ kind: 'pause' },
{
action: 'pause',
expectedGoalId: 'goal-1',
expectedRevision: 4,
},
],
[
'resume',
{ kind: 'resume' },
{
action: 'resume',
expectedGoalId: 'goal-1',
expectedRevision: 4,
},
],
[
'clear',
{ kind: 'clear' },
{
action: 'clear',
expectedGoalId: 'goal-1',
expectedRevision: 4,
},
],
] as const;
for (const [args, operation, request] of cases) {
const snapshot = goalSnapshot();
const { dispatch, runtime } = makeRuntime(snapshot);
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, args);
expect(dispatch).toHaveBeenCalledWith(request);
expect(result).toEqual({
type: 'goal_control',
operation,
response: { snapshot },
cause: request.action,
});
expect(result).not.toHaveProperty('content');
}
});
it.each(['edit new objective', 'pause', 'resume'])(
'rejects %j when no Goal exists',
async (args) => {
const { dispatch, runtime } = makeRuntime(noGoalSnapshot());
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, args);
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringMatching(/no goal/i),
});
expect(dispatch).not.toHaveBeenCalled();
},
);
it('treats clear with no Goal as an authoritative no-op status response', async () => {
const snapshot = noGoalSnapshot();
const { dispatch, runtime } = makeRuntime(snapshot);
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, 'clear');
expect(result).toEqual({
type: 'goal_control',
operation: { kind: 'clear' },
response: { snapshot },
});
expect(dispatch).not.toHaveBeenCalled();
});
it('creates a Goal without requiring hook services', async () => {
const before = noGoalSnapshot();
const after = goalSnapshot({ objective: 'Bare Goal', revision: 1 });
const { dispatch, runtime } = makeRuntime(before, { snapshot: after });
const { context } = makeContext(runtime);
const result = await goalCommand.action!(context, 'set Bare Goal');
expect(dispatch).toHaveBeenCalledWith({
action: 'create',
objective: 'Bare Goal',
});
expect(result).toMatchObject({ type: 'goal_control' });
});
it.each(['set Ship it', 'edit Better', 'resume'])(
'rejects %j in an untrusted workspace before runtime admission',
async (args) => {
const { dispatch, runtime } = makeRuntime(goalSnapshot());
const { context, getGoalRuntimeReady } = makeContext(runtime, {
trusted: false,
});
const result = await goalCommand.action!(context, args);
expect(result).toMatchObject({
type: 'message',
messageType: 'error',
content: expect.stringMatching(/trusted workspaces/i),
});
expect(getGoalRuntimeReady).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
},
);
it.each(['', 'clear', 'pause'])(
'still allows %j in an untrusted workspace',
async (args) => {
const { runtime } = makeRuntime(goalSnapshot());
const { context } = makeContext(runtime, { trusted: false });
const result = await goalCommand.action!(context, args);
expect(result).toMatchObject({ type: 'goal_control' });
},
);
it('maps runtime errors to the existing error action without state', async () => {
const failure = new Error('Goal persistence is unavailable');
const getGoalRuntimeReady = vi.fn().mockRejectedValue(failure);
const config = {
getGoalRuntimeReady,
isTrustedFolder: () => true,
} as unknown as Config;
const context = createMockCommandContext({ services: { config } });
const result = await goalCommand.action!(context, 'status objective');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Goal persistence is unavailable',
});
expect(result).not.toHaveProperty('response');
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it('rejects when config is missing', async () => {
const context = createMockCommandContext();
const result = await goalCommand.action!(context, 'Ship it');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Configuration is not available.',
});
});
});

View file

@ -4,30 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
CommandKind,
type CommandContext,
type MessageActionReturn,
type SlashCommand,
type SlashCommandActionReturn,
type SubmitPromptActionReturn,
} from './types.js';
import type {
GoalControlRequest,
GoalStateResponse,
GoalStateCause,
GoalTerminalEvent,
} from '@qwen-code/qwen-code-core';
import {
getActiveGoal,
getLastGoalTerminal,
registerGoalHook,
unregisterGoalHook,
type GoalTerminalEvent,
} from '@qwen-code/qwen-code-core';
import {
CommandKind,
type CommandContext,
type GoalCommandOperation,
type GoalControlActionReturn,
type MessageActionReturn,
type SlashCommand,
type SlashCommandActionReturn,
type SubmitPromptActionReturn,
} from './types.js';
import { t } from '../../i18n/index.js';
import { MessageType, type HistoryItemGoalStatus } from '../types.js';
import { installGoalTerminalObserver } from '../utils/restoreGoal.js';
import { formatDuration } from '../utils/formatters.js';
import { t } from '../../i18n/index.js';
// Mirrored by GOAL_CLEAR_KEYWORDS in
// packages/web-shell/client/utils/goalCondition.ts, whose test reads this
// literal and fails on drift. The Web Shell client bundles for the browser and
// cannot import from core, so this is duplicated rather than shared.
// literal and fails on drift.
const CLEAR_KEYWORDS = new Set([
'clear',
'stop',
@ -37,190 +43,315 @@ const CLEAR_KEYWORDS = new Set([
'cancel',
]);
// Keep the surrounding `"…"` quote structure intact: collapse newlines so the
// condition stays on one line, and downgrade embedded double-quotes to single
// quotes so they don't visually close the wrapping quote.
function sanitizeConditionForPrompt(condition: string): string {
return condition.replace(/[\r\n]+/g, ' ').replace(/"/g, "'");
function formatLegacyTurns(count: number): string {
return `${count} ${count === 1 ? 'turn' : 'turns'}`;
}
const goalInstructionPrompt = (condition: string): string =>
`A session-scoped Stop hook is now active with condition: "${sanitizeConditionForPrompt(condition)}". ` +
`Briefly acknowledge the goal, then immediately start (or continue) working ` +
`toward it — treat the condition itself as your directive and do not pause to ` +
`ask the user what to do. The hook will block stopping until the condition ` +
`holds. It auto-clears once the condition is met — do not tell the user to ` +
`run \`/goal clear\` after success; that's only for clearing a goal early.`;
const formatTurns = (n: number) => `${n} ${n === 1 ? 'turn' : 'turns'}`;
function assertNeverGoalKind(kind: never): never {
throw new Error(`Unexpected terminal goal kind: ${kind}`);
function assertNeverTerminalKind(kind: never): never {
throw new Error(`Unexpected GoalTerminalKind: ${kind}`);
}
function terminalGoalTitle(kind: GoalTerminalEvent['kind']): string {
switch (kind) {
function formatLegacyTerminalSummary(event: GoalTerminalEvent): string {
let title: string;
switch (event.kind) {
case 'achieved':
return 'Goal achieved';
title = 'Goal achieved';
break;
case 'failed':
return 'Goal could not be achieved';
title = 'Goal could not be achieved';
break;
case 'aborted':
return 'Goal aborted';
title = 'Goal aborted';
break;
default:
return assertNeverGoalKind(kind);
title = assertNeverTerminalKind(event.kind);
}
}
function formatTerminalSummary(event: GoalTerminalEvent): string {
// Mirrors GoalStatusMessage: empty-`/goal` after completion surfaces the
// most recent terminal event, including the judge's `lastReason` (when
// present) so this view matches the inline terminal
// history card.
const title = terminalGoalTitle(event.kind);
const stats: string[] = [];
if (event.iterations > 0) stats.push(formatTurns(event.iterations));
if (typeof event.durationMs === 'number')
if (event.iterations > 0) stats.push(formatLegacyTurns(event.iterations));
if (typeof event.durationMs === 'number') {
stats.push(formatDuration(event.durationMs, { hideTrailingZeros: true }));
}
const subtitle = stats.length > 0 ? ` · ${stats.join(' · ')}` : '';
const reason = event.lastReason?.trim();
const reasonLine = reason ? `\nLast check: ${reason}` : '';
return `${title}${subtitle}\nGoal: ${event.condition}${reasonLine}`;
return `${title}${subtitle}\nGoal: ${event.condition}${reason ? `\nLast check: ${reason}` : ''}`;
}
function infoMessage(content: string): MessageActionReturn {
return { type: 'message', messageType: 'info', content };
async function runLegacyGoalCommand(
context: CommandContext,
args: string,
explicitSet = false,
): Promise<SlashCommandActionReturn | void> {
const { config } = context.services;
if (!config) return errorMessage('Configuration is not available.');
const sessionId = config.getSessionId();
const objective = args.trim();
if (!objective) {
const active = getActiveGoal(sessionId);
if (active) {
const turns =
active.iterations === 0
? 'not yet evaluated'
: formatLegacyTurns(active.iterations);
return {
type: 'message',
messageType: 'info',
content: `Goal active: ${active.condition} (${turns})${
active.lastReason ? `\nLast check: ${active.lastReason}` : ''
}`,
};
}
const terminal = getLastGoalTerminal(sessionId);
return {
type: 'message',
messageType: 'info',
content: terminal
? formatLegacyTerminalSummary(terminal)
: 'No goal set. Usage: `/goal <condition>` (or `/goal clear`).',
};
}
if (!explicitSet && CLEAR_KEYWORDS.has(objective.toLowerCase())) {
const cleared = unregisterGoalHook(config, sessionId);
if (!cleared) {
return {
type: 'message',
messageType: 'info',
content: 'No goal set.',
};
}
const item: Omit<HistoryItemGoalStatus, 'id'> = {
type: MessageType.GOAL_STATUS,
kind: 'cleared',
condition: cleared.condition,
iterations: cleared.iterations,
durationMs: Date.now() - cleared.setAt,
};
context.ui.addItem(item, Date.now());
return {
type: 'message',
messageType: 'info',
content: `Goal cleared: ${cleared.condition}`,
};
}
if (!config.isTrustedFolder()) {
return errorMessage(
'/goal is only available in trusted workspaces. Trust this folder via `/trust` and try again.',
);
}
if (config.getDisableAllHooks()) {
return errorMessage(
'/goal is disabled because hooks are turned off in this session (`disableAllHooks` or bare mode).',
);
}
if (!config.getHookSystem()) {
return errorMessage(
'Hook system is not initialized; cannot set a /goal in this session.',
);
}
let registered;
try {
registered = registerGoalHook({
config,
sessionId,
condition: objective,
tokensAtStart: 0,
});
} catch (error) {
return errorMessage(
`Failed to set goal: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
context.ui.addItem(
{
type: MessageType.GOAL_STATUS,
kind: 'set',
condition: registered.condition,
setAt: registered.setAt,
},
Date.now(),
);
installGoalTerminalObserver({
sessionId,
config,
addItem: context.ui.addItem,
});
const result: SubmitPromptActionReturn = {
type: 'submit_prompt',
content: [
{
text:
`A session-scoped Stop hook is now active with condition: "${objective
.replace(/[\r\n]+/g, ' ')
.replace(/"/g, "'")}". ` +
'Briefly acknowledge the goal, then immediately start (or continue) working toward it — treat the condition itself as your directive and do not pause to ask the user what to do. The hook will block stopping until the condition holds. It auto-clears once the condition is met — do not tell the user to run `/goal clear` after success; that is only for clearing a goal early.',
},
],
};
return result;
}
export type ParsedGoalCommand =
| GoalCommandOperation
| { kind: 'error'; message: string };
export function parseGoalCommand(args: string): ParsedGoalCommand {
let input = args.trim();
if (/^\/goal(?:\s|$)/i.test(input)) {
input = input.slice('/goal'.length).trim();
}
if (!input) return { kind: 'status' };
const [head = '', ...tail] = input.split(/\s+/);
const keyword = head.toLowerCase();
const objective = tail.join(' ').trim();
if (keyword === 'set') {
return objective
? { kind: 'set', objective }
: { kind: 'error', message: '`/goal set` requires an objective.' };
}
if (keyword === 'edit') {
return objective
? { kind: 'edit', objective }
: { kind: 'error', message: '`/goal edit` requires an objective.' };
}
if (tail.length === 0) {
if (keyword === 'pause') return { kind: 'pause' };
if (keyword === 'resume') return { kind: 'resume' };
if (CLEAR_KEYWORDS.has(keyword)) return { kind: 'clear' };
}
return { kind: 'set', objective: input };
}
function errorMessage(content: string): MessageActionReturn {
return { type: 'message', messageType: 'error', content };
}
function goalControl(
operation: GoalCommandOperation,
response: GoalStateResponse,
cause?: GoalStateCause,
): GoalControlActionReturn {
return {
type: 'goal_control',
operation,
response,
...(cause ? { cause } : {}),
};
}
export const goalCommand: SlashCommand = {
name: 'goal',
get description() {
return t('Set a goal — keep working until the condition is met');
return t('Set or control a session goal');
},
argumentHint: '[<condition> | clear]',
argumentHint:
'[<objective> | set <objective> | edit <objective> | pause | resume | clear]',
kind: CommandKind.BUILT_IN,
supportedModes: ['interactive', 'non_interactive', 'acp'] as const,
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn | void> => {
const { config } = context.services;
if (!config) {
return errorMessage('Configuration is not available.');
}
const sessionId = config.getSessionId();
const q = args.trim();
// ── Branch 1: empty arg → show current status ─────────────────────────
if (q === '') {
const active = getActiveGoal(sessionId);
if (active) {
const turns =
active.iterations === 0
? 'not yet evaluated'
: formatTurns(active.iterations);
const lastReason = active.lastReason
? `\nLast check: ${active.lastReason}`
: '';
return infoMessage(
`Goal active: ${active.condition} (${turns})${lastReason}`,
): Promise<SlashCommandActionReturn> => {
if (context.executionMode !== 'interactive') {
const operation = parseGoalCommand(args);
if (operation.kind === 'error') return errorMessage(operation.message);
if (
operation.kind !== 'status' &&
operation.kind !== 'clear' &&
operation.kind !== 'set'
) {
return errorMessage(
`'/goal ${operation.kind}' is only available in interactive mode.`,
);
}
// No active goal — surface a summary of the most recent automatic
// terminal goal for this session. User-initiated `/goal clear` does not
// populate it.
const last = getLastGoalTerminal(sessionId);
if (last) {
return infoMessage(formatTerminalSummary(last));
}
return infoMessage(
'No goal set. Usage: `/goal <condition>` (or `/goal clear`).',
const explicitSet = operation.kind === 'set';
const legacyArgs = explicitSet ? operation.objective : args;
return (
(await runLegacyGoalCommand(context, legacyArgs, explicitSet)) ?? {
type: 'message',
messageType: 'info',
content: 'Command executed successfully.',
}
);
}
const { config } = context.services;
if (!config) return errorMessage('Configuration is not available.');
// ── Branch 2: clear keyword ──────────────────────────────────────────
//
// When an active goal exists, drop the Stop hook and emit a `cleared`
// history sentinel. When no active goal exists, this is a no-op that just
// returns "No goal set." The cached terminal summary is left intact so a
// later empty `/goal` can still show the latest automatic terminal state.
if (CLEAR_KEYWORDS.has(q.toLowerCase())) {
const cleared = unregisterGoalHook(config, sessionId);
if (!cleared) {
return infoMessage('No goal set.');
}
const clearedItem: Omit<HistoryItemGoalStatus, 'id'> = {
type: MessageType.GOAL_STATUS,
kind: 'cleared',
condition: cleared.condition,
iterations: cleared.iterations,
durationMs: Date.now() - cleared.setAt,
};
context.ui.addItem(clearedItem, Date.now());
if (context.executionMode !== 'interactive') {
return infoMessage(`Goal cleared: ${cleared.condition}`);
}
return;
}
const operation = parseGoalCommand(args);
if (operation.kind === 'error') return errorMessage(operation.message);
// ── Branch 3: gates ──────────────────────────────────────────────────
if (!config.isTrustedFolder()) {
// Starting or re-driving an autonomous Goal ingests workspace context
// (QWEN.md, files) without per-tool confirmation, so it requires a trusted
// workspace — the same boundary the legacy hook path enforces. `status`,
// `clear`, and `pause` only read or reduce work, so they stay available.
const requiresTrustedFolder =
operation.kind === 'set' ||
operation.kind === 'edit' ||
operation.kind === 'resume';
if (requiresTrustedFolder && !config.isTrustedFolder()) {
return errorMessage(
'/goal is only available in trusted workspaces. Trust this folder via `/trust` and try again.',
);
}
if (config.getDisableAllHooks()) {
return errorMessage(
'/goal is disabled because hooks are turned off in this session (`disableAllHooks` or bare mode).',
);
}
if (!config.getHookSystem()) {
return errorMessage(
'Hook system is not initialized; cannot set a /goal in this session.',
);
}
// ── Branch 4: register hook + emit set card + kick off first turn ────
let registered;
try {
registered = registerGoalHook({
config,
sessionId,
condition: q,
tokensAtStart: 0,
});
} catch (err) {
const runtime = await config.getGoalRuntimeReady();
const snapshot = runtime.getSnapshot();
if (operation.kind === 'status') {
return goalControl(operation, { snapshot });
}
const current = snapshot.goal;
if (operation.kind === 'set') {
const request: GoalControlRequest = current
? {
action: 'replace',
objective: operation.objective,
expectedGoalId: current.goalId,
expectedRevision: current.revision,
}
: { action: 'create', objective: operation.objective };
return goalControl(
operation,
await runtime.dispatch(request),
request.action,
);
}
if (!current) {
if (operation.kind === 'clear') {
return goalControl(operation, { snapshot });
}
return errorMessage(`Cannot ${operation.kind}: no Goal is active.`);
}
const version = {
expectedGoalId: current.goalId,
expectedRevision: current.revision,
};
const request: GoalControlRequest =
operation.kind === 'edit'
? {
action: 'edit',
objective: operation.objective,
...version,
}
: { action: operation.kind, ...version };
return goalControl(
operation,
await runtime.dispatch(request),
request.action,
);
} catch (error) {
return errorMessage(
`Failed to set goal: ${err instanceof Error ? err.message : String(err)}`,
error instanceof Error ? error.message : String(error),
);
}
const setItem: Omit<HistoryItemGoalStatus, 'id'> = {
type: MessageType.GOAL_STATUS,
kind: 'set',
condition: registered.condition,
setAt: registered.setAt,
};
context.ui.addItem(setItem, Date.now());
// Bridge core-side hook outcomes back into CLI history. The addItem ref
// is stable across turns (useCallback in useHistoryManager), so capturing
// it here is safe even though the observer fires from a later turn's
// Stop hook callback. The core side clears the observer on terminal /
// unregister so we don't accumulate stale closures across goals.
installGoalTerminalObserver({
sessionId,
config,
addItem: context.ui.addItem,
});
const result: SubmitPromptActionReturn = {
type: 'submit_prompt',
content: [{ text: goalInstructionPrompt(q) }],
};
return result;
},
};

View file

@ -8,6 +8,8 @@ import type { MutableRefObject, ReactNode } from 'react';
import type { Content, PartListUnion } from '@google/genai';
import type {
Config,
GoalStateResponse,
GoalStateCause,
Logger,
SessionListItem,
} from '@qwen-code/qwen-code-core';
@ -139,6 +141,21 @@ export interface MessageActionReturn {
content: string;
}
export type GoalCommandOperation =
| { kind: 'status' }
| { kind: 'set'; objective: string }
| { kind: 'edit'; objective: string }
| { kind: 'pause' }
| { kind: 'resume' }
| { kind: 'clear' };
export interface GoalControlActionReturn {
type: 'goal_control';
operation: GoalCommandOperation;
response: GoalStateResponse;
cause?: GoalStateCause;
}
/**
* The return type for a command action that streams multiple messages.
* Used for long-running operations that need to send progress updates.
@ -269,6 +286,7 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn
| GoalControlActionReturn
| ConfirmShellCommandsActionReturn
| ConfirmActionReturn;

View file

@ -22,7 +22,11 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimModeState } from '../contexts/VimModeContext.js';
import { GeminiSpinner } from './GeminiRespondingSpinner.js';
import { GoalPill, useFooterGoalState } from './GoalPill.js';
import {
GoalPill,
isLiveGoalSnapshot,
useFooterGoalState,
} from './GoalPill.js';
import { CronPill, useFooterCronTaskCount } from './CronPill.js';
import { t } from '../../i18n/index.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
@ -183,9 +187,12 @@ export const Footer: React.FC = () => {
// Goal pill: only present in `rightItems` when a goal is active so the
// divider chain stays tight; the pill itself does the live elapsed-time
// refresh internally.
const goalActive = useFooterGoalState() !== undefined;
if (goalActive) {
rightItems.push({ key: 'goal', node: <GoalPill /> });
const goalState = useFooterGoalState();
if (isLiveGoalSnapshot(goalState)) {
rightItems.push({
key: 'goal',
node: <GoalPill snapshot={goalState} />,
});
}
const cronTaskCount = useFooterCronTaskCount();
if (cronTaskCount > 0) {

View file

@ -4,62 +4,183 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
__resetActiveGoalStoreForTests,
registerGoalHook,
unregisterGoalHook,
type Config,
import { act } from '@testing-library/react';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
Config,
GoalRuntime,
GoalSnapshotV2,
GoalStateCause,
} from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../test-utils/render.js';
import { GoalPill } from './GoalPill.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import {
GoalPill,
useFooterGoalState,
type GoalPillProps,
} from './GoalPill.js';
function makeConfig(): Config {
const NOW = 10_000;
function snapshot(
status: NonNullable<GoalSnapshotV2['goal']>['status'],
activity: GoalSnapshotV2['activity'] = 'idle',
overrides: Partial<NonNullable<GoalSnapshotV2['goal']>> = {},
): GoalSnapshotV2 {
return {
getSessionId: () => 'sess-pill',
isTrustedFolder: () => true,
getDisableAllHooks: () => false,
getHookSystem: () => ({
addFunctionHook: vi.fn().mockReturnValue('hook-pill'),
removeFunctionHook: vi.fn().mockReturnValue(true),
}),
} as unknown as Config;
v: 2,
activity,
goal: {
goalId: 'goal-1',
revision: 1,
objective: 'finish the refactor',
status,
evidenceCursor: { recordId: null },
turnCount: 3,
activeTimeMs: 2_000,
createdAt: 1_000,
updatedAt: 7_000,
...overrides,
},
};
}
describe('GoalPill', () => {
beforeEach(() => __resetActiveGoalStoreForTests());
afterEach(() => __resetActiveGoalStoreForTests());
const noGoalSnapshot: GoalSnapshotV2 = {
v: 2,
activity: 'idle',
goal: null,
};
it('renders nothing when no goal is active', () => {
const { lastFrame, unmount } = renderWithProviders(<GoalPill />, {
config: makeConfig(),
});
expect(lastFrame()).toBe('');
function renderPill(props: GoalPillProps) {
return render(<GoalPill {...props} />);
}
function createRuntime(initial: GoalSnapshotV2) {
let current = initial;
const listeners = new Set<
(value: GoalSnapshotV2, cause?: GoalStateCause) => void
>();
const unsubscribe = vi.fn();
const runtime = {
getSnapshot: () => structuredClone(current),
subscribe: (
listener: (value: GoalSnapshotV2, cause?: GoalStateCause) => void,
) => {
listeners.add(listener);
return () => {
unsubscribe();
listeners.delete(listener);
};
},
} as GoalRuntime;
return {
runtime,
unsubscribe,
emit(next: GoalSnapshotV2) {
current = next;
for (const listener of listeners) listener(structuredClone(next));
},
};
}
const GoalProbe = () => {
const goalState = useFooterGoalState();
return goalState ? <GoalPill snapshot={goalState} /> : <Text />;
};
describe('GoalPill', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([
['no goal', noGoalSnapshot, ''],
['active and idle', snapshot('active', 'idle'), '◎\uFE0E /goal active'],
[
'active and running',
snapshot('active', 'running'),
'◎\uFE0E /goal active',
],
[
'active and verifying',
snapshot('active', 'verifying'),
'○\uFE0E /goal checking',
],
['paused', snapshot('paused'), '! /goal paused'],
['blocked', snapshot('blocked'), '✖\uFE0E /goal blocked'],
['usage limited', snapshot('usage_limited'), '! /goal usage limited'],
['complete', snapshot('complete'), ''],
])('renders accessible lifecycle text for %s', (_name, value, expected) => {
vi.setSystemTime(NOW);
const { lastFrame, unmount } = renderPill({ snapshot: value });
if (expected) {
expect(lastFrame()).toContain(expected);
expect(lastFrame()).not.toContain('finish the refactor');
expect(lastFrame()).not.toContain('turn');
} else {
expect(lastFrame()).toBe('');
}
// Active snapshots start a real elapsed-time interval; unmount clears it.
unmount();
});
it('renders a compact label once a goal is active', () => {
const config = makeConfig();
registerGoalHook({
config,
sessionId: 'sess-pill',
condition: 'do something',
tokensAtStart: 0,
it('adds the current active span to persisted active time', () => {
vi.setSystemTime(NOW);
const { lastFrame, unmount } = renderPill({
snapshot: snapshot('active', 'running'),
});
const { lastFrame, unmount } = renderWithProviders(<GoalPill />, {
config,
});
// Aligned with Claude Code 2.1.140 footer: "◎ /goal active" (no time
// suffix during the first second, terse — turns/reason live elsewhere).
expect(lastFrame()).toMatch(/\/goal active/);
expect(lastFrame()).toMatch(/◎/);
// Pill should not leak the raw condition into the footer.
expect(lastFrame()).not.toMatch(/do something/);
// Turns count should not appear here either (intentionally moved to the
// /goal status card to stop pill jitter).
expect(lastFrame()).not.toMatch(/turn/);
expect(lastFrame()).toContain('(5s)');
unmount();
unregisterGoalHook(config, 'sess-pill');
});
it('keeps paused elapsed time frozen while wall clock advances', () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
const paused = snapshot('paused');
const { lastFrame, rerender } = renderPill({ snapshot: paused });
expect(lastFrame()).toContain('(2s)');
act(() => {
vi.advanceTimersByTime(60_000);
});
rerender(<GoalPill snapshot={paused} />);
expect(lastFrame()).toContain('(2s)');
expect(lastFrame()).not.toContain('1m');
});
it('subscribes once and re-subscribes when Config changes sessions', () => {
const first = createRuntime(snapshot('active', 'running'));
const second = createRuntime(snapshot('paused'));
let sessionId = 'session-1';
let runtime = first.runtime;
const config = {
getSessionId: () => sessionId,
getGoalRuntime: () => runtime,
} as unknown as Config;
const tree = () => (
<ConfigContext.Provider value={config}>
<GoalProbe />
</ConfigContext.Provider>
);
const { lastFrame, rerender, unmount } = render(tree());
expect(lastFrame()).toContain('/goal active');
act(() => first.emit(snapshot('active', 'verifying')));
expect(lastFrame()).toContain('/goal checking');
sessionId = 'session-2';
runtime = second.runtime;
rerender(tree());
expect(first.unsubscribe).toHaveBeenCalledOnce();
expect(lastFrame()).toContain('/goal paused');
act(() => first.emit(snapshot('blocked')));
expect(lastFrame()).toContain('/goal paused');
unmount();
expect(second.unsubscribe).toHaveBeenCalledOnce();
});
});

View file

@ -7,79 +7,136 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { Text } from 'ink';
import { getActiveGoal, type ActiveGoal } from '@qwen-code/qwen-code-core';
import { elapsedActiveTime } from '@qwen-code/qwen-code-core';
import type {
Config,
GoalRuntime,
GoalSnapshotV2,
} from '@qwen-code/qwen-code-core';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { ICON } from '../constants.js';
const POLL_INTERVAL_MS = 1000;
const ELAPSED_REFRESH_MS = 1000;
/**
* Most-significant-unit elapsed string for the footer pill. Returns an empty
* string when under 1 second so the pill collapses to just "◎ /goal active"
* in its first second matches Claude Code 2.1.140's footer behavior
* (`f < 1000 ? "" : (formattedElapsed)`).
*/
function formatElapsed(ms: number): string {
if (ms < 1000) return '';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
/**
* Polls the in-memory active goal store so the footer pill reflects elapsed
* time without coupling the store to React state. Polling is cheap (one map
* lookup) and aligns the pill's freshness budget with the user's wall-clock
* patience for the loop.
*/
function useActiveGoal(sessionId: string): ActiveGoal | undefined {
const [goal, setGoal] = useState<ActiveGoal | undefined>(() =>
getActiveGoal(sessionId),
);
// Re-render once per second to refresh elapsed time while a goal is active.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => {
const next = getActiveGoal(sessionId);
setGoal(next);
// Bump tick so derived strings (elapsed) recompute even when the goal
// reference is stable.
if (next) setTick((t) => (t + 1) % 1_000_000);
}, POLL_INTERVAL_MS);
return () => clearInterval(id);
}, [sessionId]);
return goal;
function getRuntime(config: Config): GoalRuntime | null {
if (typeof config.getGoalRuntime !== 'function') return null;
try {
return config.getGoalRuntime();
} catch {
return null;
}
}
/**
* Hook exposed for parent containers (e.g. Footer) so they can omit the
* surrounding divider chip entirely when no goal is active avoids a stray
* separator next to a render-null pill.
*/
export function useFooterGoalState(): ActiveGoal | undefined {
export function useFooterGoalState(): GoalSnapshotV2 | undefined {
const config = useConfig();
return useActiveGoal(config.getSessionId());
const sessionId = config.getSessionId();
const runtime = getRuntime(config);
const [observed, setObserved] = useState<{
runtime: GoalRuntime | null;
snapshot?: GoalSnapshotV2;
}>(() => ({
runtime,
snapshot: runtime?.getSnapshot(),
}));
useEffect(() => {
if (!runtime) {
setObserved({ runtime });
return;
}
setObserved({ runtime, snapshot: runtime.getSnapshot() });
return runtime.subscribe((snapshot) => {
setObserved({ runtime, snapshot });
});
}, [runtime, sessionId]);
return observed.runtime === runtime
? observed.snapshot
: runtime?.getSnapshot();
}
/**
* Compact "Goal is running" indicator for the footer. Renders nothing when no
* goal is active. Aligned with Claude Code 2.1.140's footer pill:
*
* /goal active (during the first second)
* /goal active (12s) (afterwards, most-significant unit only)
*
* Turns count and last-check reason are intentionally NOT in the pill those
* live in `/goal` status output and the `goal_status` history items so the
* footer stays terse and stops jitter from per-iteration count flicker.
*/
export const GoalPill: React.FC = () => {
const goal = useFooterGoalState();
if (!goal) return null;
export function isLiveGoalSnapshot(
snapshot: GoalSnapshotV2 | undefined,
): boolean {
const status = snapshot?.goal?.status;
return status !== undefined && status !== 'complete';
}
const elapsed = formatElapsed(Date.now() - goal.setAt);
function presentation(snapshot: GoalSnapshotV2): {
icon: string;
label: string;
color: string;
} | null {
const goal = snapshot.goal;
if (!goal || goal.status === 'complete') return null;
if (goal.status === 'active') {
return snapshot.activity === 'verifying'
? {
icon: ICON.CIRCLE_EMPTY,
label: 'checking',
color: theme.text.secondary,
}
: { icon: ICON.BULLSEYE, label: 'active', color: theme.text.accent };
}
switch (goal.status) {
case 'paused':
return { icon: '!', label: 'paused', color: theme.status.warning };
case 'blocked':
return { icon: ICON.CROSS, label: 'blocked', color: theme.status.error };
case 'usage_limited':
return {
icon: '!',
label: 'usage limited',
color: theme.status.warning,
};
default: {
const exhaustive: never = goal.status;
void exhaustive;
return null;
}
}
}
export interface GoalPillProps {
snapshot: GoalSnapshotV2 | undefined;
}
export const GoalPill: React.FC<GoalPillProps> = ({ snapshot }) => {
const [, setTick] = useState(0);
const refreshElapsed = snapshot?.goal?.status === 'active';
useEffect(() => {
if (!refreshElapsed) return;
const interval = setInterval(() => {
setTick((tick) => (tick + 1) % 1_000_000);
}, ELAPSED_REFRESH_MS);
return () => clearInterval(interval);
}, [refreshElapsed]);
if (!snapshot) return null;
const goal = snapshot.goal;
if (!goal) return null;
const visible = presentation(snapshot);
if (!visible) return null;
const elapsed = formatElapsed(elapsedActiveTime(goal, Date.now()));
const suffix = elapsed ? ` (${elapsed})` : '';
return <Text color={theme.text.accent}> /goal active{suffix}</Text>;
return (
<Text color={visible.color}>
{visible.icon} /goal {visible.label}
{suffix}
</Text>
);
};

View file

@ -124,6 +124,39 @@ describe('<HistoryItemDisplay />', () => {
expect(output).toContain('Converted 1 image(s) to text via vm.');
});
it('renders v2 goal_state history items through the lifecycle card', () => {
const item: HistoryItem = {
id: 1,
type: MessageType.GOAL_STATE,
snapshot: {
v: 2,
activity: 'idle',
goal: {
goalId: 'goal-1',
revision: 1,
objective: 'ship the release',
status: 'blocked',
evidenceCursor: { recordId: 'record-1' },
turnCount: 2,
activeTimeMs: 4_000,
createdAt: 1_000,
updatedAt: 5_000,
lastReason: 'waiting for approval',
},
},
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay item={item} terminalWidth={80} isPending={false} />,
);
const output = lastFrame();
expect(output).toContain('Goal blocked');
expect(output).toContain('Goal: ship the release');
expect(output).toContain('2 turns');
expect(output).toContain('Reason: waiting for approval');
});
it('renders StatsDisplay for "stats" type', () => {
const item: HistoryItem = {
...baseItem,

View file

@ -213,6 +213,7 @@ function getHistoryItemMarginTop(item: HistoryItem): number {
case 'stop_hook_loop':
case 'stop_hook_system_message':
case 'goal_status':
case 'goal_state':
case 'vision_notice':
return 0;
default:
@ -510,6 +511,12 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
lastReason={itemForDisplay.lastReason}
/>
)}
{itemForDisplay.type === 'goal_state' && (
<GoalStatusMessage
snapshot={itemForDisplay.snapshot}
cause={itemForDisplay.cause}
/>
)}
</Box>
);
};

View file

@ -6,8 +6,33 @@
import { render } from 'ink-testing-library';
import { describe, expect, it } from 'vitest';
import type { GoalSnapshotV2 } from '@qwen-code/qwen-code-core';
import { GOAL_STATUS_KINDS, MessageType } from '../../types.js';
import { GoalStatusMessage } from './GoalStatusMessage.js';
function snapshot(
status: NonNullable<GoalSnapshotV2['goal']>['status'],
activity: GoalSnapshotV2['activity'] = 'idle',
lastReason?: string,
): GoalSnapshotV2 {
return {
v: 2,
activity,
goal: {
goalId: 'goal-1',
revision: 2,
objective: 'finish the refactor',
status,
evidenceCursor: { recordId: 'record-1' },
turnCount: 4,
activeTimeMs: 12_000,
createdAt: 1_000,
updatedAt: 13_000,
...(lastReason ? { lastReason } : {}),
},
};
}
describe('<GoalStatusMessage />', () => {
it('is wrapped in React.memo to avoid unnecessary scrollback rerenders', () => {
expect(
@ -50,4 +75,73 @@ describe('<GoalStatusMessage />', () => {
expect(output).toContain('Goal: merge a nonexistent branch');
expect(output).toContain('Last check: the remote branch does not exist');
});
it('keeps the legacy GoalStatusKind union closed', () => {
expect(GOAL_STATUS_KINDS).toEqual([
'set',
'achieved',
'cleared',
'failed',
'aborted',
'paused',
'checking',
]);
expect(MessageType.GOAL_STATE).toBe('goal_state');
});
it('renders legacy pause as a non-terminal paused card', () => {
const { lastFrame } = render(
<GoalStatusMessage
kind="paused"
condition="finish the refactor"
iterations={2}
durationMs={12_000}
/>,
);
const output = lastFrame();
expect(output).toContain('Goal paused');
expect(output).not.toContain('Goal aborted');
});
it.each([
['active', snapshot('active', 'running'), '◎', 'Goal running'],
['verifying', snapshot('active', 'verifying'), '○', 'Goal checking'],
[
'paused',
snapshot('paused', 'idle', 'paused by the user'),
'!',
'Goal paused',
],
[
'blocked',
snapshot('blocked', 'idle', 'approval is required'),
'✖',
'Goal blocked',
],
[
'usage limited',
snapshot('usage_limited', 'idle', 'provider quota reached'),
'!',
'Goal usage limited',
],
[
'complete',
snapshot('complete', 'idle', 'all acceptance checks passed'),
'✓',
'Goal complete',
],
])('renders v2 %s state as a lifecycle card', (_name, value, icon, title) => {
const { lastFrame } = render(<GoalStatusMessage snapshot={value} />);
const output = lastFrame();
expect(output).toContain(icon);
expect(output).toContain(title);
expect(output).toContain('Goal: finish the refactor');
expect(output).toContain('4 turns');
expect(output).toContain('12s');
if (value.goal?.lastReason) {
expect(output).toContain(`Reason: ${value.goal.lastReason}`);
}
});
});

View file

@ -6,36 +6,152 @@
import React from 'react';
import { Box, Text } from 'ink';
import type { GoalSnapshotV2, GoalStateCause } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { ICON } from '../../constants.js';
import { formatDuration } from '../../utils/formatters.js';
import { isTerminalGoalStatusKind, type GoalStatusKind } from '../../types.js';
interface GoalStatusMessageProps {
interface LegacyGoalStatusMessageProps {
kind: GoalStatusKind;
condition: string;
iterations?: number;
durationMs?: number;
lastReason?: string;
snapshot?: never;
cause?: never;
}
interface GoalStateMessageProps {
snapshot: GoalSnapshotV2;
cause?: GoalStateCause;
kind?: never;
condition?: never;
iterations?: never;
durationMs?: never;
lastReason?: never;
}
type GoalStatusMessageProps =
| LegacyGoalStatusMessageProps
| GoalStateMessageProps;
const pluralTurns = (n: number) => (n === 1 ? 'turn' : 'turns');
function assertNeverGoalStatusKind(kind: never): never {
throw new Error(`Unexpected goal status kind: ${kind}`);
}
const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
kind,
condition,
iterations,
durationMs,
lastReason,
const GoalStateCard: React.FC<GoalStateMessageProps> = ({
snapshot,
cause,
}) => {
// The "checking" kind is the per-iteration "judge said not met, continuing"
// marker that replaces the generic `stop_hook_loop` rendering for /goal.
// Show the active condition and latest judge reason on every iteration so
// the user can see why the loop is continuing.
const goal = snapshot.goal;
if (!goal) {
if (cause !== 'clear') return null;
return (
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={theme.text.secondary}>{ICON.CIRCLE_EMPTY}</Text>
</Box>
<Text color={theme.text.secondary}>Goal cleared</Text>
</Box>
);
}
const lifecycle = (() => {
switch (goal.status) {
case 'active':
if (snapshot.activity === 'verifying') {
return {
prefix: ICON.CIRCLE_EMPTY,
color: theme.text.secondary,
title: 'Goal checking',
};
}
return {
prefix: ICON.BULLSEYE,
color: theme.text.accent,
title:
snapshot.activity === 'running' ? 'Goal running' : 'Goal active',
};
case 'paused':
return {
prefix: '!',
color: theme.status.warning,
title: 'Goal paused',
};
case 'blocked':
return {
prefix: ICON.CROSS,
color: theme.status.error,
title: 'Goal blocked',
};
case 'usage_limited':
return {
prefix: '!',
color: theme.status.warning,
title: 'Goal usage limited',
};
case 'complete':
return {
prefix: ICON.CHECK,
color: theme.status.success,
title: 'Goal complete',
};
default: {
const exhaustive: never = goal.status;
void exhaustive;
throw new Error('Unexpected Goal status');
}
}
})();
const stats: string[] = [];
if (goal.turnCount > 0) {
stats.push(`${goal.turnCount} ${pluralTurns(goal.turnCount)}`);
}
if (goal.activeTimeMs > 0) {
stats.push(formatDuration(goal.activeTimeMs, { hideTrailingZeros: true }));
}
const subtitle = stats.length > 0 ? stats.join(' · ') : null;
const reason =
goal.status !== 'active' || snapshot.activity === 'verifying'
? goal.lastReason?.trim()
: undefined;
return (
<Box flexDirection="row">
<Box width={2} flexShrink={0}>
<Text color={lifecycle.color}>{lifecycle.prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<Text color={lifecycle.color}>
{lifecycle.title}
{subtitle ? (
<Text color={theme.text.secondary}> · {subtitle}</Text>
) : null}
</Text>
<Box flexDirection="row">
<Box flexShrink={0} marginRight={1}>
<Text color={theme.text.secondary}>Goal:</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap">{goal.objective}</Text>
</Box>
</Box>
{reason ? (
<Text color={theme.text.secondary} wrap="wrap">
Reason: {reason}
</Text>
) : null}
</Box>
</Box>
);
};
const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = (props) => {
if (props.snapshot) return <GoalStateCard {...props} />;
const { kind, condition, iterations, durationMs, lastReason } = props;
if (kind === 'checking') {
const reason = lastReason?.trim();
return (
@ -67,8 +183,6 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
const { prefix, prefixColor, title } = (() => {
switch (kind) {
case 'set':
// ◎ matches the footer GoalPill's icon — same visual identity for
// "goal is on / armed" between the history card and the live pill.
return {
prefix: ICON.BULLSEYE,
prefixColor: theme.text.accent,
@ -76,7 +190,7 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
};
case 'achieved':
return {
prefix: '✓',
prefix: ICON.CHECK,
prefixColor: theme.status.success,
title: 'Goal achieved',
};
@ -88,7 +202,7 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
};
case 'failed':
return {
prefix: '✖',
prefix: ICON.CROSS,
prefixColor: theme.status.error,
title: 'Goal could not be achieved',
};
@ -98,6 +212,12 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
prefixColor: theme.status.warning,
title: 'Goal aborted',
};
case 'paused':
return {
prefix: '!',
prefixColor: theme.status.warning,
title: 'Goal paused',
};
default:
return assertNeverGoalStatusKind(kind);
}
@ -124,12 +244,6 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
<Text color={theme.text.secondary}> · {subtitle}</Text>
) : null}
</Text>
{/* Ink's flex-row layout strips trailing whitespace inside the label
Text (so "Last check: " renders as "Last check:" with the value
slammed up against the colon, and wrapped lines align with col 0
of the value instead of after the colon-space). Use marginRight
on the label Box to introduce a real 1-column gap that survives
the row layout same fix applies to the "Goal:" row. */}
<Box flexDirection="row">
<Box flexShrink={0} marginRight={1}>
<Text color={theme.text.secondary}>Goal:</Text>
@ -138,17 +252,6 @@ const GoalStatusMessageInternal: React.FC<GoalStatusMessageProps> = ({
<Text wrap="wrap">{condition}</Text>
</Box>
</Box>
{/* `lastReason` is shown on terminal cards (achieved / aborted /
failed) so
the final summary records *why* the judge ruled the goal complete
or why the loop gave up. Skipped for `cleared` because user-driven
clears don't carry a judge reason.
Rendered as a single `<Text wrap="wrap">` (label + value inline)
rather than the flex-row split used for `Goal:` above the judge
reason is capped at 240 chars and almost always wraps, and the
flex-row variant hangs the continuation at the value column's
left edge (12 cols of empty space, easily mistaken for a blank
line). One Text + natural wrap keeps the continuation flush. */}
{isTerminalGoalStatusKind(kind) && lastReason?.trim() ? (
<Text color={theme.text.secondary} wrap="wrap">
Last check: {lastReason.trim()}

View file

@ -46,4 +46,6 @@ export const ICON = {
STAR: `${_VS15}`,
RADIO_FILLED: `${_VS15}`,
CIRCLE_LEFT_HALF: `${_VS15}`,
CHECK: `${_VS15}`,
CROSS: `${_VS15}`,
} as const;

View file

@ -211,6 +211,7 @@ describe('useSlashCommandProcessor', () => {
setIsProcessing = vi.fn(),
settings: LoadedSettings = mockSettings,
extensionRefreshState?: ExtensionRefreshState,
isIdleRef = { current: true },
) => {
mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands));
mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands));
@ -228,7 +229,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleVimEnabled
false, // isProcessing
setIsProcessing,
{ current: true }, // isIdleRef
isIdleRef,
vi.fn(), // setGeminiMdFileCount
createMockActions(),
new Map(), // extensionsUpdateState
@ -454,6 +455,167 @@ describe('useSlashCommandProcessor', () => {
);
});
it('renders an idle Goal control response as a Goal state item', async () => {
const snapshot = {
v: 2 as const,
activity: 'running' as const,
goal: {
goalId: 'goal-ui',
revision: 1,
objective: 'Ship the TUI',
status: 'active' as const,
evidenceCursor: { recordId: 'record-ui' },
turnCount: 0,
activeTimeMs: 0,
createdAt: 1,
updatedAt: 1,
},
};
const command = createTestCommand({
name: 'goal',
action: vi.fn().mockResolvedValue({
type: 'goal_control',
operation: { kind: 'set', objective: 'Ship the TUI' },
response: { snapshot },
cause: 'create',
}),
});
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/goal Ship the TUI');
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.GOAL_STATE,
snapshot,
cause: 'create',
},
expect.any(Number),
);
});
it('leaves a mid-turn Goal control response to the active stream', async () => {
const snapshot = {
v: 2 as const,
activity: 'idle' as const,
goal: null,
};
const command = createTestCommand({
name: 'goal',
action: vi.fn().mockResolvedValue({
type: 'goal_control',
operation: { kind: 'clear' },
response: { snapshot },
cause: 'clear',
}),
});
const result = setupProcessorHook(
[command],
[],
[],
vi.fn(),
mockSettings,
undefined,
{ current: false },
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/goal clear');
});
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.GOAL_STATE }),
expect.any(Number),
);
});
it('renders a mid-turn /goal status card since it emits no broadcast', async () => {
const snapshot = {
v: 2 as const,
activity: 'idle' as const,
goal: {
goalId: 'goal-status',
revision: 2,
objective: 'Ship the TUI',
status: 'active' as const,
evidenceCursor: { recordId: 'record-status' },
turnCount: 1,
activeTimeMs: 5,
createdAt: 1,
updatedAt: 2,
},
};
const command = createTestCommand({
name: 'goal',
action: vi.fn().mockResolvedValue({
type: 'goal_control',
operation: { kind: 'status' },
response: { snapshot },
}),
});
const result = setupProcessorHook(
[command],
[],
[],
vi.fn(),
mockSettings,
undefined,
{ current: false },
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/goal');
});
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.GOAL_STATE,
snapshot,
},
expect.any(Number),
);
});
it('renders a mid-turn causeless /goal clear with no active goal', async () => {
const snapshot = {
v: 2 as const,
activity: 'idle' as const,
goal: null,
};
const command = createTestCommand({
name: 'goal',
action: vi.fn().mockResolvedValue({
type: 'goal_control',
operation: { kind: 'clear' },
response: { snapshot },
}),
});
const result = setupProcessorHook(
[command],
[],
[],
vi.fn(),
mockSettings,
undefined,
{ current: false },
);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/goal clear');
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: MessageType.INFO, text: 'No Goal set.' },
expect.any(Number),
);
});
it('should correctly find and execute a nested subcommand', async () => {
const childAction = vi.fn();
const parentCommand: SlashCommand = {

View file

@ -1073,6 +1073,36 @@ export const useSlashCommandProcessor = (
});
}
return { type: 'handled' };
case 'goal_control': {
// A causeless result (a `status` read, or a `clear` when no
// Goal is active) emits no runtime broadcast, so it must render
// its own card even mid-turn. Mutations broadcast a GoalState
// event the active stream renders, so they defer to it while a
// turn is running.
const rendersHere =
result.cause === undefined ||
commandContext.ui.isIdleRef.current;
if (rendersHere) {
const snapshot = result.response.snapshot;
if (snapshot.goal || result.cause === 'clear') {
addItem(
{
type: MessageType.GOAL_STATE,
snapshot,
...(result.cause ? { cause: result.cause } : {}),
},
Date.now(),
);
} else {
addMessage({
type: MessageType.INFO,
content: 'No Goal set.',
timestamp: new Date(),
});
}
}
return { type: 'handled' };
}
case 'dialog':
switch (result.dialog) {
case 'arena_start':

View file

@ -7,13 +7,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useBranchCommand } from './useBranchCommand.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../utils/restoreGoal.js', () => ({
restoreGoalFromHistory: vi.fn(() => ({ restored: false })),
}));
const mockSettings = {
merged: { ui: { history: { collapseOnResume: false } } },
} as unknown as LoadedSettings;
@ -26,6 +21,7 @@ describe('useBranchCommand', () => {
let finalize: ReturnType<typeof vi.fn>;
let flush: ReturnType<typeof vi.fn>;
let startNewSessionConfig: ReturnType<typeof vi.fn>;
let getGoalRuntimeReady: ReturnType<typeof vi.fn>;
let startNewSessionUI: ReturnType<typeof vi.fn>;
let findSessionTitlesByPrefix: ReturnType<typeof vi.fn>;
let clearItems: ReturnType<typeof vi.fn>;
@ -78,7 +74,6 @@ describe('useBranchCommand', () => {
});
beforeEach(() => {
vi.mocked(restoreGoalFromHistory).mockClear();
forkSession = vi
.fn()
.mockResolvedValue({ filePath: '/tmp/new.jsonl', copiedCount: 2 });
@ -95,6 +90,7 @@ describe('useBranchCommand', () => {
flush = vi.fn().mockResolvedValue(undefined);
findSessionTitlesByPrefix = vi.fn().mockResolvedValue([]);
startNewSessionConfig = vi.fn();
getGoalRuntimeReady = vi.fn().mockResolvedValue({});
startNewSessionUI = vi.fn();
clearItems = vi.fn();
loadHistory = vi.fn();
@ -133,6 +129,7 @@ describe('useBranchCommand', () => {
getBackgroundShellRegistry: () => backgroundShellRegistry,
getWorkflowRunRegistry: () => workflowRunRegistry,
startNewSession: startNewSessionConfig,
getGoalRuntimeReady,
getDebugLogger: () => ({ warn: vi.fn() }),
};
});
@ -201,6 +198,10 @@ describe('useBranchCommand', () => {
return true;
});
startNewSessionConfig.mockImplementation(() => order.push('config.start'));
getGoalRuntimeReady.mockImplementation(async () => {
order.push('goal.ready');
return {};
});
const { result } = renderHook(() => useBranchCommand(makeOptions()));
await act(async () => {
@ -216,6 +217,7 @@ describe('useBranchCommand', () => {
'rename',
'load', // final load after title persistence
'config.start',
'goal.ready',
]);
});
@ -250,20 +252,35 @@ describe('useBranchCommand', () => {
);
});
it('re-arms /goal against the forked sessionId after the UI swap', async () => {
// The branched JSONL is a verbatim copy of the parent's, so an active
// goal sentinel rides along. Without this restore call the forked
// session inherits the goal in transcript only — store stays empty,
// footer pill shows nothing, and the Stop hook never fires under the
// new sessionId. Same root cause as the /resume gap; pin it here.
it('waits for the forked session Goal runtime exactly once', async () => {
const { result } = renderHook(() => useBranchCommand(makeOptions()));
await act(async () => {
await result.current.handleBranch('my-branch');
});
expect(restoreGoalFromHistory).toHaveBeenCalledWith(
expect.any(Array),
config,
addItem,
expect(getGoalRuntimeReady).toHaveBeenCalledTimes(1);
});
it('rolls core back when the fork contains malformed Goal state', async () => {
getGoalRuntimeReady.mockRejectedValueOnce(
new Error('unsupported Goal lifecycle record'),
);
const { result } = renderHook(() => useBranchCommand(makeOptions()));
await act(async () => {
await result.current.handleBranch('my-branch');
});
expect(startNewSessionConfig).toHaveBeenCalledTimes(2);
expect(startNewSessionUI).not.toHaveBeenCalled();
expect(clearItems).not.toHaveBeenCalled();
expect(loadHistory).not.toHaveBeenCalled();
expect(removeSession).toHaveBeenCalledTimes(1);
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/unsupported Goal lifecycle record/),
}),
expect.any(Number),
);
});

View file

@ -17,7 +17,6 @@ import {
buildResumedHistoryItems,
applyCollapsePolicyAndSummary,
} from '../utils/resumeHistoryUtils.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { LoadedSettings } from '../../config/settings.js';
import { t } from '../../i18n/index.js';
@ -25,6 +24,7 @@ import {
hasBlockingBackgroundWork,
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';
import { waitForGoalRuntime } from '../utils/goal-runtime.js';
const BACKGROUND_WORK_BRANCH_BLOCKED_MESSAGE =
"Stop the current session's running background tasks before branching the conversation.";
@ -188,6 +188,7 @@ export function useBranchCommand(
// the parent, silently recording user input into an orphan.
config.startNewSession(newSessionId, resumed);
coreSwapped = true;
await waitForGoalRuntime(config);
await config.getGeminiClient()?.initialize?.(SessionStartSource.Branch);
// 8. Swap UI. Once this commits, rolling core back is unsafe —
@ -212,23 +213,7 @@ export function useBranchCommand(
uiSwapped = true;
resetBackgroundStateForSessionSwitch(config);
// 9. Re-arm /goal under the fork's new sessionId. The branched JSONL
// is a verbatim copy of the parent's, so an active goal sentinel
// carries over — but `config.startNewSession` rebuilt the hook
// system under `newSessionId`, leaving the parent's `activeGoal`
// store entry stale and the Stop hook unregistered. Same rationale
// as the /resume path; see [[useResumeCommand]] for details.
try {
restoreGoalFromHistory(
uiHistoryItems,
config,
historyManager.addItem,
);
} catch {
// Best-effort — branch must not fail on goal restoration.
}
// 10. Apply the already-persisted title to the prompt bar.
// 9. Apply the already-persisted title to the prompt bar.
setSessionName?.(effectiveTitle);
// Refresh terminal UI.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessageQueue, type QueuedSubmission } from './useMessageQueue.js';
import type { GoalTurnHost, GoalTurnPermit } from '@qwen-code/qwen-code-core';
import { useMessageQueue } from './useMessageQueue.js';
describe('useMessageQueue', () => {
beforeEach(() => {
@ -85,11 +86,384 @@ describe('useMessageQueue', () => {
);
});
it('keeps one hidden Goal turn out of the public queue and wakes dequeue', () => {
const permit: GoalTurnPermit = {
goalId: 'goal-1',
revision: 2,
turnId: 'turn-1',
};
const input: Parameters<GoalTurnHost['startGoalTurn']>[0] = {
permit,
continuationContext: 'Continue the active Goal',
verifierFeedback: 'Need stronger evidence',
};
const { result } = renderHook(() => useMessageQueue());
const queue = result.current as typeof result.current & {
enqueueGoalTurn?: (value: typeof input) => void;
pendingSubmissionCount?: number;
popNextSubmission?: () => unknown;
};
expect(queue.enqueueGoalTurn).toBeTypeOf('function');
act(() => {
queue.enqueueGoalTurn!(input);
queue.enqueueGoalTurn!(input);
});
expect(result.current.messageQueue).toEqual([]);
expect((result.current as typeof queue).pendingSubmissionCount).toBe(1);
let submission: unknown;
act(() => {
submission = queue.popNextSubmission!();
});
expect(submission).toEqual({
kind: 'goal',
permit,
turnKey: 'goal-runtime:turn-1',
continuationContext: 'Continue the active Goal',
verifierFeedback: 'Need stronger evidence',
});
expect(queue.popNextSubmission!()).toBeNull();
});
it('peeks a stable plain-user batch key without consuming messages', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('first prompt');
result.current.addMessage('/help');
result.current.addMessage('second prompt');
});
const queue = result.current as typeof result.current & {
peekNextUserBatchKey?: () => string | undefined;
popNextSubmission: () => unknown;
};
expect(queue.peekNextUserBatchKey).toBeTypeOf('function');
const firstPeek = queue.peekNextUserBatchKey!();
const secondPeek = queue.peekNextUserBatchKey!();
expect(firstPeek).toEqual(expect.any(String));
expect(secondPeek).toBe(firstPeek);
expect(result.current.messageQueue).toEqual([
'first prompt',
'/help',
'second prompt',
]);
let submission: unknown;
act(() => {
submission = queue.popNextSubmission();
});
expect(submission).toEqual({
kind: 'user',
modelText: 'first prompt\n\nsecond prompt',
turnKey: firstPeek,
});
expect(result.current.messageQueue).toEqual(['/help']);
expect(queue.peekNextUserBatchKey!()).toBeUndefined();
});
it('pops a slash-command-headed queue one command at a time in normal mode', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/help');
});
let submission: ReturnType<typeof result.current.popNextSubmission> = null;
act(() => {
submission = result.current.popNextSubmission();
});
expect(submission).toMatchObject({ kind: 'user', modelText: '/model' });
expect(result.current.messageQueue).toEqual(['/help']);
let second: ReturnType<typeof result.current.popNextSubmission> = null;
act(() => {
second = result.current.popNextSubmission();
});
expect(second).toMatchObject({ kind: 'user', modelText: '/help' });
expect(result.current.messageQueue).toEqual([]);
});
it('hides the plain-user batch key from an active Goal turn reservation', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('queued user');
});
const queue = result.current as typeof result.current & {
peekNextUserBatchKey?: (goalTurnActive?: boolean) => string | undefined;
};
// Idle boundary: the plain message is deliverable, so it is reservable.
expect(queue.peekNextUserBatchKey!()).toEqual(expect.any(String));
// Active Goal turn: the two-lane drain gate holds plain messages, so no
// key is reported and the Goal loop continues instead of reserving a turn
// the queue will never release.
expect(queue.peekNextUserBatchKey!(true)).toBeUndefined();
expect(result.current.messageQueue).toEqual(['queued user']);
expect(queue.peekNextUserBatchKey!()).toEqual(expect.any(String));
});
it('keeps a Goal permit hidden until plain user preprocessing succeeds', () => {
const permit: GoalTurnPermit = {
goalId: 'goal-1',
revision: 2,
turnId: 'turn-user-priority',
};
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.enqueueGoalTurn({
permit,
continuationContext: 'automatic continuation',
});
result.current.addMessage('user goes first');
});
const userTurnKey = result.current.peekNextUserBatchKey();
let submission;
act(() => {
submission = result.current.popNextSubmission();
});
expect(submission).toEqual({
kind: 'user',
modelText: 'user goes first',
turnKey: userTurnKey,
});
expect(result.current.pendingSubmissionCount).toBe(1);
let claimedGoal;
act(() => {
claimedGoal = result.current.claimGoalTurn();
});
expect(claimedGoal).toEqual({
kind: 'goal',
permit,
turnKey: 'goal-runtime:turn-user-priority',
continuationContext: 'automatic continuation',
});
expect(result.current.pendingSubmissionCount).toBe(0);
});
it('defensively copies a Goal permit when it is admitted', () => {
const permit: GoalTurnPermit = {
goalId: 'goal-copy',
revision: 3,
turnId: 'turn-copy',
};
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.enqueueGoalTurn({
permit,
continuationContext: 'copy the permit',
});
});
permit.revision = 99;
let submission: unknown;
act(() => {
submission = result.current.popNextSubmission();
});
expect(submission).toMatchObject({ kind: 'goal' });
const goalSubmission = submission as {
kind: 'goal';
permit: typeof permit;
};
expect(goalSubmission.permit).toEqual({
goalId: 'goal-copy',
revision: 3,
turnId: 'turn-copy',
});
expect(goalSubmission.permit).not.toBe(permit);
});
it('creates a stable direct-user admission that claims a hidden Goal', () => {
const permit: GoalTurnPermit = {
goalId: 'goal-direct',
revision: 4,
turnId: 'turn-direct',
};
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.enqueueGoalTurn({
permit,
continuationContext: 'direct user wins',
});
});
const queue = result.current as typeof result.current & {
claimDirectUserAdmission?: () => unknown;
};
expect(queue.claimDirectUserAdmission).toBeTypeOf('function');
let admission: unknown;
act(() => {
admission = queue.claimDirectUserAdmission!();
});
expect(admission).toEqual({
turnKey: expect.any(String),
goal: {
kind: 'goal',
permit,
turnKey: 'goal-runtime:turn-direct',
continuationContext: 'direct user wins',
},
});
expect(result.current.pendingSubmissionCount).toBe(0);
let nextAdmission: unknown;
act(() => {
nextAdmission = queue.claimDirectUserAdmission!();
});
expect(nextAdmission).toEqual({
turnKey: expect.any(String),
});
});
it('lets a system turn claim a hidden Goal without creating a user key', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-system',
revision: 2,
turnId: 'turn-system',
},
continuationContext: 'system event goes first',
});
});
const queue = result.current as typeof result.current & {
claimGoalTurn?: () => unknown;
};
expect(queue.claimGoalTurn).toBeTypeOf('function');
let claimed: unknown;
act(() => {
claimed = queue.claimGoalTurn!();
});
expect(claimed).toEqual({
kind: 'goal',
permit: {
goalId: 'goal-system',
revision: 2,
turnId: 'turn-system',
},
turnKey: 'goal-runtime:turn-system',
continuationContext: 'system event goes first',
});
expect(result.current.pendingSubmissionCount).toBe(0);
expect(queue.claimGoalTurn!()).toBeUndefined();
});
it('does not reuse real-user turn keys across hook instances', () => {
const first = renderHook(() => useMessageQueue());
const second = renderHook(() => useMessageQueue());
const firstAdmission = first.result.current.claimDirectUserAdmission();
const secondAdmission = second.result.current.claimDirectUserAdmission();
expect(firstAdmission.turnKey).not.toBe(secondAdmission.turnKey);
});
it('releases Goal dedup state after many claimed turns', () => {
const { result } = renderHook(() => useMessageQueue());
for (let index = 0; index < 160; index++) {
act(() => {
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-many-turns',
revision: 1,
turnId: `turn-${index}`,
},
continuationContext: `continue ${index}`,
});
result.current.claimGoalTurn();
});
}
expect(result.current.pendingSubmissionCount).toBe(0);
act(() => {
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-many-turns',
revision: 1,
turnId: 'turn-0',
},
continuationContext: 'turn ids do not leak forever',
});
});
expect(result.current.pendingSubmissionCount).toBe(1);
});
it('reports queued real-user priority separately from hidden Goal work', () => {
const { result } = renderHook(() => useMessageQueue());
expect(result.current.hasQueuedUserMessages()).toBe(false);
expect(result.current.getPendingSubmissionCount()).toBe(0);
act(() => {
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-priority',
revision: 1,
turnId: 'turn-priority',
},
continuationContext: 'hidden',
});
});
expect(result.current.hasQueuedUserMessages()).toBe(false);
expect(result.current.getPendingSubmissionCount()).toBe(1);
act(() => {
result.current.addMessage('/help');
});
expect(result.current.hasQueuedUserMessages()).toBe(true);
expect(result.current.getPendingSubmissionCount()).toBe(2);
});
it('removes queued Goal turns without deleting real user text', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-preempt',
revision: 1,
turnId: 'turn-preempt',
},
continuationContext: 'remove only this entry',
});
result.current.addMessage('keep me');
});
const queue = result.current as typeof result.current & {
removeGoalTurns?: () => string[];
};
expect(queue.removeGoalTurns).toBeTypeOf('function');
let removedKeys: string[] = [];
act(() => {
removedKeys = queue.removeGoalTurns!();
});
expect(removedKeys).toHaveLength(1);
expect(removedKeys[0]).toMatch(/^goal-runtime:/);
expect(result.current.messageQueue).toEqual(['keep me']);
expect(result.current.pendingSubmissionCount).toBe(1);
let kept: unknown;
act(() => {
kept = result.current.popNextSubmission();
});
expect(kept).toMatchObject({
kind: 'user',
modelText: 'keep me',
});
});
describe('popAllMessages (cancel and ESC/Up restore)', () => {
it('returns null when the queue is empty', () => {
const { result } = renderHook(() => useMessageQueue());
let popped: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
@ -107,12 +481,13 @@ describe('useMessageQueue', () => {
result.current.addMessage('Message 3');
});
let popped: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toEqual({
expect(popped).toMatchObject({
kind: 'user',
modelText: 'Message 1\n\nMessage 2\n\nMessage 3',
});
expect(result.current.messageQueue).toEqual([]);
@ -125,12 +500,15 @@ describe('useMessageQueue', () => {
result.current.addMessage('Only message');
});
let popped: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toEqual({ modelText: 'Only message' });
expect(popped).toMatchObject({
kind: 'user',
modelText: 'Only message',
});
expect(result.current.messageQueue).toEqual([]);
});
@ -146,53 +524,153 @@ describe('useMessageQueue', () => {
result.current.addMessage('world');
});
let popped: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toEqual({
expect(popped).toMatchObject({
kind: 'user',
modelText: '/model\n\nhello\n\nworld',
});
expect(result.current.messageQueue).toEqual([]);
});
it('aggregates provenance only when every queued message has it', () => {
it('reports the exact removed turn keys for Goal reservation release', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => result.current.addMessage('queued user'));
const reservedKey = result.current.peekNextUserBatchKey();
const removed: string[][] = [];
act(() => {
result.current.addMessage('model one', false, 'user one');
result.current.addMessage('model two', false, 'user two');
result.current.popAllMessages((keys) => removed.push(keys));
});
let popped: QueuedSubmission | null = null;
expect(removed).toEqual([[reservedKey]]);
});
it('aggregates submittedPrompt when every message has one', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('msg A', false, 'prompt A');
result.current.addMessage('msg B', false, 'prompt B');
});
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toEqual({
modelText: 'model one\n\nmodel two',
submittedPrompt: 'user one\n\nuser two',
expect(popped).toMatchObject({
kind: 'user',
modelText: 'msg A\n\nmsg B',
submittedPrompt: 'prompt A\n\nprompt B',
});
});
it('omits aggregate provenance when any queued message lacks it', () => {
it('omits submittedPrompt when any message lacks one', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('model one', false, 'user one');
result.current.addMessage('restored steer');
result.current.addMessage('msg A', false, 'prompt A');
result.current.addMessage('msg B');
});
let popped: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
popped = result.current.popAllMessages();
});
expect(popped).toEqual({
modelText: 'model one\n\nrestored steer',
expect(popped).toMatchObject({
kind: 'user',
modelText: 'msg A\n\nmsg B',
});
expect(popped!.submittedPrompt).toBeUndefined();
});
});
it('holds reserved user input behind a stopped Goal until /goal resumes it', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('queued user');
result.current.addMessage('/goal resume');
});
const reservedKey = result.current.peekNextUserBatchKey();
let goalControl: ReturnType<typeof result.current.popNextSubmission>;
act(() => {
goalControl = result.current.popNextSubmission('only');
});
expect(goalControl!).toMatchObject({
kind: 'user',
modelText: '/goal resume',
});
expect(result.current.messageQueue).toEqual(['queued user']);
expect(result.current.popNextSubmission('only')).toBeNull();
let userSubmission: ReturnType<typeof result.current.popNextSubmission>;
act(() => {
userSubmission = result.current.popNextSubmission();
});
expect(userSubmission!).toEqual({
kind: 'user',
modelText: 'queued user',
turnKey: reservedKey,
});
});
it('prioritizes a Goal control over ordinary input while the Goal is active', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('queued user');
result.current.addMessage('/goal pause');
});
let goalControl: ReturnType<typeof result.current.popNextSubmission>;
act(() => {
goalControl = result.current.popNextSubmission('priority');
});
expect(goalControl!).toMatchObject({
kind: 'user',
modelText: '/goal pause',
});
expect(result.current.messageQueue).toEqual(['queued user']);
});
it('keeps ordinary input queued while an active Goal has no continuation ready', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('queued user');
});
expect(result.current.popNextSubmission('priority')).toBeNull();
expect(result.current.messageQueue).toEqual(['queued user']);
});
it('drains a hidden Goal continuation before ordinary queued input', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('queued user');
result.current.enqueueGoalTurn({
permit: {
goalId: 'goal-1',
revision: 1,
turnId: 'goal-turn-1',
},
continuationContext: 'continue the active Goal',
});
});
let submission: ReturnType<typeof result.current.popNextSubmission>;
act(() => {
submission = result.current.popNextSubmission('priority');
});
expect(submission!).toMatchObject({
kind: 'goal',
turnKey: 'goal-runtime:goal-turn-1',
});
expect(result.current.messageQueue).toEqual(['queued user']);
expect(result.current.popNextSubmission('priority')).toBeNull();
});
describe('drainQueue (mid-turn drain for tool-result injection)', () => {
@ -225,14 +703,13 @@ describe('useMessageQueue', () => {
expect(result.current.messageQueue).toEqual(['/model']);
});
it('drains goal commands during an active turn', () => {
it('keeps Goal creation queued until an ordinary turn reaches idle', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('steer now');
result.current.addMessage('/goal clear');
result.current.addMessage('/goal ship the release');
result.current.addMessage('/model');
result.current.addMessage('/goal replace the active goal');
});
let drained: string[] = [];
@ -240,12 +717,38 @@ describe('useMessageQueue', () => {
drained = result.current.drainQueue();
});
expect(drained).toEqual([
'steer now',
'/goal clear',
'/goal replace the active goal',
expect(drained).toEqual(['steer now']);
expect(result.current.messageQueue).toEqual([
'/goal ship the release',
'/model',
]);
});
it('drains only Goal controls while a Goal turn is running', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('plain user text');
result.current.addMessage('/goal pause');
result.current.addMessage('/model');
result.current.addMessage('/goal edit revised objective');
result.current.addMessage('/goal clear');
});
let drained: string[] = [];
act(() => {
drained = result.current.drainQueue(false, true);
});
expect(drained).toEqual([
'/goal pause',
'/goal edit revised objective',
'/goal clear',
]);
expect(result.current.messageQueue).toEqual([
'plain user text',
'/model',
]);
expect(result.current.messageQueue).toEqual(['/model']);
});
it('leaves goal commands queued at the idle boundary', () => {
@ -347,123 +850,42 @@ describe('useMessageQueue', () => {
expect(result.current.messageQueue).toEqual(['steer now', 'newer input']);
});
it('drops provenance when interrupted steer messages are restored', () => {
it('preserves submittedPrompt provenance when restoring one interrupted message', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('steer now', false, 'raw steer');
result.current.restoreMessages(['steer now'], 'original prompt');
});
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
const drained = result.current.drainQueue();
result.current.restoreMessages(drained);
popped = result.current.popAllMessages();
});
let submission: QueuedSubmission | null = null;
act(() => {
submission = result.current.popNextTurn();
expect(popped).toMatchObject({
kind: 'user',
modelText: 'steer now',
submittedPrompt: 'original prompt',
});
expect(submission).toEqual({ modelText: 'steer now' });
});
});
describe('popNextTurn', () => {
it('returns null when the queue is empty', () => {
const { result } = renderHook(() => useMessageQueue());
let submission: QueuedSubmission | null = null;
act(() => {
submission = result.current.popNextTurn();
});
expect(submission).toBeNull();
});
it('pops the first slash command and leaves the rest queued', () => {
it('drops submittedPrompt provenance when restoring multiple messages', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/help');
result.current.restoreMessages(['first', 'second'], 'original prompt');
});
let submission: QueuedSubmission | null = null;
let popped: ReturnType<typeof result.current.popAllMessages> = null;
act(() => {
submission = result.current.popNextTurn();
});
expect(submission).toEqual({ modelText: '/model' });
expect(result.current.messageQueue).toEqual(['/help']);
});
it('drains slash commands one item at a time across repeated calls', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('/theme');
result.current.addMessage('/help');
popped = result.current.popAllMessages();
});
const submissions: Array<QueuedSubmission | null> = [];
act(() => {
submissions.push(result.current.popNextTurn());
});
act(() => {
submissions.push(result.current.popNextTurn());
});
act(() => {
submissions.push(result.current.popNextTurn());
});
act(() => {
submissions.push(result.current.popNextTurn());
});
expect(submissions).toEqual([
{ modelText: '/model' },
{ modelText: '/theme' },
{ modelText: '/help' },
null,
]);
expect(result.current.messageQueue).toEqual([]);
});
it('batches all plain prompts while leaving interleaved slash commands', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('/model');
result.current.addMessage('model one', false, 'user one');
result.current.addMessage('/help');
result.current.addMessage('model two', true, 'user two');
});
let submission: QueuedSubmission | null = null;
act(() => {
submission = result.current.popNextTurn();
});
expect(submission).toEqual({
modelText: 'model one\n\nmodel two',
submittedPrompt: 'user one\n\nuser two',
});
expect(result.current.messageQueue).toEqual(['/model', '/help']);
});
it('fails closed when a batched prompt lacks provenance', () => {
const { result } = renderHook(() => useMessageQueue());
act(() => {
result.current.addMessage('model one', false, 'user one');
result.current.addMessage('model two');
});
let submission: QueuedSubmission | null = null;
act(() => {
submission = result.current.popNextTurn();
});
expect(submission).toEqual({
modelText: 'model one\n\nmodel two',
expect(popped).toMatchObject({
kind: 'user',
modelText: 'first\n\nsecond',
});
expect(popped!.submittedPrompt).toBeUndefined();
});
});
});

View file

@ -4,75 +4,226 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { useCallback, useRef, useState } from 'react';
import type { GoalTurnHost, GoalTurnPermit } from '@qwen-code/qwen-code-core';
import { isSlashCommand } from '../utils/commandUtils.js';
export interface QueuedGoalTurn {
kind: 'goal';
permit: GoalTurnPermit;
turnKey: string;
continuationContext: string;
verifierFeedback?: string;
}
export interface QueuedUserSubmission {
kind: 'user';
modelText: string;
submittedPrompt?: string;
turnKey: string;
}
export interface DirectUserAdmission {
turnKey: string;
goal?: QueuedGoalTurn;
}
export type QueuedSubmission = QueuedUserSubmission | QueuedGoalTurn;
export type GoalQueueControlMode = 'normal' | 'priority' | 'only';
export interface UseMessageQueueReturn {
messageQueue: string[];
pendingSubmissionCount: number;
addMessage: (
message: string,
deferUntilIdle?: boolean,
submittedPrompt?: string,
) => void;
enqueueGoalTurn: (
input: Parameters<GoalTurnHost['startGoalTurn']>[0],
) => void;
peekNextUserBatchKey: (goalTurnActive?: boolean) => string | undefined;
hasQueuedUserMessages: () => boolean;
getPendingSubmissionCount: () => number;
claimGoalTurn: () => QueuedGoalTurn | undefined;
claimDirectUserAdmission: () => DirectUserAdmission;
removeGoalTurns: () => string[];
popNextSubmission: (
goalControlMode?: GoalQueueControlMode,
) => QueuedSubmission | null;
clearQueue: () => void;
getQueuedMessagesText: () => string;
/** Drain the entire queue joined with `\n\n`. For Ctrl+C / ESC / Up edit-restore. */
popAllMessages: () => QueuedSubmission | null;
/** Restore interrupted steer messages to the front of the queue. */
restoreMessages: (messages: string[]) => void;
/**
* Drain plain-text prompts that can steer the active turn. Pass true at the
* idle boundary to also drain messages explicitly deferred with Ctrl+Q.
* Slash commands stay queued except `/goal`, which must control active loops.
*/
drainQueue: (includeDeferred?: boolean) => string[];
/** Drain the next idle turn while preserving eligible prompt provenance. */
popNextTurn: () => QueuedSubmission | null;
popAllMessages: (
onRemoved?: (turnKeys: string[]) => void,
) => QueuedUserSubmission | null;
restoreMessages: (messages: string[], submittedPrompt?: string) => void;
drainQueue: (includeDeferred?: boolean, goalTurnActive?: boolean) => string[];
}
export interface QueuedSubmission {
modelText: string;
interface QueuedMessage {
key: string;
text: string;
submittedPrompt?: string;
}
interface QueuedMessage extends QueuedSubmission {
deferUntilIdle: boolean;
}
export const GOAL_COMMAND_RE = /^\/goal(?:\s|$)/;
function aggregateMessages(
function aggregateUserMessages(
messages: readonly QueuedMessage[],
): QueuedSubmission {
const modelText = messages.map((message) => message.modelText).join('\n\n');
): QueuedUserSubmission {
const text = messages.map((message) => message.text).join('\n\n');
const submittedPrompts = messages.map((message) => message.submittedPrompt);
return submittedPrompts.every(
(submittedPrompt): submittedPrompt is string =>
submittedPrompt !== undefined,
)
? { modelText, submittedPrompt: submittedPrompts.join('\n\n') }
: { modelText };
return {
kind: 'user',
modelText: text,
turnKey: messages[0].key,
...(submittedPrompts.every(
(submittedPrompt): submittedPrompt is string =>
submittedPrompt !== undefined,
)
? { submittedPrompt: submittedPrompts.join('\n\n') }
: {}),
};
}
export function useMessageQueue(): UseMessageQueueReturn {
const [queuedMessages, setQueuedMessages] = useState<QueuedMessage[]>([]);
// Synchronous mirror so non-React callbacks see the latest queue.
const [queuedGoalTurns, setQueuedGoalTurns] = useState<QueuedGoalTurn[]>([]);
const queueRef = useRef<QueuedMessage[]>([]);
const goalQueueRef = useRef<QueuedGoalTurn[]>([]);
const nextMessageKey = useCallback(() => `message-queue:${randomUUID()}`, []);
const addMessage = useCallback(
(message: string, deferUntilIdle = false, submittedPrompt?: string) => {
const modelText = message.trim();
if (modelText.length > 0) {
queueRef.current = [
...queueRef.current,
{ modelText, deferUntilIdle, submittedPrompt },
];
setQueuedMessages(queueRef.current);
const text = message.trim();
if (!text) return;
queueRef.current = [
...queueRef.current,
{
key: nextMessageKey(),
text,
deferUntilIdle,
submittedPrompt,
},
];
setQueuedMessages(queueRef.current);
},
[nextMessageKey],
);
const enqueueGoalTurn = useCallback(
(input: Parameters<GoalTurnHost['startGoalTurn']>[0]) => {
if (
goalQueueRef.current.some(
({ permit }) => permit.turnId === input.permit.turnId,
)
) {
return;
}
const entry: QueuedGoalTurn = {
kind: 'goal',
permit: { ...input.permit },
turnKey: `goal-runtime:${input.permit.turnId}`,
continuationContext: input.continuationContext,
...(input.verifierFeedback
? { verifierFeedback: input.verifierFeedback }
: {}),
};
goalQueueRef.current = [...goalQueueRef.current, entry];
setQueuedGoalTurns(goalQueueRef.current);
},
[],
);
const peekNextUserBatchKey = useCallback(
(goalTurnActive = false) =>
goalTurnActive
? undefined
: queueRef.current.find(({ text }) => !isSlashCommand(text))?.key,
[],
);
const hasQueuedUserMessages = useCallback(
() => queueRef.current.length > 0,
[],
);
const getPendingSubmissionCount = useCallback(
() => queueRef.current.length + goalQueueRef.current.length,
[],
);
const claimGoalTurn = useCallback((): QueuedGoalTurn | undefined => {
const [goal, ...remainingGoals] = goalQueueRef.current;
if (goal) {
goalQueueRef.current = remainingGoals;
setQueuedGoalTurns(remainingGoals);
}
return goal;
}, []);
const claimDirectUserAdmission = useCallback((): DirectUserAdmission => {
const goal = claimGoalTurn();
return {
turnKey: nextMessageKey(),
...(goal ? { goal } : {}),
};
}, [claimGoalTurn, nextMessageKey]);
const removeGoalTurns = useCallback((): string[] => {
const keys = goalQueueRef.current.map(({ turnKey }) => turnKey);
if (keys.length === 0) return [];
goalQueueRef.current = [];
setQueuedGoalTurns([]);
return keys;
}, []);
const popNextSubmission = useCallback(
(
goalControlMode: GoalQueueControlMode = 'normal',
): QueuedSubmission | null => {
if (goalControlMode !== 'normal') {
const goalCommandIndex = queueRef.current.findIndex(({ text }) =>
GOAL_COMMAND_RE.test(text),
);
if (goalCommandIndex >= 0) {
const goalCommand = queueRef.current[goalCommandIndex];
queueRef.current = [
...queueRef.current.slice(0, goalCommandIndex),
...queueRef.current.slice(goalCommandIndex + 1),
];
setQueuedMessages(queueRef.current);
return aggregateUserMessages([goalCommand]);
}
if (goalControlMode === 'priority') {
return claimGoalTurn() ?? null;
}
if (goalControlMode === 'only') return null;
}
const plainMessages = queueRef.current.filter(
({ text }) => !isSlashCommand(text),
);
if (plainMessages.length > 0) {
queueRef.current = queueRef.current.filter(({ text }) =>
isSlashCommand(text),
);
setQueuedMessages(queueRef.current);
return aggregateUserMessages(plainMessages);
}
const [userHead, ...userRest] = queueRef.current;
if (userHead) {
queueRef.current = userRest;
setQueuedMessages(userRest);
return aggregateUserMessages([userHead]);
}
return claimGoalTurn() ?? null;
},
[claimGoalTurn],
);
const clearQueue = useCallback(() => {
queueRef.current = [];
setQueuedMessages([]);
@ -80,64 +231,76 @@ export function useMessageQueue(): UseMessageQueueReturn {
const getQueuedMessagesText = useCallback(() => {
if (queuedMessages.length === 0) return '';
return queuedMessages.map(({ modelText }) => modelText).join('\n\n');
return queuedMessages.map(({ text }) => text).join('\n\n');
}, [queuedMessages]);
const popAllMessages = useCallback((): QueuedSubmission | null => {
const current = queueRef.current;
if (current.length === 0) return null;
queueRef.current = [];
setQueuedMessages([]);
return aggregateMessages(current);
}, []);
const popAllMessages = useCallback(
(onRemoved?: (turnKeys: string[]) => void): QueuedUserSubmission | null => {
const current = queueRef.current;
if (current.length === 0) return null;
queueRef.current = [];
setQueuedMessages([]);
onRemoved?.(current.map(({ key }) => key));
return aggregateUserMessages(current);
},
[],
);
const restoreMessages = useCallback((messages: string[]) => {
const restored = messages
.map((text) => text.trim())
.filter(Boolean)
.map((modelText) => ({ modelText, deferUntilIdle: false }));
if (restored.length === 0) return;
queueRef.current = [...restored, ...queueRef.current];
setQueuedMessages(queueRef.current);
}, []);
const restoreMessages = useCallback(
(messages: string[], submittedPrompt?: string) => {
const restored = messages
.map((text) => text.trim())
.filter(Boolean)
.map((text) => ({
key: nextMessageKey(),
text,
...(messages.length === 1 && submittedPrompt !== undefined
? { submittedPrompt }
: {}),
deferUntilIdle: false,
}));
if (restored.length === 0) return;
queueRef.current = [...restored, ...queueRef.current];
setQueuedMessages(queueRef.current);
},
[nextMessageKey],
);
const drainQueue = useCallback((includeDeferred = false): string[] => {
const current = queueRef.current;
if (current.length === 0) return [];
const shouldDrain = (message: QueuedMessage) =>
(!isSlashCommand(message.modelText) ||
(!includeDeferred && GOAL_COMMAND_RE.test(message.modelText))) &&
(includeDeferred || !message.deferUntilIdle);
const drained = current.filter(shouldDrain);
if (drained.length === 0) return [];
const rest = current.filter((message) => !shouldDrain(message));
queueRef.current = rest;
setQueuedMessages(rest);
return drained.map(({ modelText }) => modelText);
}, []);
const popNextTurn = useCallback((): QueuedSubmission | null => {
const current = queueRef.current;
if (current.length === 0) return null;
const plainMessages = current.filter(
(message) => !isSlashCommand(message.modelText),
);
const messages = plainMessages.length > 0 ? plainMessages : [current[0]];
const selected = new Set(messages);
const rest = current.filter((message) => !selected.has(message));
queueRef.current = rest;
setQueuedMessages(rest);
return aggregateMessages(messages);
}, []);
const drainQueue = useCallback(
(includeDeferred = false, goalTurnActive = false): string[] => {
const current = queueRef.current;
if (current.length === 0) return [];
const shouldDrain = (message: QueuedMessage) =>
(goalTurnActive
? GOAL_COMMAND_RE.test(message.text)
: !isSlashCommand(message.text)) &&
(includeDeferred || !message.deferUntilIdle);
const drained = current.filter(shouldDrain);
if (drained.length === 0) return [];
const rest = current.filter((message) => !shouldDrain(message));
queueRef.current = rest;
setQueuedMessages(rest);
return drained.map(({ text }) => text);
},
[],
);
return {
messageQueue: queuedMessages.map(({ modelText }) => modelText),
messageQueue: queuedMessages.map(({ text }) => text),
pendingSubmissionCount: queuedMessages.length + queuedGoalTurns.length,
addMessage,
enqueueGoalTurn,
peekNextUserBatchKey,
hasQueuedUserMessages,
getPendingSubmissionCount,
claimGoalTurn,
claimDirectUserAdmission,
removeGoalTurns,
popNextSubmission,
clearQueue,
getQueuedMessagesText,
popAllMessages,
restoreMessages,
drainQueue,
popNextTurn,
};
}

View file

@ -11,7 +11,6 @@ import {
useResumeCommand,
} from './useResumeCommand.js';
import { useHistory } from './useHistoryManager.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { Content } from '@google/genai';
import type { LoadedSettings } from '../../config/settings.js';
@ -83,10 +82,6 @@ vi.mock('../utils/resumeHistoryUtils.js', async (importOriginal) => {
};
});
vi.mock('../utils/restoreGoal.js', () => ({
restoreGoalFromHistory: vi.fn(() => ({ restored: false })),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
@ -255,6 +250,7 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getGoalRuntimeReady: vi.fn().mockResolvedValue({}),
getBackgroundTaskRegistry: () => ({
hasRunningTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
@ -330,15 +326,7 @@ describe('useResumeCommand', () => {
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
expect(resetMonitorRegistry).toHaveBeenCalledTimes(1);
// Goal must be re-armed under the resumed sessionId so the in-memory
// activeGoalStore entry (potentially stale across /new + /resume) gets
// a fresh setAt / hookId / observer — otherwise the footer pill ticks
// from the pre-/new setAt and the Stop hook is silently dead.
expect(restoreGoalFromHistory).toHaveBeenCalledWith(
expect.any(Array),
config,
historyManager.addItem,
);
expect(config.getGoalRuntimeReady).toHaveBeenCalledTimes(1);
});
it('adds a recovery notice when resuming an interrupted tool turn', async () => {
@ -360,6 +348,7 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getGoalRuntimeReady: vi.fn().mockResolvedValue({}),
getBackgroundTaskRegistry: () => ({
hasRunningTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
@ -443,6 +432,7 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getGoalRuntimeReady: vi.fn().mockResolvedValue({}),
getBackgroundTaskRegistry: () => ({
hasRunningTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
@ -538,6 +528,7 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getGoalRuntimeReady: vi.fn().mockResolvedValue({}),
getBackgroundTaskRegistry: () => ({
hasRunningTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
@ -592,9 +583,6 @@ describe('useResumeCommand', () => {
}),
expect.any(Number),
);
expect(historyManager.loadHistory.mock.invocationCallOrder[0]).toBeLessThan(
historyManager.addItem.mock.invocationCallOrder[0]!,
);
});
it('blocks resume when the current session still has running background work', async () => {
@ -732,20 +720,19 @@ describe('useResumeCommand', () => {
);
});
it('rolls core back to the old session when something fails after core swap but before UI swap', async () => {
it('rolls core back when persisted Goal state is malformed', async () => {
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi
.fn()
.mockRejectedValueOnce(new Error('init boom'))
.mockResolvedValueOnce(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
const goalFailure = new Error('unsupported Goal lifecycle record');
const config = {
getSessionId: () => 'old-session-id',
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getGoalRuntimeReady: vi.fn().mockRejectedValue(goalFailure),
getBackgroundTaskRegistry: () => ({
hasRunningTasks: vi.fn().mockReturnValue(false),
reset: vi.fn(),
@ -803,6 +790,9 @@ describe('useResumeCommand', () => {
'old-session-id',
undefined,
);
expect(config.loadPausedBackgroundAgents).toHaveBeenCalledWith(
'old-session-id',
);
// UI never swapped.
expect(startNewSession).not.toHaveBeenCalled();
expect(historyManager.clearItems).not.toHaveBeenCalled();
@ -811,17 +801,12 @@ describe('useResumeCommand', () => {
expect(historyManager.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringMatching(/Failed to resume session.*init boom/),
text: expect.stringMatching(
/Failed to resume session.*unsupported Goal lifecycle record/,
),
}),
expect.any(Number),
);
// The rollback reloads the old session's still-on-disk background agents
// so `list_agents` is not left empty after core is restored. The forward
// path never reached its own load (initialize threw first), so this call
// is the rollback reload, scoped to the old session.
expect(config.loadPausedBackgroundAgents).toHaveBeenCalledTimes(1);
expect(config.loadPausedBackgroundAgents).toHaveBeenCalledWith(
'old-session-id',
);
expect(geminiClient.initialize).not.toHaveBeenCalled();
});
});

View file

@ -15,7 +15,6 @@ import {
buildResumedHistoryItems,
applyCollapsePolicyAndSummary,
} from '../utils/resumeHistoryUtils.js';
import { restoreGoalFromHistory } from '../utils/restoreGoal.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItemWithoutId } from '../types.js';
import {
@ -23,6 +22,7 @@ import {
resetBackgroundStateForSessionSwitch,
} from '../utils/backgroundWorkUtils.js';
import type { LoadedSettings } from '../../config/settings.js';
import { waitForGoalRuntime } from '../utils/goal-runtime.js';
export interface UseResumeCommandOptions {
config: Config | null;
@ -158,20 +158,7 @@ export function useResumeCommand(
resetBackgroundStateForSessionSwitch(config);
config.startNewSession(sessionId, sessionData);
coreSwapped = true;
// Re-arm /goal: the in-memory activeGoalStore entry (if any) is stale
// after `config.startNewSession` rebuilds the hook system — its
// `setAt` was captured before /new, and its `hookId` points to a
// hook that no longer exists. The cold-boot path runs this same
// call in AppContainer; the runtime /resume path needs it too,
// otherwise the footer pill keeps ticking from the original setAt
// (visible as "几十秒" elapsed immediately after /new + /resume) and
// the Stop hook is silently dead until the user re-issues /goal.
try {
restoreGoalFromHistory(uiHistoryItems, config, addItem);
} catch {
// Best-effort — never block resume on goal restoration.
}
await waitForGoalRuntime(config);
// Rebuild turn boundary tracking so rewind works within resumed sessions.
config
.getChatRecordingService()

View file

@ -14,6 +14,8 @@ import type {
ToolResultDisplay,
AgentStatus,
ArenaDiffSummary,
GoalSnapshotV2,
GoalStateCause,
} from '@qwen-code/qwen-code-core';
import type { PartListUnion } from '@google/genai';
import type { ReactNode } from 'react';
@ -584,6 +586,7 @@ export type GoalStatusKind =
| 'cleared'
| 'failed'
| 'aborted'
| 'paused'
| 'checking';
export const GOAL_STATUS_KINDS = [
@ -592,6 +595,7 @@ export const GOAL_STATUS_KINDS = [
'cleared',
'failed',
'aborted',
'paused',
'checking',
] as const satisfies readonly GoalStatusKind[];
@ -628,6 +632,12 @@ export type HistoryItemGoalStatus = HistoryItemBase & {
lastReason?: string;
};
export type HistoryItemGoalState = HistoryItemBase & {
type: 'goal_state';
snapshot: GoalSnapshotV2;
cause?: GoalStateCause;
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@ -674,7 +684,8 @@ export type HistoryItemWithoutId =
| HistoryItemStopHookSystemMessage
| HistoryItemDoctor
| HistoryItemDiffStats
| HistoryItemGoalStatus;
| HistoryItemGoalStatus
| HistoryItemGoalState;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -719,6 +730,7 @@ export enum MessageType {
NOTIFICATION = 'notification',
DIFF_STATS = 'diff_stats',
GOAL_STATUS = 'goal_status',
GOAL_STATE = 'goal_state',
VISION_NOTICE = 'vision_notice',
}

View file

@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import { GoalPersistenceUnavailableError } from '@qwen-code/qwen-code-core';
import {
shouldDisplayGoalStateCause,
waitForGoalRuntime,
} from './goal-runtime.js';
describe('waitForGoalRuntime', () => {
it('allows Goal-less sessions when persistence is disabled', async () => {
const getGoalRuntimeReady = vi
.fn()
.mockRejectedValue(new GoalPersistenceUnavailableError());
await expect(
waitForGoalRuntime({ getGoalRuntimeReady }),
).resolves.toBeUndefined();
expect(getGoalRuntimeReady).toHaveBeenCalledTimes(1);
});
it('does not hide malformed or unsupported persisted Goal state', async () => {
const failure = new Error('unsupported Goal lifecycle record');
const getGoalRuntimeReady = vi.fn().mockRejectedValue(failure);
await expect(waitForGoalRuntime({ getGoalRuntimeReady })).rejects.toBe(
failure,
);
});
it('keeps turn and verifier bookkeeping out of scrollback', () => {
expect(shouldDisplayGoalStateCause('turn_finished')).toBe(false);
expect(shouldDisplayGoalStateCause('verifier_accept')).toBe(false);
expect(shouldDisplayGoalStateCause('verifier_reject')).toBe(true);
expect(shouldDisplayGoalStateCause('create')).toBe(true);
expect(shouldDisplayGoalStateCause('complete')).toBe(true);
expect(shouldDisplayGoalStateCause('clear')).toBe(true);
});
});

View file

@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
GoalPersistenceUnavailableError,
type Config,
type GoalStateCause,
} from '@qwen-code/qwen-code-core';
export function shouldDisplayGoalStateCause(cause: GoalStateCause): boolean {
switch (cause) {
case 'turn_finished':
case 'verifier_accept':
return false;
case 'verifier_reject':
case 'create':
case 'replace':
case 'edit':
case 'pause':
case 'resume':
case 'complete':
case 'blocked':
case 'usage_limited':
case 'clear':
case 'migrated':
return true;
default: {
const exhaustive: never = cause;
return exhaustive;
}
}
}
export async function waitForGoalRuntime(
config: Pick<Config, 'getGoalRuntimeReady'>,
): Promise<void> {
try {
await config.getGoalRuntimeReady();
} catch (error) {
if (!(error instanceof GoalPersistenceUnavailableError)) throw error;
}
}

View file

@ -84,6 +84,21 @@ describe('isSyntheticHistoryItem', () => {
),
).toBe(true);
});
it('treats v2 goal lifecycle cards as meaningful history', () => {
expect(
isSyntheticHistoryItem(
mk({
type: 'goal_state',
snapshot: {
v: 2,
activity: 'idle',
goal: null,
},
}),
),
).toBe(false);
});
});
describe('itemsAfterAreOnlySynthetic', () => {

View file

@ -92,6 +92,7 @@ export function isSyntheticHistoryItem(
case 'arena_agent_complete':
case 'arena_session_complete':
case 'goal_status':
case 'goal_state':
return false;
default: {

View file

@ -16,6 +16,7 @@ import type {
AnyDeclarativeTool,
Config,
ConversationRecord,
GoalSnapshotV2,
ResumedSessionData,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
@ -44,6 +45,98 @@ describe('resumeHistoryUtils', () => {
} as unknown as AnyDeclarativeTool;
});
it('restores lifecycle cards without per-turn Goal bookkeeping', () => {
const goal: NonNullable<GoalSnapshotV2['goal']> = {
goalId: 'goal-1',
revision: 1,
objective: 'ship the feature',
status: 'active',
evidenceCursor: { recordId: 'goal-create' },
turnCount: 0,
activeTimeMs: 0,
createdAt: 1,
updatedAt: 1,
};
const goalRecord = (
uuid: string,
cause: 'create' | 'turn_finished' | 'complete' | 'clear',
snapshotGoal: GoalSnapshotV2['goal'],
) => ({
uuid,
type: 'system' as const,
subtype: 'goal_state',
systemPayload: {
v: 2,
cause,
snapshot: { v: 2, activity: 'idle', goal: snapshotGoal },
},
});
const completeGoal = {
...goal,
status: 'complete' as const,
turnCount: 2,
lastReason: 'verified',
};
const conversation = {
messages: [
goalRecord('goal-create', 'create', goal),
goalRecord('goal-turn', 'turn_finished', { ...goal, turnCount: 1 }),
goalRecord('goal-complete', 'complete', completeGoal),
goalRecord('goal-clear', 'clear', null),
],
} as unknown as ConversationRecord;
const items = buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({}),
100,
);
expect(items).toMatchObject([
{ id: 101, type: 'goal_state', cause: 'create' },
{
id: 102,
type: 'goal_state',
cause: 'complete',
snapshot: { goal: { status: 'complete', lastReason: 'verified' } },
},
{
id: 103,
type: 'goal_state',
cause: 'clear',
snapshot: { goal: null },
},
]);
});
it('does not replay internal Goal runtime prompts as user history', () => {
const conversation = {
messages: [
{
type: 'user',
subtype: 'goal_runtime',
uuid: 'goal-runtime',
message: {
parts: [{ text: 'Continue working on the active Goal.' }],
},
},
{
type: 'user',
uuid: 'user',
message: { parts: [{ text: 'real user prompt' }] },
},
],
} as unknown as ConversationRecord;
expect(
buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({}),
100,
),
).toMatchObject([{ type: 'user', text: 'real user prompt' }]);
});
it('inserts a history-gap divider before the gap child record', () => {
// The gap child is the first reachable record; the notice sits above it and
// states the earlier history could not be recovered.

View file

@ -19,6 +19,7 @@ import type {
} from '@qwen-code/qwen-code-core';
import {
getToolResponseDisplayText,
parseGoalStateRecordPayloadV2,
stripTrailingUserPromptSubmitContextPart,
} from '@qwen-code/qwen-code-core';
import type {
@ -34,6 +35,7 @@ import {
formatHistoryGapNotice,
indexGapsByChild,
} from './history-gap-notice.js';
import { shouldDisplayGoalStateCause } from './goal-runtime.js';
/**
* Projects a plain user record to its display text.
@ -295,6 +297,21 @@ function convertToHistoryItems(
}
if (record.type === 'system') {
if (record.subtype === 'goal_state') {
const payload = parseGoalStateRecordPayloadV2(record.systemPayload);
if (payload && shouldDisplayGoalStateCause(payload.cause)) {
if (currentToolGroup.length > 0) {
items.push({ type: 'tool_group', tools: [...currentToolGroup] });
currentToolGroup = [];
}
items.push({
type: 'goal_state',
snapshot: payload.snapshot,
cause: payload.cause,
});
}
continue;
}
if (record.subtype === 'slash_command') {
// Flush any pending tool group to avoid mixing contexts.
if (currentToolGroup.length > 0) {
@ -343,6 +360,7 @@ function convertToHistoryItems(
}
switch (record.type) {
case 'user': {
if (record.subtype === 'goal_runtime') break;
// Restore notification items (background agent completions and cron fires)
if (record.subtype === 'notification' || record.subtype === 'cron') {
const payload = record.systemPayload as

View file

@ -9,6 +9,7 @@ import type { Config } from '../config/config.js';
import type { GeminiChat } from './geminiChat.js';
import {
createGoalRuntime,
MAX_GOAL_CONTINUATION_TURNS,
GoalPersistenceUnavailableError,
type GoalJournal,
type GoalRuntime,
@ -982,7 +983,7 @@ describe('GeminiClient Goal admission', () => {
expect(runtime.finishTurn).not.toHaveBeenCalled();
});
it('runs 150 runtime-scheduled Goal turns without recursive or session budgets', async () => {
it('runs runtime-scheduled Goal turns within the continuation budget without session budgets', async () => {
const { client, config } = setupGoalClient();
const goalJournal: GoalJournal = {
getTranscriptCursor: () => ({ recordId: null }),
@ -1016,7 +1017,8 @@ describe('GeminiClient Goal admission', () => {
vi.mocked(config.getGoalRuntime).mockReturnValue(runtime);
await runtime.dispatch({ action: 'create', objective: 'ship' });
for (let turn = 0; turn < 150; turn += 1) {
const turns = MAX_GOAL_CONTINUATION_TURNS - 1;
for (let turn = 0; turn < turns; turn += 1) {
const current = started[turn]!;
await drain(
client.sendMessageStream(
@ -1033,8 +1035,8 @@ describe('GeminiClient Goal admission', () => {
);
}
expect(started).toHaveLength(151);
expect(turnMocks.run).toHaveBeenCalledTimes(150);
expect(started).toHaveLength(turns + 1);
expect(turnMocks.run).toHaveBeenCalledTimes(turns);
expect(client['sessionTurnCount']).toBe(0);
});
});

View file

@ -2134,6 +2134,40 @@ describe('CoreToolScheduler', () => {
]);
});
it('propagates a tool turn-termination boundary to the host', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'proposal recorded',
returnDisplay: 'proposal recorded',
terminateTurn: true,
});
const toolsByName = new Map<string, MockTool>([
['update_goal', new MockTool({ name: 'update_goal', execute })],
]);
const { scheduler, onAllToolCallsComplete } =
createSchedulerForLegacyToolTests({ toolsByName });
await scheduler.schedule(
[
{
callId: 'goal-complete-1',
name: 'update_goal',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-goal',
},
],
new AbortController().signal,
);
const completedCall = (
onAllToolCallsComplete.mock.calls[0][0] as ToolCall[]
)[0];
expect(completedCall.status).toBe('success');
if (completedCall.status === 'success') {
expect(completedCall.response.terminateTurn).toBe(true);
}
});
it('does not dedupe requests with empty callIds in one batch', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'result',

View file

@ -4805,6 +4805,7 @@ export class CoreToolScheduler {
: 'modelOverride' in toolResult
? { modelOverride: toolResult.modelOverride }
: {}),
...(toolResult.terminateTurn ? { terminateTurn: true } : {}),
...(processedImages.visionBridgeNotice !== undefined
? { visionBridgeNotice: processedImages.visionBridgeNotice }
: {}),

View file

@ -148,6 +148,7 @@ export interface ToolCallResponseInfo {
contentLength?: number;
persistedOutputFiles?: string[];
modelOverride?: string;
terminateTurn?: boolean;
visionBridgeNotice?: string;
artifacts?: ToolArtifact[];
}

View file

@ -88,6 +88,28 @@ describe('goal reducer', () => {
});
});
it('clears lastReason when editing the objective', () => {
const previous = goalRecord({
goalId: 'g-1',
revision: 2,
lastReason: 'stale verifier rejection',
});
const next = reduceGoalControl(previous, {
request: {
action: 'edit',
objective: 'updated objective',
expectedGoalId: 'g-1',
expectedRevision: 2,
},
now: 300,
nextGoalId: 'unused',
cursor: { recordId: 'r-300' },
});
expect(next?.lastReason).toBeUndefined();
expect(next?.objective).toBe('updated objective');
});
it('creates a trimmed active goal only when no goal exists', () => {
const next = reduceGoalControl(null, {
request: { action: 'create', objective: ' ship ' },
@ -221,6 +243,29 @@ describe('goal reducer', () => {
},
);
it('resets the continuation turn budget when resuming an exhausted goal', () => {
const resumed = reduceGoalControl(
goalRecord({ status: 'usage_limited', revision: 4, turnCount: 50 }),
{
request: {
action: 'resume',
expectedGoalId: 'g-1',
expectedRevision: 4,
},
now: 200,
nextGoalId: 'unused',
cursor: { recordId: 'r-200' },
},
);
expect(resumed).toMatchObject({
status: 'active',
revision: 4,
turnCount: 0,
evidenceCursor: { recordId: 'r-100' },
});
});
it('rejects an unsupported control action instead of resuming', () => {
expect(() =>
reduceGoalControl(goalRecord({ status: 'paused' }), {

View file

@ -96,6 +96,7 @@ export function reduceGoalControl(
revision: current.revision + 1,
objective: normalizeObjective(request.objective, snapshotOf(current)),
evidenceCursor: copyCursor(transition.cursor),
lastReason: undefined,
});
}
@ -124,7 +125,13 @@ export function reduceGoalControl(
if (request.action !== 'resume') {
return assertNever(request, snapshotOf(current));
}
return transitionGoal(current, transition.now, { status: 'active' });
// An explicit resume re-authorizes autonomous continuation, so it grants a
// fresh turn budget; keeping the exhausted count would report `active` and
// immediately re-transition to `usage_limited` without running a turn.
return transitionGoal(current, transition.now, {
status: 'active',
turnCount: 0,
});
}
export function reduceGoalTurnFinished(

View file

@ -12,6 +12,7 @@ import type {
} from './goal-protocol.js';
import {
createGoalRuntime,
MAX_GOAL_CONTINUATION_TURNS,
type GoalJournal,
type GoalTurnHost,
} from './goal-runtime.js';
@ -30,7 +31,8 @@ function journal(): GoalJournal {
}
describe('Goal runtime host integration', () => {
it('keeps 150 sequential automatic admissions independent', async () => {
it('keeps sequential automatic admissions independent within the turn budget', async () => {
const turns = MAX_GOAL_CONTINUATION_TURNS - 1;
const started: GoalTurnPermit[] = [];
const host: GoalTurnHost = {
startGoalTurn: vi.fn(async ({ permit }) => {
@ -42,17 +44,17 @@ describe('Goal runtime host integration', () => {
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'ship' });
for (let turn = 0; turn < 150; turn += 1) {
for (let turn = 0; turn < turns; turn += 1) {
const permit = started[turn];
expect(permit).toBeDefined();
await runtime.finishTurn(permit!);
}
expect(started).toHaveLength(151);
expect(new Set(started.map(({ turnId }) => turnId)).size).toBe(151);
expect(started).toHaveLength(turns + 1);
expect(new Set(started.map(({ turnId }) => turnId)).size).toBe(turns + 1);
expect(runtime.getSnapshot()).toMatchObject({
activity: 'running',
goal: { status: 'active', turnCount: 150 },
goal: { status: 'active', turnCount: turns },
});
});

View file

@ -18,6 +18,7 @@ import {
import {
createGoalRuntime,
GoalPersistenceUnavailableError,
MAX_GOAL_CONTINUATION_TURNS,
type GoalEvidenceSource,
type GoalJournal,
type GoalTurnHost,
@ -923,6 +924,114 @@ describe('goal runtime', () => {
expect(observed[0]?.activity).toBe('running');
});
it('transitions to usage_limited after exceeding the continuation turn budget', async () => {
const journal = fakeGoalJournal();
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal });
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'loop forever' });
// Drive turns up to the budget cap.
for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS; i++) {
const permit = host.started[host.started.length - 1];
expect(permit).toBeDefined();
await runtime.finishTurn(permit);
}
// Allow the async usage_limited transition to settle.
await vi.waitFor(() =>
expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'),
);
expect(runtime.getSnapshot().goal?.lastReason).toContain(
String(MAX_GOAL_CONTINUATION_TURNS),
);
expect(journal.appended.at(-1)?.cause).toBe('usage_limited');
});
it('resumes a budget-exhausted goal into a fresh turn instead of re-limiting', async () => {
const journal = fakeGoalJournal();
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal });
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'loop forever' });
for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS; i++) {
const permit = host.started[host.started.length - 1];
expect(permit).toBeDefined();
await runtime.finishTurn(permit);
}
await vi.waitFor(() =>
expect(runtime.getSnapshot().goal?.status).toBe('usage_limited'),
);
const goal = runtime.getSnapshot().goal!;
const startedBeforeResume = host.started.length;
const response = await runtime.dispatch({
action: 'resume',
expectedGoalId: goal.goalId,
expectedRevision: goal.revision,
});
// The reported outcome must match the settled outcome: resume grants a
// fresh budget and starts a continuation turn rather than reporting
// `active` and immediately re-transitioning to `usage_limited`.
expect(response.snapshot.goal?.status).toBe('active');
expect(response.snapshot.goal?.turnCount).toBe(0);
await vi.waitFor(() =>
expect(host.started.length).toBe(startedBeforeResume + 1),
);
expect(runtime.getSnapshot().goal?.status).toBe('active');
});
it('does not usage-limit a replacement goal created during budget-exhaustion persistence', async () => {
const appendReached = deferred<void>();
const appendGate = deferred<void>();
let blockNext = false;
const journal = fakeGoalJournal({
beforeAppend: async () => {
if (!blockNext) return;
blockNext = false;
appendReached.resolve();
await appendGate.promise;
},
});
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal });
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'loop forever' });
for (let i = 0; i < MAX_GOAL_CONTINUATION_TURNS - 1; i++) {
const permit = host.started[host.started.length - 1];
expect(permit).toBeDefined();
await runtime.finishTurn(permit);
}
const goalId = runtime.getSnapshot().goal!.goalId;
const revision = runtime.getSnapshot().goal!.revision;
blockNext = true;
const lastPermit = host.started[host.started.length - 1];
const finishing = runtime.finishTurn(lastPermit);
await appendReached.promise;
const replacing = runtime.dispatch({
action: 'replace',
objective: 'fresh start',
expectedGoalId: goalId,
expectedRevision: revision,
});
appendGate.resolve();
await Promise.all([finishing, replacing]);
await new Promise((resolve) => setImmediate(resolve));
expect(runtime.getSnapshot().goal?.status).toBe('active');
expect(runtime.getSnapshot().goal?.objective).toBe('fresh start');
expect(
journal.appended.map((p) => p.cause).filter((c) => c === 'usage_limited'),
).toHaveLength(0);
});
it('returns a bounded catalog without exposing full evidence content', async () => {
const journal = fakeGoalJournal();
let records: readonly RuntimeRecord[] = [];

View file

@ -43,6 +43,7 @@ import {
export const GOAL_RUNTIME_DISPOSED_MESSAGE = 'Goal runtime has been disposed';
export const STALE_GOAL_TURN_MESSAGE = 'Goal turn permit is no longer valid';
export const MAX_GOAL_CONTINUATION_TURNS = 50;
export interface GoalJournal {
getTranscriptCursor(): TranscriptCursor;
@ -303,6 +304,39 @@ export function createGoalRuntime(
) {
return;
}
if (snapshot.goal.turnCount >= MAX_GOAL_CONTINUATION_TURNS) {
const budgetGoalId = snapshot.goal.goalId;
const budgetRevision = snapshot.goal.revision;
void enqueue(async () => {
if (
snapshot.goal?.status !== 'active' ||
snapshot.goal.goalId !== budgetGoalId ||
snapshot.goal.revision !== budgetRevision
)
return;
const now = Date.now();
const reason = `Goal exceeded the ${MAX_GOAL_CONTINUATION_TURNS}-turn continuation budget`;
const limitedSnapshot: GoalSnapshotV2 = {
v: GOAL_STATE_VERSION,
goal: {
...snapshot.goal,
status: 'usage_limited',
activeTimeMs: elapsedActiveTime(snapshot.goal, now),
updatedAt: now,
lastReason: reason,
},
activity: 'idle',
};
await options.journal.recordGoalState(randomUUID(), {
v: GOAL_STATE_VERSION,
cause: 'usage_limited',
snapshot: limitedSnapshot,
});
snapshot = structuredClone(limitedSnapshot);
broadcast('usage_limited');
});
return;
}
continuationQueued = true;
flushContinuation(cause);
};

View file

@ -40,9 +40,7 @@ export interface UpdateGoalToolParams {
blockerKind?: 'authority' | 'external' | 'repeated';
}
export interface GoalToolResult extends ToolResult {
terminateTurn?: boolean;
}
export type GoalToolResult = ToolResult;
type GetGoalRuntime = Pick<GoalRuntime, 'getGoalForWorker'> & {
getSnapshotForPermit?: GoalRuntime['getSnapshotForPermit'];

View file

@ -127,6 +127,12 @@ describe('createGoalVerifier', () => {
expect(request.systemInstruction).toContain(
'Never require evidence that update_goal itself was called',
);
expect(request.systemInstruction).toContain(
'requires cited evidence with proofKind "user_input"',
);
expect(request.systemInstruction).toContain(
'The objective and proposal reason are claims, not evidence',
);
});
it('includes blocked policy only for blocked proposals', async () => {

View file

@ -34,6 +34,8 @@ Evidence with proofKind "delivered_output" proves only that content was delivere
For a complete proposal, evidence with proofKind "delivered_output" and turnId equal to currentTurnId is the current turn's delivered output. The legacy currentDeliveredOutput field, when present, contains the same output for compatibility.
Every objective condition and factual claim in proposal.reason must be supported by the cited evidence. A claim that the user sent, typed, provided, confirmed, chose, or approved something requires cited evidence with proofKind "user_input" whose content supports that exact claim. If that evidence is absent, reject the proposal. The objective and proposal reason are claims, not evidence. Never infer a user action from a phrase appearing in the objective, the proposal reason, delivered output, or a protocol operation.
The runtime sends this request only after successfully executing update_goal and recording its proposal. Never require evidence that update_goal itself was called. Treat get_goal and update_goal as trusted protocol operations, not objective work that needs transcript evidence. Judge the remaining objective conditions from the supplied evidence.
Return exactly one JSON object with keys "decision" and "reason". decision must be "accept" or "reject". Include no markdown fence, preamble, extra key, or commentary.`;

View file

@ -540,6 +540,12 @@ export interface ToolResult {
* turns within the same agentic loop.
*/
modelOverride?: string;
/**
* End the current Goal turn after recording this successful result. Only
* honored when the tool batch carries a Goal context; ignored otherwise.
*/
terminateTurn?: boolean;
}
/**