mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-03 05:14:52 +00:00
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:
parent
933d78a089
commit
4aa7d1ec0b
47 changed files with 5654 additions and 1199 deletions
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -46,4 +46,6 @@ export const ICON = {
|
|||
STAR: `★${_VS15}`,
|
||||
RADIO_FILLED: `◉${_VS15}`,
|
||||
CIRCLE_LEFT_HALF: `◐${_VS15}`,
|
||||
CHECK: `✓${_VS15}`,
|
||||
CROSS: `✖${_VS15}`,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
|||
43
packages/cli/src/ui/utils/goal-runtime.test.ts
Normal file
43
packages/cli/src/ui/utils/goal-runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
packages/cli/src/ui/utils/goal-runtime.ts
Normal file
45
packages/cli/src/ui/utils/goal-runtime.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export function isSyntheticHistoryItem(
|
|||
case 'arena_agent_complete':
|
||||
case 'arena_session_complete':
|
||||
case 'goal_status':
|
||||
case 'goal_state':
|
||||
return false;
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -4805,6 +4805,7 @@ export class CoreToolScheduler {
|
|||
: 'modelOverride' in toolResult
|
||||
? { modelOverride: toolResult.modelOverride }
|
||||
: {}),
|
||||
...(toolResult.terminateTurn ? { terminateTurn: true } : {}),
|
||||
...(processedImages.visionBridgeNotice !== undefined
|
||||
? { visionBridgeNotice: processedImages.visionBridgeNotice }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ export interface ToolCallResponseInfo {
|
|||
contentLength?: number;
|
||||
persistedOutputFiles?: string[];
|
||||
modelOverride?: string;
|
||||
terminateTurn?: boolean;
|
||||
visionBridgeNotice?: string;
|
||||
artifacts?: ToolArtifact[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }), {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue