From 4aa7d1ec0b20f1dd2013ce55fea08c191072aba9 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 1 Aug 2026 19:21:53 +0800 Subject: [PATCH] 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 ` 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 Co-authored-by: Qwen Code Bot Co-authored-by: Qwen Code Autofix Co-authored-by: qwen-code-dev-bot Co-authored-by: qwen-code-dev-bot Co-authored-by: qwen-code-ci-bot --- packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + packages/cli/src/nonInteractiveCliCommands.ts | 7 + packages/cli/src/ui/AppContainer.test.tsx | 495 +++++++ packages/cli/src/ui/AppContainer.tsx | 392 +++++- .../cli/src/ui/commands/goalCommand.test.ts | 792 ++++++----- packages/cli/src/ui/commands/goalCommand.ts | 435 +++--- packages/cli/src/ui/commands/types.ts | 18 + packages/cli/src/ui/components/Footer.tsx | 15 +- .../cli/src/ui/components/GoalPill.test.tsx | 211 ++- packages/cli/src/ui/components/GoalPill.tsx | 177 ++- .../ui/components/HistoryItemDisplay.test.tsx | 33 + .../src/ui/components/HistoryItemDisplay.tsx | 7 + .../messages/GoalStatusMessage.test.tsx | 94 ++ .../components/messages/GoalStatusMessage.tsx | 167 ++- packages/cli/src/ui/constants.ts | 2 + .../ui/hooks/slashCommandProcessor.test.ts | 164 ++- .../cli/src/ui/hooks/slashCommandProcessor.ts | 30 + .../cli/src/ui/hooks/useBranchCommand.test.ts | 49 +- packages/cli/src/ui/hooks/useBranchCommand.ts | 21 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1170 ++++++++++++++++- packages/cli/src/ui/hooks/useGeminiStream.ts | 1002 ++++++++++++-- .../cli/src/ui/hooks/useMessageQueue.test.ts | 678 ++++++++-- packages/cli/src/ui/hooks/useMessageQueue.ts | 325 +++-- .../cli/src/ui/hooks/useResumeCommand.test.ts | 47 +- packages/cli/src/ui/hooks/useResumeCommand.ts | 17 +- packages/cli/src/ui/types.ts | 14 +- .../cli/src/ui/utils/goal-runtime.test.ts | 43 + packages/cli/src/ui/utils/goal-runtime.ts | 45 + .../cli/src/ui/utils/historyUtils.test.ts | 15 + packages/cli/src/ui/utils/historyUtils.ts | 1 + .../src/ui/utils/resumeHistoryUtils.test.ts | 93 ++ .../cli/src/ui/utils/resumeHistoryUtils.ts | 18 + packages/core/src/core/client-goal.test.ts | 10 +- .../core/src/core/coreToolScheduler.test.ts | 34 + packages/core/src/core/coreToolScheduler.ts | 1 + packages/core/src/core/turn.ts | 1 + packages/core/src/goals/goal-reducer.test.ts | 45 + packages/core/src/goals/goal-reducer.ts | 9 +- .../goals/goal-runtime.integration.test.ts | 12 +- packages/core/src/goals/goal-runtime.test.ts | 109 ++ packages/core/src/goals/goal-runtime.ts | 34 + packages/core/src/goals/goal-tools.ts | 4 +- packages/core/src/goals/goal-verifier.test.ts | 6 + packages/core/src/goals/goal-verifier.ts | 2 + packages/core/src/tools/tools.ts | 6 + 47 files changed, 5654 insertions(+), 1199 deletions(-) create mode 100644 packages/cli/src/ui/utils/goal-runtime.test.ts create mode 100644 packages/cli/src/ui/utils/goal-runtime.ts diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 99f3a9d283..4c3f2197ee 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 88aa16ddfb..23c7db4460 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -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.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index c5545464bb..eae304cc88 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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.': diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 6bcd669188..2a0129c2aa 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -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; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 06b20fe166..6a66052271 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -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( + , + ); + + 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; + 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); + + 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; + 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['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((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; + 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; + 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( + , + ); + 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( + , + ); + + 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 = '\nmanaged context\n\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(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b229e30b11..fe870e0e9c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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['submitQuery']; + submissionInFlightRef: RefObject; + 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[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(null); + const restoredSubmissionRef = useRef | null>(null); const submittedPromptProvenanceUnavailableRef = useRef(false); const setBufferTextRef = useRef< ReturnType['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( + null, + ); const midTurnRestoreRef = useRef<((messages: string[]) => void) | null>(null); + const goalQueueRef = useRef< + | (Pick< + UseMessageQueueReturn, + | 'peekNextUserBatchKey' + | 'claimDirectUserAdmission' + | 'claimGoalTurn' + | 'hasQueuedUserMessages' + | 'getPendingSubmissionCount' + > & { + waitForReservationSettlement: () => Promise; + submissionInFlightRef: RefObject; + onSubmissionSettled: () => void; + }) + | null + >(null); + const goalReservationSettlementRef = useRef>(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'); diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index e9a7b1027f..fba17b218f 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -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 { +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(); 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> = {}, +): 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), - }, - }); - 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), - }, - }); - 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).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).mock.calls - .length; - const result = await goalCommand.action!(ctx, 'clear'); - expect(result).toBeUndefined(); - const after = (ctx.ui.addItem as ReturnType).mock.calls - .length; - expect(after).toBe(before + 1); - const lastItem = (ctx.ui.addItem as ReturnType).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).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; - 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) 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; - 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; - - 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.', + }); }); }); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index c3d157ecf7..a67efd5aae 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -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 { + 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 ` (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 = { + 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: '[ | clear]', + argumentHint: + '[ | set | edit | pause | resume | clear]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, args: string, - ): Promise => { - 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 => { + 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 ` (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 = { - 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 = { - 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; }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 8c5141c8e2..d82680e7bd 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 1ece51c859..b902f0732f 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -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: }); + const goalState = useFooterGoalState(); + if (isLiveGoalSnapshot(goalState)) { + rightItems.push({ + key: 'goal', + node: , + }); } const cronTaskCount = useFooterCronTaskCount(); if (cronTaskCount > 0) { diff --git a/packages/cli/src/ui/components/GoalPill.test.tsx b/packages/cli/src/ui/components/GoalPill.test.tsx index a4dd5e8a7a..7aaee245f8 100644 --- a/packages/cli/src/ui/components/GoalPill.test.tsx +++ b/packages/cli/src/ui/components/GoalPill.test.tsx @@ -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['status'], + activity: GoalSnapshotV2['activity'] = 'idle', + overrides: Partial> = {}, +): 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(, { - config: makeConfig(), - }); - expect(lastFrame()).toBe(''); +function renderPill(props: GoalPillProps) { + return render(); +} + +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 ? : ; +}; + +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(, { - 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(); + + 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 = () => ( + + + + ); + 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(); }); }); diff --git a/packages/cli/src/ui/components/GoalPill.tsx b/packages/cli/src/ui/components/GoalPill.tsx index 53caf0cb17..36326d1437 100644 --- a/packages/cli/src/ui/components/GoalPill.tsx +++ b/packages/cli/src/ui/components/GoalPill.tsx @@ -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(() => - 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 = ({ 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 ◎ /goal active{suffix}; + return ( + + {visible.icon} /goal {visible.label} + {suffix} + + ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 1bdee56009..d524aa47da 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -124,6 +124,39 @@ describe('', () => { 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( + , + ); + + 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, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 43d5504214..1364fd4897 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 = ({ lastReason={itemForDisplay.lastReason} /> )} + {itemForDisplay.type === 'goal_state' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx b/packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx index d2ea821fa2..d18289c1af 100644 --- a/packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/GoalStatusMessage.test.tsx @@ -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['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('', () => { it('is wrapped in React.memo to avoid unnecessary scrollback rerenders', () => { expect( @@ -50,4 +75,73 @@ describe('', () => { 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( + , + ); + + 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(); + + 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}`); + } + }); }); diff --git a/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx b/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx index f2d932cbb9..2550a851b0 100644 --- a/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx +++ b/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx @@ -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 = ({ - kind, - condition, - iterations, - durationMs, - lastReason, +const GoalStateCard: React.FC = ({ + 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 ( + + + {ICON.CIRCLE_EMPTY} + + Goal cleared + + ); + } + + 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 ( + + + {lifecycle.prefix} + + + + {lifecycle.title} + {subtitle ? ( + · {subtitle} + ) : null} + + + + Goal: + + + {goal.objective} + + + {reason ? ( + + Reason: {reason} + + ) : null} + + + ); +}; + +const GoalStatusMessageInternal: React.FC = (props) => { + if (props.snapshot) return ; + const { kind, condition, iterations, durationMs, lastReason } = props; if (kind === 'checking') { const reason = lastReason?.trim(); return ( @@ -67,8 +183,6 @@ const GoalStatusMessageInternal: React.FC = ({ 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 = ({ }; case 'achieved': return { - prefix: '✓', + prefix: ICON.CHECK, prefixColor: theme.status.success, title: 'Goal achieved', }; @@ -88,7 +202,7 @@ const GoalStatusMessageInternal: React.FC = ({ }; 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 = ({ 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 = ({ · {subtitle} ) : null} - {/* 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. */} Goal: @@ -138,17 +252,6 @@ const GoalStatusMessageInternal: React.FC = ({ {condition} - {/* `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 `` (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() ? ( Last check: {lastReason.trim()} diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 7fbf498a96..df76e24d4b 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -46,4 +46,6 @@ export const ICON = { STAR: `★${_VS15}`, RADIO_FILLED: `◉${_VS15}`, CIRCLE_LEFT_HALF: `◐${_VS15}`, + CHECK: `✓${_VS15}`, + CROSS: `✖${_VS15}`, } as const; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 5679de653c..1a4dcc2185 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -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 = { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d6d693d611..68922cba96 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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': diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index e431f96cde..e53797ceb9 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -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; let flush: ReturnType; let startNewSessionConfig: ReturnType; + let getGoalRuntimeReady: ReturnType; let startNewSessionUI: ReturnType; let findSessionTitlesByPrefix: ReturnType; let clearItems: ReturnType; @@ -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), ); }); diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index 983ab767ea..7fd09d608d 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -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. diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index aa89113af4..cd34365da5 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -23,6 +23,7 @@ import type { EditorType, GeminiClient, AnyToolInvocation, + GoalTurnPermit, SteerInput, } from '@qwen-code/qwen-code-core'; import { @@ -43,6 +44,7 @@ import type { HistoryItem, SlashCommandProcessorResult } from '../types.js'; import { MessageType, StreamingState, ToolCallStatus } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; +import type { DirectUserAdmission, QueuedGoalTurn } from './useMessageQueue.js'; // --- MOCKS --- const mockSendMessageStream = vi @@ -351,6 +353,7 @@ describe('useGeminiStream', () => { availableTerminalHeightRef?: { current: number }, onCancelSubmit: Parameters[15] = () => {}, logger?: Parameters[20], + goalQueueRef?: Parameters[24], ) => { let currentToolCalls = initialToolCalls; const setToolCalls = (newToolCalls: TrackedToolCall[]) => { @@ -407,6 +410,9 @@ describe('useGeminiStream', () => { undefined, // midTurnDrainRef logger, availableTerminalHeightRef, + undefined, // terminalWidthRef + undefined, // midTurnRestoreRef + goalQueueRef, ); }, { @@ -434,6 +440,163 @@ describe('useGeminiStream', () => { }; }; + it('sends a hidden Goal turn without user admission side effects', async () => { + const permit = { + goalId: 'goal-1', + revision: 3, + turnId: 'turn-automatic', + }; + const goal: QueuedGoalTurn = { + kind: 'goal', + permit, + turnKey: 'goal-runtime:turn-automatic', + continuationContext: 'continue from the last accepted evidence', + verifierFeedback: 'show the final verification result', + }; + const peekNextUserBatchKey = vi.fn((goalTurnActive?: boolean) => + goalTurnActive ? undefined : 'message-queue:next-user', + ); + const { result, mockSendMessageStream: streamMock } = renderTestHook( + [], + undefined, + undefined, + undefined, + undefined, + { + current: { peekNextUserBatchKey }, + }, + ); + + await act(async () => { + await result.current.submitQuery( + goal.continuationContext, + SendMessageType.Goal, + 'prompt-id-goal', + { goal }, + ); + }); + + expect(streamMock).toHaveBeenCalledWith( + [ + 'Continue working on the active Goal.', + 'Use get_goal for the authoritative objective and evidence state.', + "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", + 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', + 'This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something.', + 'A phrase mentioned in the objective or this prompt is not evidence that the user supplied it.', + `Verifier feedback: ${goal.verifierFeedback}`, + ].join('\n'), + expect.any(AbortSignal), + 'prompt-id-goal', + expect.objectContaining({ + type: SendMessageType.Goal, + goalPermit: permit, + goalTurnKey: goal.turnKey, + goalSignal: expect.any(AbortSignal), + getQueuedGoalTurnKey: expect.any(Function), + }), + ); + const options = streamMock.mock.calls[0][3] as { + goalSignal: AbortSignal; + getQueuedGoalTurnKey: () => string | undefined; + }; + expect(options.goalSignal).not.toBe(streamMock.mock.calls[0][1]); + // A Goal turn must not reserve the next turn for a held plain message. + expect(options.getQueuedGoalTurnKey()).toBeUndefined(); + expect(peekNextUserBatchKey).toHaveBeenCalledWith(true); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: MessageType.USER }), + expect.any(Number), + ); + expect(mockStartNewPrompt).not.toHaveBeenCalled(); + expect(MockedUserPromptEvent).not.toHaveBeenCalled(); + }); + + it('does not copy the objective into a synthetic Goal turn', async () => { + const goal: QueuedGoalTurn = { + kind: 'goal', + permit: { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-stop-token', + }, + turnKey: 'goal-runtime:turn-stop-token', + continuationContext: 'Wait until the user types SECRET_STOP_TOKEN', + }; + const { result, mockSendMessageStream: streamMock } = renderTestHook([]); + + await act(async () => { + await result.current.submitQuery( + goal.continuationContext, + SendMessageType.Goal, + 'prompt-id-goal-stop-token', + { goal }, + ); + }); + + const syntheticPrompt = streamMock.mock.calls[0]?.[0]; + expect(syntheticPrompt).not.toContain('SECRET_STOP_TOKEN'); + expect(syntheticPrompt).toContain('contains no new real user input'); + }); + + it('claims a Goal only after direct user input becomes model-facing', async () => { + const goal: QueuedGoalTurn = { + kind: 'goal', + permit: { + goalId: 'goal-direct-user', + revision: 4, + turnId: 'turn-direct-user', + }, + turnKey: 'goal-runtime:turn-direct-user', + continuationContext: 'the user arrived first', + }; + const admission: DirectUserAdmission = { + turnKey: 'message-queue:direct-user', + goal, + }; + const claimDirectUserAdmission = vi.fn(() => admission); + const { result, mockSendMessageStream: streamMock } = renderTestHook( + [], + undefined, + undefined, + undefined, + undefined, + { + current: { + peekNextUserBatchKey: () => undefined, + claimDirectUserAdmission, + }, + }, + ); + mockHandleSlashCommand.mockResolvedValueOnce({ type: 'handled' }); + + await act(async () => { + await result.current.submitQuery('/goal pause'); + }); + + expect(claimDirectUserAdmission).not.toHaveBeenCalled(); + expect(streamMock).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.submitQuery('user goes first'); + }); + + expect(claimDirectUserAdmission).toHaveBeenCalledTimes(1); + expect(streamMock).toHaveBeenCalledWith( + 'user goes first', + expect.any(AbortSignal), + expect.any(String), + expect.objectContaining({ + type: SendMessageType.UserQuery, + goalPermit: goal.permit, + goalTurnKey: goal.turnKey, + goalSignal: expect.any(AbortSignal), + goalOrigin: 'user', + }), + ); + }); + it('queues background shell terminal notifications for the model loop', async () => { const { mockSendMessageStream } = renderTestHook(); const displayText = 'Background shell "npm test" completed.'; @@ -1430,6 +1593,693 @@ describe('useGeminiStream', () => { ); }); + it('forwards one exact Goal context across a ToolResult batch', async () => { + const permit: GoalTurnPermit = { + goalId: 'goal-tools', + revision: 5, + turnId: 'turn-tools', + }; + const makeCompletedTool = (callId: string): TrackedCompletedToolCall => + ({ + request: { + callId, + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-goal-tools', + goalContext: permit, + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId, + responseParts: [{ text: `${callId} response` }], + errorType: undefined, + }, + tool: { displayName: 'MockTool' }, + invocation: { + getDescription: () => callId, + } as unknown as AnyToolInvocation, + }) as unknown as TrackedCompletedToolCall; + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await capturedOnComplete?.([ + makeCompletedTool('goal-tool-1'), + makeCompletedTool('goal-tool-2'), + ]); + }); + + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + const options = mockSendMessageStream.mock.calls[0][3] as { + goalPermit: GoalTurnPermit; + goalTurnKey: string; + goalSignal: AbortSignal; + }; + expect(options).toMatchObject({ + type: SendMessageType.ToolResult, + goalPermit: permit, + goalTurnKey: 'goal-runtime:turn-tools', + goalSignal: expect.any(AbortSignal), + }); + expect(options.goalPermit).not.toBe(permit); + }); + + it('ignores a deduplicated tool without Goal context when forwarding a fresh Goal result', async () => { + const permit: GoalTurnPermit = { + goalId: 'goal-dedup', + revision: 2, + turnId: 'turn-dedup', + }; + const makeCompletedTool = ( + callId: string, + goalContext?: GoalTurnPermit, + ): TrackedCompletedToolCall => + ({ + request: { + callId, + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-goal-dedup', + ...(goalContext ? { goalContext } : {}), + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId, + responseParts: [{ text: `${callId} response` }], + errorType: undefined, + }, + tool: { displayName: 'MockTool' }, + invocation: { + getDescription: () => callId, + } as unknown as AnyToolInvocation, + }) as unknown as TrackedCompletedToolCall; + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + const client = new MockedGeminiClientClass(mockConfig); + client.getHistoryFunctionResponseIds = vi + .fn() + .mockReturnValue(new Set(['deduplicated-tool'])); + renderHook(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await capturedOnComplete?.([ + makeCompletedTool('deduplicated-tool'), + makeCompletedTool('fresh-goal-tool', permit), + ]); + }); + + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + expect(mockSendMessageStream.mock.calls[0][3]).toMatchObject({ + type: SendMessageType.ToolResult, + goalPermit: permit, + goalTurnKey: 'goal-runtime:turn-dedup', + }); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ + text: 'ToolResult batch has mixed Goal contexts', + }), + expect.any(Number), + ); + }); + + it('fails close when a ToolResult batch is missing the active Goal context', async () => { + const permit: GoalTurnPermit = { + goalId: 'goal-missing', + revision: 3, + turnId: 'turn-missing', + }; + const dispatch = vi.fn().mockResolvedValue(undefined); + const finishTurn = vi.fn().mockResolvedValue(undefined); + const flush = vi.fn().mockResolvedValue(undefined); + const activeSnapshot = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: permit.goalId, + revision: permit.revision, + objective: 'keep going', + status: 'active' as const, + evidenceCursor: { recordId: 'record-missing' }, + turnCount: 1, + activeTimeMs: 5, + createdAt: 1, + updatedAt: 2, + }, + }; + const runtime = { + permitForTurn: vi.fn(() => permit), + dispatch, + finishTurn, + getSnapshot: vi.fn(() => activeSnapshot), + } as unknown as ReturnType; + mockConfig.getGoalRuntime = vi.fn(() => runtime); + mockConfig.getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); + mockConfig.getChatRecordingService = vi.fn().mockReturnValue({ flush }); + const makeCompletedTool = ( + callId: string, + goalContext?: GoalTurnPermit, + ): TrackedCompletedToolCall => + ({ + request: { + callId, + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-goal-missing', + ...(goalContext ? { goalContext } : {}), + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId, + responseParts: [{ text: `${callId} response` }], + errorType: undefined, + }, + tool: { displayName: 'MockTool' }, + invocation: { + getDescription: () => callId, + } as unknown as AnyToolInvocation, + }) as unknown as TrackedCompletedToolCall; + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + // The first batch carries the Goal context and binds the active turn; its + // stream schedules a continuation tool so the binding survives the turn. + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { callId: 'cont-tool', name: 'testTool', args: {} }, + }; + })(), + ); + await act(async () => { + await capturedOnComplete?.([makeCompletedTool('setup-tool', permit)]); + }); + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + expect(mockScheduleToolCalls).toHaveBeenCalled(); + + // The continuation batch drops the Goal context while the turn is still + // active, which must fail close instead of reaching the model. + mockAddItem.mockClear(); + await act(async () => { + await capturedOnComplete?.([makeCompletedTool('cont-tool')]); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'ToolResult batch is missing the active Goal context', + }, + expect.any(Number), + ); + }); + expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['cont-tool']); + expect(dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(finishTurn).toHaveBeenCalledWith(permit); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('fails close when a ToolResult batch has a stale Goal context', async () => { + const permit: GoalTurnPermit = { + goalId: 'goal-stale', + revision: 1, + turnId: 'turn-stale', + }; + const stalePermit: GoalTurnPermit = { ...permit, revision: 2 }; + const dispatch = vi.fn().mockResolvedValue(undefined); + const finishTurn = vi.fn().mockResolvedValue(undefined); + const flush = vi.fn().mockResolvedValue(undefined); + const activeSnapshot = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: permit.goalId, + revision: permit.revision, + objective: 'keep going', + status: 'active' as const, + evidenceCursor: { recordId: 'record-stale' }, + turnCount: 1, + activeTimeMs: 5, + createdAt: 1, + updatedAt: 2, + }, + }; + const runtime = { + permitForTurn: vi.fn(() => permit), + dispatch, + finishTurn, + getSnapshot: vi.fn(() => activeSnapshot), + } as unknown as ReturnType; + mockConfig.getGoalRuntime = vi.fn(() => runtime); + mockConfig.getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); + mockConfig.getChatRecordingService = vi.fn().mockReturnValue({ flush }); + const makeCompletedTool = ( + callId: string, + goalContext?: GoalTurnPermit, + ): TrackedCompletedToolCall => + ({ + request: { + callId, + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-goal-stale', + ...(goalContext ? { goalContext } : {}), + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId, + responseParts: [{ text: `${callId} response` }], + errorType: undefined, + }, + tool: { displayName: 'MockTool' }, + invocation: { + getDescription: () => callId, + } as unknown as AnyToolInvocation, + }) as unknown as TrackedCompletedToolCall; + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + // The first batch binds the active turn at revision 1; its stream schedules + // a continuation tool so the binding survives the turn. + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { callId: 'cont-tool', name: 'testTool', args: {} }, + }; + })(), + ); + await act(async () => { + await capturedOnComplete?.([makeCompletedTool('setup-tool', permit)]); + }); + await waitFor(() => { + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + expect(mockScheduleToolCalls).toHaveBeenCalled(); + + // A revision bump (e.g. an edit) lands before the continuation batch + // completes, so it carries a stale permit and must fail close. + mockAddItem.mockClear(); + await act(async () => { + await capturedOnComplete?.([makeCompletedTool('cont-tool', stalePermit)]); + }); + + await waitFor(() => { + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'ToolResult batch has a stale Goal context', + }, + expect.any(Number), + ); + }); + expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['cont-tool']); + expect(dispatch).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: permit.goalId, + expectedRevision: permit.revision, + }); + expect(finishTurn).toHaveBeenCalledWith(permit); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('finishes a Goal turn without another model call after update_goal', async () => { + const permit: GoalTurnPermit = { + goalId: 'goal-complete', + revision: 1, + turnId: 'turn-complete', + }; + const flush = vi.fn().mockResolvedValue(undefined); + const finishTurn = vi.fn().mockResolvedValue(undefined); + const completedSnapshot = { + v: 2 as const, + activity: 'idle' as const, + goal: { + goalId: permit.goalId, + revision: permit.revision, + objective: 'finish without another call', + status: 'complete' as const, + evidenceCursor: { recordId: 'record-complete' }, + turnCount: 1, + activeTimeMs: 20, + createdAt: 1, + updatedAt: 2, + }, + }; + const runtime = { + permitForTurn: vi.fn(() => permit), + finishTurn, + getSnapshot: vi.fn(() => completedSnapshot), + } as unknown as ReturnType; + mockConfig.getGoalRuntime = vi.fn(() => runtime); + mockConfig.getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); + mockConfig.getChatRecordingService = vi.fn().mockReturnValue({ flush }); + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + const client = new MockedGeminiClientClass(mockConfig); + renderHook(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + const responseParts: Part[] = [ + { + functionResponse: { + id: 'update-goal-1', + name: 'update_goal', + response: { output: 'proposal recorded' }, + }, + }, + ]; + + await act(async () => { + await capturedOnComplete?.([ + { + request: { + callId: 'update-goal-1', + name: 'update_goal', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-goal-complete', + goalContext: permit, + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId: 'update-goal-1', + responseParts, + errorType: undefined, + terminateTurn: true, + }, + tool: { displayName: 'UpdateGoal' }, + invocation: { + getDescription: () => 'complete Goal', + } as unknown as AnyToolInvocation, + } as TrackedCompletedToolCall, + ]); + }); + + expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['update-goal-1']); + expect(client.addHistory).toHaveBeenCalledWith({ + role: 'user', + parts: responseParts, + }); + expect(flush).toHaveBeenCalledOnce(); + expect(finishTurn).toHaveBeenCalledWith(permit); + expect(mockAddItem).toHaveBeenCalledWith( + { + type: 'goal_state', + snapshot: completedSnapshot, + cause: 'complete', + }, + expect.any(Number), + ); + expect(mockSendMessageStream).not.toHaveBeenCalled(); + }); + + it('records tool results with goalContext during a Goal turn', async () => { + const recordToolResult = vi.fn(); + const permit: GoalTurnPermit = { + goalId: 'goal-record', + revision: 1, + turnId: 'turn-record', + }; + const runtime = { + permitForTurn: vi.fn(() => permit), + finishTurn: vi.fn().mockResolvedValue(undefined), + getSnapshot: vi.fn(() => ({ + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: permit.goalId, + revision: permit.revision, + objective: 'record test', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 0, + createdAt: 1, + updatedAt: 2, + }, + })), + } as unknown as ReturnType; + mockConfig.getGoalRuntime = vi.fn(() => runtime); + mockConfig.getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); + mockConfig.getChatRecordingService = vi + .fn() + .mockReturnValue({ recordToolResult }); + let capturedOnComplete: + | ((completedTools: TrackedToolCall[]) => Promise) + | null = null; + mockUseReactToolScheduler.mockImplementation((onComplete) => { + capturedOnComplete = onComplete; + return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted]; + }); + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'done', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { totalTokenCount: 1 }, + }, + }; + })(), + ); + + const client = new MockedGeminiClientClass(mockConfig); + renderHook(() => + useGeminiStream( + client, + [], + mockAddItem, + mockConfig, + true, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + await act(async () => { + await capturedOnComplete?.([ + { + request: { + callId: 'shell-goal-1', + name: 'shell', + args: { command: 'echo hi' }, + isClientInitiated: false, + prompt_id: 'prompt-goal-record', + goalContext: permit, + }, + status: 'success', + responseSubmittedToGemini: false, + response: { + callId: 'shell-goal-1', + responseParts: [ + { + functionResponse: { + id: 'shell-goal-1', + name: 'shell', + response: { output: 'hi' }, + }, + }, + ], + resultDisplay: 'hi', + error: undefined, + errorType: undefined, + }, + tool: { displayName: 'Shell' }, + invocation: { + getDescription: () => 'echo hi', + } as unknown as AnyToolInvocation, + } as TrackedCompletedToolCall, + ]); + }); + + await waitFor(() => { + expect(recordToolResult).toHaveBeenCalledOnce(); + }); + expect(recordToolResult).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ callId: 'shell-goal-1', status: 'success' }), + { + goalContext: { + goalId: 'goal-record', + revision: 1, + turnId: 'turn-record', + }, + }, + ); + }); + it('waits for a background agent when its launch exhausts capacity', async () => { const responseParts: Part[] = [ { @@ -1834,7 +2684,7 @@ describe('useGeminiStream', () => { expect(restoreSteer).not.toHaveBeenCalled(); }); - it('steers with the replacement prompt from a queued /goal command', async () => { + it('executes a queued /goal command without steering its prompt into the model', async () => { const goalCommand = '/goal replace the active goal'; const replacementPrompt = [{ text: 'new goal instruction' }]; const restoreSteer = vi.fn(); @@ -1892,12 +2742,11 @@ describe('useGeminiStream', () => { }); expect(mockHandleSlashCommand).toHaveBeenCalledWith(goalCommand); - expect(steerInput?.parts).toEqual(replacementPrompt); - steerInput?.restore(); + expect(steerInput).toBeUndefined(); expect(restoreSteer).not.toHaveBeenCalled(); }); - it('uses only the final prompt from queued goal replacements', async () => { + it('keeps ordinary queued messages while Goal controls stay out of model input', async () => { mockHandleSlashCommand .mockResolvedValueOnce({ type: 'submit_prompt', @@ -1957,8 +2806,6 @@ describe('useGeminiStream', () => { expect(steerInput?.parts).toEqual([ { text: 'plain before final goal' }, { text: '\n\n' }, - { text: 'final goal instruction' }, - { text: '\n\n' }, { text: 'plain after final goal' }, ]); }); @@ -6856,7 +7703,7 @@ describe('useGeminiStream', () => { '', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -6875,7 +7722,7 @@ describe('useGeminiStream', () => { '// This is a line comment', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -6894,7 +7741,7 @@ describe('useGeminiStream', () => { '/* This is a block comment */', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -7275,6 +8122,105 @@ describe('useGeminiStream', () => { }); expect(capturedRuntimeView).toBeUndefined(); }); + + it('defers a cron notification while a Goal owns queued user messages, then delivers it exactly once', async () => { + let queuedUserMessages = true; + let pendingSubmissionCount = 2; + const goalQueueRef = { + current: { + hasQueuedUserMessages: vi.fn(() => queuedUserMessages), + getPendingSubmissionCount: vi.fn(() => pendingSubmissionCount), + claimGoalTurn: vi.fn(() => undefined), + }, + }; + let snapshot: { goal: { status: string } | null; activity: string } = { + goal: { status: 'active' }, + activity: 'running', + }; + const runtime = { + getSnapshot: vi.fn(() => snapshot), + subscribe: vi.fn(() => vi.fn()), + } as unknown as ReturnType; + mockConfig.getGoalRuntime = vi.fn(() => runtime); + + let schedulerCallback: + | ((job: { prompt: string; cronExpr?: string }) => void) + | null = null; + const scheduler = { + hasPendingWork: true, + enableDurable: vi.fn().mockResolvedValue(undefined), + start: vi.fn((callback: (job: { prompt: string }) => void) => { + schedulerCallback = callback; + }), + stop: vi.fn(), + getExitSummary: vi.fn().mockReturnValue(undefined), + }; + (mockConfig.isCronEnabled as unknown as Mock).mockReturnValue(true); + (mockConfig.getCronScheduler as unknown as Mock).mockReturnValue( + scheduler, + ); + + const { rerender, client } = renderTestHook( + [], + undefined, + undefined, + undefined, + undefined, + goalQueueRef as never, + ); + await waitFor(() => expect(schedulerCallback).not.toBeNull()); + mockSendMessageStream.mockClear(); + mockAddItem.mockClear(); + + // Phase 1: a Goal owns the turn and user messages are queued, so the + // gate reports not-ready and the cron notification must stay queued — + // neither submitted nor rendered as a history item. + act(() => { + schedulerCallback?.({ + prompt: 'check the build', + cronExpr: '* * * * *', + }); + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(mockSendMessageStream).not.toHaveBeenCalled(); + expect(mockAddItem).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'notification' }), + expect.any(Number), + ); + + // Phase 2: the user messages drain and the Goal completes, so the gate + // admits the turn. The single queued notification is delivered once. + queuedUserMessages = false; + snapshot = { goal: null, activity: 'idle' }; + pendingSubmissionCount = 1; + mockSendMessageStream.mockClear(); + mockAddItem.mockClear(); + rerender({ + client, + history: [], + addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'], + config: mockConfig, + onDebugMessage: mockOnDebugMessage, + handleSlashCommand: mockHandleSlashCommand as unknown as ( + cmd: PartListUnion, + ) => Promise, + shellModeActive: false, + loadedSettings: mockLoadedSettings, + toolCalls: [], + }); + + await waitFor(() => + expect(mockSendMessageStream).toHaveBeenCalledOnce(), + ); + expect(mockSendMessageStream.mock.calls[0][3]).toMatchObject({ + type: SendMessageType.Cron, + }); + expect( + mockAddItem.mock.calls.filter( + ([item]) => (item as { type?: string }).type === 'notification', + ), + ).toHaveLength(1); + }); }); }); @@ -9314,6 +10260,43 @@ describe('useGeminiStream', () => { }); }); + it('omits the Ctrl+Y retry hint for stream errors during a Goal turn', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'Goal stream error' } }, + }; + })(), + ); + + const goal: QueuedGoalTurn = { + kind: 'goal', + permit: { goalId: 'goal-err', revision: 1, turnId: 'turn-err' }, + turnKey: 'goal-runtime:turn-err', + continuationContext: 'continue toward the objective', + }; + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery( + goal.continuationContext, + SendMessageType.Goal, + 'prompt-id-goal-error', + { goal }, + ); + }); + + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeDefined(); + expect((errorItem as { hint?: string })?.hint).toBeUndefined(); + }); + }); + it('should clear stale countdown error when retry succeeds without a second Retry event', async () => { vi.useFakeTimers(); try { @@ -9394,6 +10377,88 @@ describe('useGeminiStream', () => { } }); + it('should not wipe a Goal turn terminal error in post-stream cleanup', async () => { + (mockConfig as any).getHookSystem = vi.fn(() => null); + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'Goal terminal error' } }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const goal: QueuedGoalTurn = { + kind: 'goal', + permit: { goalId: 'goal-cleanup', revision: 1, turnId: 'turn-cleanup' }, + turnKey: 'goal-runtime:turn-cleanup', + continuationContext: 'continue toward the objective', + }; + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery( + goal.continuationContext, + SendMessageType.Goal, + 'prompt-id-goal-cleanup', + { goal }, + ); + }); + + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeDefined(); + expect((errorItem as { hint?: string })?.hint).toBeUndefined(); + }); + }); + + it('fires onDeliveryFailed (not onDelivered) when a Goal turn hits a stream error', async () => { + (mockConfig as any).getHookSystem = vi.fn(() => null); + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'Goal terminal error' } }, + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const goal: QueuedGoalTurn = { + kind: 'goal', + permit: { goalId: 'goal-deliver', revision: 1, turnId: 'turn-deliver' }, + turnKey: 'goal-runtime:turn-deliver', + continuationContext: 'continue toward the objective', + }; + + const onDelivered = vi.fn(); + const onDeliveryFailed = vi.fn(); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery( + goal.continuationContext, + SendMessageType.Goal, + 'prompt-id-goal-deliver', + { goal, onDelivered, onDeliveryFailed }, + ); + }); + + await waitFor(() => expect(onDeliveryFailed).toHaveBeenCalled()); + expect(onDelivered).not.toHaveBeenCalled(); + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], @@ -10088,7 +11153,7 @@ describe('useGeminiStream', () => { 'First query', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); // Verify only the first query was added to history @@ -10140,14 +11205,14 @@ describe('useGeminiStream', () => { 'First query', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); expect(mockSendMessageStream).toHaveBeenNthCalledWith( 2, 'Second query', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); @@ -10170,7 +11235,7 @@ describe('useGeminiStream', () => { 'Second query', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -10525,7 +11590,7 @@ describe('useGeminiStream', () => { }); describe('StopHookLoop Event', () => { - it('syncs active_goal events into the active goal store', async () => { + it('ignores legacy active_goal events after the Goal runtime cutover', async () => { const activeGoal = { condition: 'finish the refactor', iterations: 1, @@ -10557,11 +11622,8 @@ describe('useGeminiStream', () => { await result.current.submitQuery('continue goal'); }); - expect(mockSetActiveGoal).toHaveBeenCalledWith( - 'test-session-id', - activeGoal, - ); - expect(mockClearActiveGoal).toHaveBeenCalledWith('test-session-id'); + expect(mockSetActiveGoal).not.toHaveBeenCalled(); + expect(mockClearActiveGoal).not.toHaveBeenCalled(); }); it('skips redundant active_goal store updates', async () => { @@ -10643,7 +11705,7 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Idle); }); - it('renders active goal StopHookLoop as a goal_status checking card', async () => { + it('keeps StopHookLoop as legacy history after the Goal runtime cutover', async () => { const recordSlashCommand = vi.fn(); mockConfig.getChatRecordingService = vi.fn().mockReturnValue({ recordSlashCommand, @@ -10678,32 +11740,14 @@ describe('useGeminiStream', () => { await waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ - type: 'goal_status', - kind: 'checking', - condition: 'finish the refactor', - iterations: 7, - lastReason: 'not enough evidence yet', + type: 'stop_hook_loop', + iterationCount: 2, + reasons: ['controlled continuation prompt'], }), expect.any(Number), ); }); - expect(recordSlashCommand).toHaveBeenCalledWith({ - phase: 'result', - rawCommand: '/goal', - outputHistoryItems: [ - expect.objectContaining({ - type: 'goal_status', - kind: 'checking', - condition: 'finish the refactor', - iterations: 7, - lastReason: 'not enough evidence yet', - }), - ], - }); - expect(mockAddItem).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'stop_hook_loop' }), - expect.any(Number), - ); + expect(recordSlashCommand).not.toHaveBeenCalled(); }); it('should move pending history item before adding StopHookLoop event', async () => { @@ -10787,6 +11831,48 @@ describe('useGeminiStream', () => { }); describe('HookSystemMessage Event', () => { + it('commits buffered content before a displayed Goal state', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Content, + value: 'Final Goal output', + }; + yield { + type: ServerGeminiEventType.GoalState, + cause: 'complete' as const, + value: { + v: 2 as const, + activity: 'idle' as const, + goal: { + goalId: 'goal-order', + revision: 1, + objective: 'deliver output', + status: 'complete' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 1, + createdAt: 1, + updatedAt: 2, + }, + }, + }; + })(), + ); + const { result } = renderTestHook(); + await act(async () => { + await result.current.submitQuery('finish the Goal'); + }); + const contentIndex = mockAddItem.mock.calls.findIndex( + ([item]) => item.type === 'gemini' && item.text === 'Final Goal output', + ); + const goalIndex = mockAddItem.mock.calls.findIndex( + ([item]) => item.type === 'goal_state' && item.cause === 'complete', + ); + expect(contentIndex).toBeGreaterThanOrEqual(0); + expect(goalIndex).toBeGreaterThan(contentIndex); + }); + it('should handle HookSystemMessage event and add stop_hook_system_message history item', async () => { mockSendMessageStream.mockReturnValue( (async function* () { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 927268ce62..bc14ccf98f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -27,7 +27,7 @@ import { type ToolCallRequestInfo, type ToolCallResponseInfo, type GeminiErrorEventValue, - type ActiveGoal, + type GoalTurnPermit, type SteerInput, GeminiEventType as ServerGeminiEventType, SendMessageType, @@ -61,10 +61,7 @@ import { clampInlineMediaPart, splitImageParts, generateToolUseSummary, - getActiveGoal, - activeGoalEquals, - setActiveGoal, - clearActiveGoal, + goalRequiresExactPermit, createDuplicateProviderToolCallResponse, markDuplicateProviderToolCallResponseSent, findRepeatedDuplicateProviderToolCall, @@ -75,7 +72,6 @@ import { import { type Part, type PartListUnion, FinishReason } from '@google/genai'; import type { HistoryItem, - HistoryItemGoalStatus, HistoryItemWithoutId, HistoryItemToolGroup, HistoryItemGemini, @@ -118,10 +114,14 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import type { LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; import { useDualOutput } from '../../dualOutput/DualOutputContext.js'; -import { recordGoalStatusItem } from '../utils/restoreGoal.js'; +import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js'; import { sanitizeDisplayText } from '../../utils/extension-mention.js'; import process from 'node:process'; -import { GOAL_COMMAND_RE } from './useMessageQueue.js'; +import { + GOAL_COMMAND_RE, + type DirectUserAdmission, + type QueuedGoalTurn, +} from './useMessageQueue.js'; import { classifyApiError } from '../../utils/classify-api-error.js'; import { cleanupReviewWorktreeLeases } from '../../services/review-worktree-lease.js'; @@ -175,6 +175,37 @@ interface ResolvedSteerMessages { restoreMessages: string[]; } +interface GoalTurnBinding { + permit: GoalTurnPermit; + turnKey: string; + controller: AbortController; + origin: 'runtime' | 'user'; +} + +type GoalTurnAdmission = Omit; + +function sameGoalPermit(left: GoalTurnPermit, right: GoalTurnPermit): boolean { + return ( + left.goalId === right.goalId && + left.revision === right.revision && + left.turnId === right.turnId + ); +} + +function sharedGoalPermit( + contexts: Array, +): GoalTurnPermit | undefined { + const first = contexts[0]; + if (contexts.every((context) => context === undefined)) return undefined; + if ( + !first || + contexts.some((context) => !context || !sameGoalPermit(first, context)) + ) { + throw new Error('ToolResult batch has mixed Goal contexts'); + } + return { ...first }; +} + /** * Pull the assistant's most recent visible text from the UI history. Used as * an intent prefix for tool-use summary generation so the summarizer knows @@ -310,6 +341,11 @@ enum StreamProcessingStatus { Error, } +interface StreamProcessingResult { + status: StreamProcessingStatus; + scheduledToolContinuation: boolean; +} + const EDIT_TOOL_NAMES = new Set([ ToolNames.EDIT, 'replace', // legacy alias, may still arrive from older providers @@ -404,6 +440,14 @@ export interface CancelSubmitInfo { * when the consumer's React history snapshot is still stale. */ turnProducedMeaningfulContent: boolean; + /** + * True when the cancelled turn was a Goal continuation turn. Such a turn + * appends a synthetic continuation prompt to the chat history but, unlike a + * UserQuery, adds no UI user item, so the cancel handler's auto-restore + * branch bails before its orphan strip runs. The handler uses this flag to + * strip that prompt so it can't merge into the user's next real message. + */ + wasGoalTurn: boolean; } /** @@ -432,7 +476,9 @@ export const useGeminiStream = ( setShellInputFocused: (value: boolean) => void, terminalWidth: number, terminalHeight: number, - midTurnDrainRef?: React.RefObject<(() => string[]) | null>, + midTurnDrainRef?: React.RefObject< + ((includeDeferred?: boolean, goalTurnActive?: boolean) => string[]) | null + >, logger?: Logger | null, // Live content-area height (terminal minus composer/header). Used to bound the // pending item's rendered height so it commits to before it can grow @@ -443,12 +489,144 @@ export const useGeminiStream = ( // both dimensions consistently across a mid-stream resize. terminalWidthRef?: React.RefObject, midTurnRestoreRef?: React.RefObject<((messages: string[]) => void) | null>, + goalQueueRef?: React.RefObject<{ + peekNextUserBatchKey: (goalTurnActive?: boolean) => string | undefined; + claimDirectUserAdmission?: () => DirectUserAdmission; + claimGoalTurn?: () => QueuedGoalTurn | undefined; + hasQueuedUserMessages?: () => boolean; + getPendingSubmissionCount?: () => number; + waitForReservationSettlement?: () => Promise; + submissionInFlightRef?: React.RefObject; + onSubmissionSettled?: () => void; + } | null>, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); + const activeGoalTurnRef = useRef(null); + const activeGoalAdmissionRef = useRef(null); + const goalTurnBindingsRef = useRef(new Map()); + const bindGoalTurn = useCallback( + ( + permit: GoalTurnPermit, + turnKey: string, + origin: GoalTurnBinding['origin'], + controller = new AbortController(), + ): GoalTurnBinding => { + const existing = goalTurnBindingsRef.current.get(permit.turnId); + if ( + existing && + existing.turnKey === turnKey && + sameGoalPermit(existing.permit, permit) && + !existing.controller.signal.aborted + ) { + activeGoalTurnRef.current = existing; + activeGoalAdmissionRef.current = existing; + return existing; + } + const binding: GoalTurnBinding = { + permit: { ...permit }, + turnKey, + controller, + origin, + }; + goalTurnBindingsRef.current.set(permit.turnId, binding); + activeGoalTurnRef.current = binding; + activeGoalAdmissionRef.current = binding; + return binding; + }, + [], + ); + const releaseGoalTurn = useCallback((binding: GoalTurnBinding) => { + if (goalTurnBindingsRef.current.get(binding.permit.turnId) === binding) { + goalTurnBindingsRef.current.delete(binding.permit.turnId); + } + if (activeGoalTurnRef.current === binding) { + activeGoalTurnRef.current = null; + } + if ( + activeGoalAdmissionRef.current?.controller === binding.controller && + activeGoalAdmissionRef.current.turnKey === binding.turnKey + ) { + activeGoalAdmissionRef.current = null; + } + }, []); + const failClosedGoalTurn = useCallback( + async (binding: GoalTurnBinding, reason: string): Promise => { + if (!binding.controller.signal.aborted) { + binding.controller.abort(reason); + } + + try { + const runtime = await config.getGoalRuntimeReady(); + const admittedPermit = runtime.permitForTurn(binding.turnKey); + if ( + !admittedPermit || + !sameGoalPermit(admittedPermit, binding.permit) + ) { + return; + } + + if (runtime.getSnapshot().goal?.status === 'active') { + try { + await runtime.dispatch({ + action: 'pause', + expectedGoalId: binding.permit.goalId, + expectedRevision: binding.permit.revision, + }); + } catch (error) { + debugLogger.warn('Failed to pause invalid Goal tool batch', error); + } + } + + try { + await config.getChatRecordingService()?.flush(); + } catch (error) { + debugLogger.warn('Failed to flush invalid Goal tool batch', error); + } + + const currentPermit = runtime.permitForTurn(binding.turnKey); + if (currentPermit && sameGoalPermit(currentPermit, binding.permit)) { + await runtime.finishTurn(binding.permit); + } + } catch (error) { + debugLogger.warn('Failed to close invalid Goal tool batch', error); + } finally { + releaseGoalTurn(binding); + } + }, + [config, releaseGoalTurn], + ); + const releaseUndeliveredGoalTurn = useCallback( + async (turnKey: string | undefined): Promise => { + if (!turnKey) return; + try { + const runtime = await config.getGoalRuntimeReady(); + await runtime.releaseTurn(turnKey); + } catch (error) { + debugLogger.warn( + `Failed to release undelivered Goal turn ${turnKey}`, + error, + ); + } + }, + [config], + ); const flushBufferedStreamEventsRef = useRef void>>(new Set()); const turnCancelledRef = useRef(false); const isSubmittingQueryRef = useRef(false); + const submissionLeaseGenerationRef = useRef(0); + const setSubmissionInFlight = useCallback( + (inFlight: boolean) => { + const changed = isSubmittingQueryRef.current !== inFlight; + isSubmittingQueryRef.current = inFlight; + const sharedRef = goalQueueRef?.current?.submissionInFlightRef; + if (sharedRef) sharedRef.current = inFlight; + if (changed && !inFlight) { + goalQueueRef?.current?.onSubmissionSettled?.(); + } + }, + [goalQueueRef], + ); const lastPromptRef = useRef(null); // Records the USER history item that THIS turn's prepareQueryForGemini // added (if any). Reset to null at the start of every turn (including @@ -479,6 +657,7 @@ export const useGeminiStream = ( // alongside lastTurnUserItemRef. const turnSawContentEventRef = useRef(false); const lastPromptErroredRef = useRef(false); + const goalTerminalErrorRef = useRef(false); // Wrapper around addItem that attaches timestamp to gemini items for display. // Only 'gemini' (new assistant turn) gets a timestamp; 'gemini_content' @@ -815,7 +994,8 @@ export const useGeminiStream = ( // would race with stream chunks that haven't re-rendered yet. const pendingItemAtCancel = pendingHistoryItemRef.current; turnCancelledRef.current = true; - isSubmittingQueryRef.current = false; + submissionLeaseGenerationRef.current += 1; + setSubmissionInFlight(false); abortControllerRef.current?.abort(); // Aborting a tick-in-flight ends any self-paced /loop: drop pending loop // wakeups so the loop doesn't resume after the cancelled tick. Only clears @@ -889,6 +1069,7 @@ export const useGeminiStream = ( lastTurnUserItem: lastTurnUserItemRef.current, canUndoLastLoggedUserMessage: canUndoLastLoggedUserMessageRef.current, turnProducedMeaningfulContent: turnSawContentEventRef.current, + wasGoalTurn: activeGoalTurnRef.current !== null, }); } finally { setIsResponding(false); @@ -905,6 +1086,7 @@ export const useGeminiStream = ( clearRetryCountdown, config, getPromptCount, + setSubmissionInFlight, ]); const applyVisionBridgeIfNeeded = useCallback( @@ -1653,8 +1835,16 @@ export const useGeminiStream = ( ); const handleErrorEvent = useCallback( - (eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => { - lastPromptErroredRef.current = true; + ( + eventValue: GeminiErrorEventValue, + userMessageTimestamp: number, + submitType: SendMessageType, + ) => { + if (submitType !== SendMessageType.Goal) { + lastPromptErroredRef.current = true; + } else { + goalTerminalErrorRef.current = true; + } // Persist any streamed reasoning (collapsed) above the error. commitPendingThought(userMessageTimestamp); if (pendingHistoryItemRef.current) { @@ -1672,7 +1862,10 @@ export const useGeminiStream = ( ); if (!isShowingAutoRetry) { - const retryHint = t('Press Ctrl+Y to retry'); + const retryHint = + submitType !== SendMessageType.Goal + ? t('Press Ctrl+Y to retry') + : undefined; // Store error with hint as a pending item (not in history). // This allows the hint to be removed when the user retries with Ctrl+Y, // since pending items are in the dynamic rendering area (not ). @@ -1919,27 +2112,6 @@ export const useGeminiStream = ( commitItem(pendingHistoryItemRef.current, userMessageTimestamp); setPendingHistoryItem(null); } - // When the active loop is driven by `/goal`, replace the generic - // "Ran N stop hooks" chip with a goal-aware `goal_status` - // `kind:'checking'` item. A not-met judge is the expected outcome of a - // continuation, not a hook failure. - const activeGoal = getActiveGoal(config.getSessionId()); - if (activeGoal && activeGoal.condition) { - const item: HistoryItemGoalStatus = { - type: MessageType.GOAL_STATUS, - kind: 'checking', - condition: activeGoal.condition, - iterations: activeGoal.iterations, - // Carried so a transcript truncated past its `set` card can still - // restore the goal's original start time. - setAt: activeGoal.setAt, - lastReason: - activeGoal.lastReason ?? value.reasons[value.reasons.length - 1], - }; - addItem(item, userMessageTimestamp); - recordGoalStatusItem(config, item); - return; - } addItem( { type: 'stop_hook_loop', @@ -1950,26 +2122,7 @@ export const useGeminiStream = ( userMessageTimestamp, ); }, - [addItem, commitItem, config, pendingHistoryItemRef, setPendingHistoryItem], - ); - - const handleActiveGoalEvent = useCallback( - (activeGoal: ActiveGoal | null) => { - const sessionId = config.getSessionId(); - const currentActiveGoal = getActiveGoal(sessionId); - if (activeGoal) { - if (activeGoalEquals(currentActiveGoal, activeGoal)) { - return; - } - setActiveGoal(sessionId, activeGoal); - return; - } - if (!currentActiveGoal) { - return; - } - clearActiveGoal(sessionId); - }, - [config], + [addItem, commitItem, pendingHistoryItemRef, setPendingHistoryItem], ); const processGeminiStreamEvents = useCallback( @@ -1977,9 +2130,12 @@ export const useGeminiStream = ( stream: AsyncIterable, userMessageTimestamp: number, signal: AbortSignal, - ): Promise => { + submitType: SendMessageType, + turnAdmission?: GoalTurnAdmission, + ): Promise => { let geminiMessageBuffer = ''; let thoughtBuffer = ''; + let scheduledToolContinuation = false; const toolCallRequests: ToolCallRequestInfo[] = []; const bufferedEvents: BufferedStreamEvent[] = []; let flushTimer: ReturnType | null = null; @@ -2108,6 +2264,14 @@ export const useGeminiStream = ( commitPendingThought(userMessageTimestamp); thoughtBuffer = ''; setThought((prev) => (prev ? null : prev)); + if (event.value.goalContext && turnAdmission) { + bindGoalTurn( + event.value.goalContext, + turnAdmission.turnKey, + turnAdmission.origin, + turnAdmission.controller, + ); + } toolCallRequests.push(event.value); // Count tool call args JSON toward token estimation. try { @@ -2121,10 +2285,13 @@ export const useGeminiStream = ( flushBufferedStreamEvents(); toolCallRequests.length = 0; handleUserCancelledEvent(userMessageTimestamp); - return StreamProcessingStatus.UserCancelled; + return { + status: StreamProcessingStatus.UserCancelled, + scheduledToolContinuation: false, + }; case ServerGeminiEventType.Error: flushBufferedStreamEvents(); - handleErrorEvent(event.value, userMessageTimestamp); + handleErrorEvent(event.value, userMessageTimestamp, submitType); break; case ServerGeminiEventType.ChatCompressed: flushBufferedStreamEvents(); @@ -2266,9 +2433,26 @@ export const useGeminiStream = ( handleStopHookLoopEvent(event.value, userMessageTimestamp); break; case ServerGeminiEventType.ActiveGoal: - handleActiveGoalEvent(event.value); break; case ServerGeminiEventType.GoalState: + if (event.cause && shouldDisplayGoalStateCause(event.cause)) { + flushBufferedStreamEvents(); + if (pendingHistoryItemRef.current) { + commitItem( + pendingHistoryItemRef.current, + userMessageTimestamp, + ); + setPendingHistoryItem(null); + } + addItem( + { + type: 'goal_state', + snapshot: event.value, + cause: event.cause, + }, + userMessageTimestamp, + ); + } break; default: { // enforces exhaustive switch-case @@ -2320,7 +2504,10 @@ export const useGeminiStream = ( `[processGeminiStreamEvents] Dropping batch after repeated duplicate provider tool-call id: ${repeatedDuplicateRequest.providerCallId} (tool: ${repeatedDuplicateRequest.name})`, ); loopDetectedRef.current = true; - return StreamProcessingStatus.Completed; + return { + status: StreamProcessingStatus.Completed, + scheduledToolContinuation: false, + }; } for (const request of toolCallRequests) { @@ -2373,6 +2560,7 @@ export const useGeminiStream = ( } if (executableToolCallRequests.length > 0) { + scheduledToolContinuation = true; scheduleToolCalls( executableToolCallRequests, signal, @@ -2380,7 +2568,10 @@ export const useGeminiStream = ( ); } } - return StreamProcessingStatus.Completed; + return { + status: StreamProcessingStatus.Completed, + scheduledToolContinuation, + }; }, [ handleContentEvent, @@ -2403,7 +2594,7 @@ export const useGeminiStream = ( setPendingHistoryItem, handleUserPromptSubmitBlockedEvent, handleStopHookLoopEvent, - handleActiveGoalEvent, + bindGoalTurn, addItem, commitItem, dualOutput, @@ -2422,7 +2613,6 @@ export const useGeminiStream = ( sideEffects: Array<() => void>; }> = []; const restoreMessages: string[] = []; - let pendingGoalSegmentIndex: number | undefined; const timestamp = Date.now(); for (let index = 0; index < messages.length; index += 1) { @@ -2433,23 +2623,7 @@ export const useGeminiStream = ( const message = messages[index]; if (GOAL_COMMAND_RE.test(message)) { - const activeGoalBeforeCommand = getActiveGoal(config.getSessionId()); - const result = await handleSlashCommand(message); - const activeGoalAfterCommand = getActiveGoal(config.getSessionId()); - if (result && result.type === 'submit_prompt') { - if (pendingGoalSegmentIndex !== undefined) { - resolvedSegments[pendingGoalSegmentIndex] = []; - } - pendingGoalSegmentIndex = resolvedSegments.length; - resolvedSegments.push(normalizePartList(result.content)); - } else if ( - activeGoalBeforeCommand?.hookId !== activeGoalAfterCommand?.hookId - ) { - if (pendingGoalSegmentIndex !== undefined) { - resolvedSegments[pendingGoalSegmentIndex] = []; - pendingGoalSegmentIndex = undefined; - } - } + await handleSlashCommand(message); continue; } @@ -2584,9 +2758,13 @@ export const useGeminiStream = ( accept: () => { for (const { message, parts, sideEffects } of resolvedForRecording) { for (const sideEffect of sideEffects) sideEffect(); - config - .getChatRecordingService?.() - ?.recordMidTurnUserMessage(parts, message); + const recorder = config.getChatRecordingService?.(); + const goalPermit = activeGoalTurnRef.current?.permit; + if (goalPermit) { + recorder?.recordMidTurnUserMessage(parts, message, goalPermit); + } else { + recorder?.recordMidTurnUserMessage(parts, message); + } addItem( { type: MessageType.USER, @@ -2652,7 +2830,11 @@ export const useGeminiStream = ( const drainSteerAtBoundary = useCallback( async (signal: AbortSignal): Promise => { - const messages = midTurnDrainRef?.current?.() ?? []; + const messages = + midTurnDrainRef?.current?.( + false, + Boolean(activeGoalAdmissionRef.current), + ) ?? []; if (messages.length === 0) return undefined; return resolveDrainedSteerMessages(messages, signal); }, @@ -2669,15 +2851,41 @@ export const useGeminiStream = ( todoWorkChainId?: string; onDelivered?: () => void; onDeliveryFailed?: () => void; + onAdmissionFailed?: () => void; + onGoalClaimDeferred?: () => void; steerInput?: SteerInput; submittedPrompt?: string; + goal?: QueuedGoalTurn; + claimGoalTurn?: () => QueuedGoalTurn | undefined; + userAdmission?: DirectUserAdmission; + goalBinding?: GoalTurnBinding; }, ) => { const allowConcurrentBtwDuringResponse = submitType === SendMessageType.UserQuery && streamingState === StreamingState.Responding && typeof query === 'string' && - isBtwCommand(query); + isBtwCommand(query) && + !activeGoalAdmissionRef.current; + let ownsSubmissionLease = false; + let submissionLeaseGeneration: number | undefined; + const acquireSubmissionLease = () => { + if (isSubmittingQueryRef.current) return; + ownsSubmissionLease = true; + submissionLeaseGeneration = submissionLeaseGenerationRef.current + 1; + submissionLeaseGenerationRef.current = submissionLeaseGeneration; + setSubmissionInFlight(true); + }; + const releaseSubmissionLease = () => { + if (!ownsSubmissionLease) return; + ownsSubmissionLease = false; + if ( + submissionLeaseGeneration !== submissionLeaseGenerationRef.current + ) { + return; + } + setSubmissionInFlight(false); + }; const isTurnContinuation = submitType === SendMessageType.ToolResult || submitType === SendMessageType.Steer; @@ -2693,6 +2901,8 @@ export const useGeminiStream = ( !isTurnContinuation && !allowConcurrentBtwDuringResponse ) { + await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey); + metadata?.onAdmissionFailed?.(); metadata?.onDeliveryFailed?.(); return; } @@ -2703,12 +2913,14 @@ export const useGeminiStream = ( !isTurnContinuation && !allowConcurrentBtwDuringResponse ) { + await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey); + metadata?.onAdmissionFailed?.(); metadata?.onDeliveryFailed?.(); return; } // Set the flag to indicate we're now executing - isSubmittingQueryRef.current = true; + acquireSubmissionLease(); // loopDetectedRef now gates tool-call scheduling (see processGeminiStream // events), so it must reflect only this turn's state. Reset it @@ -2752,6 +2964,7 @@ export const useGeminiStream = ( if ( !isTurnContinuation && submitType !== SendMessageType.Notification && + submitType !== SendMessageType.Goal && !allowConcurrentBtwDuringResponse ) { setModelSwitchedFromQuotaError(false); @@ -2813,25 +3026,127 @@ export const useGeminiStream = ( } return promptIdContext.run(prompt_id, async () => { - const { queryToSend, shouldProceed } = - submitType === SendMessageType.Retry - ? { queryToSend: query, shouldProceed: true } - : await prepareQueryForGemini( - query, - userMessageTimestamp, - abortSignal, - prompt_id!, - submitType, - submittedPrompt, - allowConcurrentBtwDuringResponse, - ); + let queuedGoal = metadata?.goal; + let preparedQuery: { + queryToSend: PartListUnion | null; + shouldProceed: boolean; + }; + try { + preparedQuery = + submitType === SendMessageType.Goal + ? queuedGoal + ? { + queryToSend: [ + 'Continue working on the active Goal.', + 'Use get_goal for the authoritative objective and evidence state.', + "Follow the objective's requested output format exactly. Do not add progress, status, or completion commentary unless the objective asks for it.", + 'If completion depends on content delivered in this turn, deliver only that content and call get_goal in the same response before update_goal.', + 'This is a synthetic continuation turn. It contains no new real user input and cannot satisfy an objective condition that requires the user to send, confirm, choose, approve, or provide something.', + 'A phrase mentioned in the objective or this prompt is not evidence that the user supplied it.', + ...(queuedGoal.verifierFeedback + ? [`Verifier feedback: ${queuedGoal.verifierFeedback}`] + : []), + ].join('\n'), + shouldProceed: true, + } + : { queryToSend: null, shouldProceed: false } + : submitType === SendMessageType.Retry + ? { queryToSend: query, shouldProceed: true } + : await prepareQueryForGemini( + query, + userMessageTimestamp, + abortSignal, + prompt_id!, + submitType, + submittedPrompt, + allowConcurrentBtwDuringResponse, + ); + } catch (error) { + await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey); + releaseSubmissionLease(); + metadata?.onAdmissionFailed?.(); + throw error; + } + const { queryToSend, shouldProceed } = preparedQuery; if (!shouldProceed || queryToSend === null) { - isSubmittingQueryRef.current = false; + await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey); + releaseSubmissionLease(); metadata?.onDeliveryFailed?.(); return; } + await goalQueueRef?.current?.waitForReservationSettlement?.(); + + if (!queuedGoal && metadata?.claimGoalTurn) { + queuedGoal = metadata.claimGoalTurn(); + if (!queuedGoal) { + releaseSubmissionLease(); + metadata.onGoalClaimDeferred?.(); + return; + } + } + + let userAdmission: DirectUserAdmission | undefined; + if (submitType === SendMessageType.UserQuery) { + if (metadata?.userAdmission) { + const goal = + metadata.userAdmission.goal ?? + goalQueueRef?.current?.claimGoalTurn?.(); + userAdmission = { + turnKey: metadata.userAdmission.turnKey, + ...(goal ? { goal } : {}), + }; + } else { + userAdmission = + goalQueueRef?.current?.claimDirectUserAdmission?.() ?? { + turnKey: prompt_id!, + }; + } + } + const goal = queuedGoal ?? userAdmission?.goal; + let goalBinding = + metadata?.goalBinding ?? + (goal + ? bindGoalTurn( + goal.permit, + goal.turnKey, + submitType === SendMessageType.UserQuery ? 'user' : 'runtime', + ) + : undefined); + const turnKey = goalBinding?.turnKey ?? userAdmission?.turnKey; + const turnController = + goalBinding?.controller ?? + (turnKey ? new AbortController() : undefined); + const processingSignal = turnController + ? AbortSignal.any([abortSignal, turnController.signal]) + : abortSignal; + const turnAdmission = + turnKey && turnController + ? { + turnKey, + controller: turnController, + origin: goalBinding?.origin ?? ('user' as const), + } + : undefined; + if ( + turnAdmission && + !goalBinding && + submitType === SendMessageType.UserQuery && + !allowConcurrentBtwDuringResponse && + !activeGoalAdmissionRef.current + ) { + try { + if ( + config.getGoalRuntime().getSnapshot().goal?.status === 'active' + ) { + activeGoalAdmissionRef.current = turnAdmission; + } + } catch { + // Goal runtime is optional during early initialization. + } + } + // Check image format support for non-continuations if ( submitType === SendMessageType.UserQuery || @@ -2851,8 +3166,11 @@ export const useGeminiStream = ( } const finalQueryToSend = queryToSend; - lastPromptRef.current = finalQueryToSend; - lastPromptErroredRef.current = false; + goalTerminalErrorRef.current = false; + if (submitType !== SendMessageType.Goal) { + lastPromptRef.current = finalQueryToSend; + lastPromptErroredRef.current = false; + } if ( submitType === SendMessageType.UserQuery || @@ -2897,11 +3215,16 @@ export const useGeminiStream = ( } let cleanupReviewLease = false; + let keepGoalBinding = false; try { // Emit user message to dual output sidecar (if enabled). // Skip for tool-result submissions — those are emitted separately // when the tool completes. - if (dualOutput && submitType !== SendMessageType.ToolResult) { + if ( + dualOutput && + submitType !== SendMessageType.ToolResult && + submitType !== SendMessageType.Goal + ) { const rawParts = typeof finalQueryToSend === 'string' ? [finalQueryToSend] @@ -2929,19 +3252,52 @@ export const useGeminiStream = ( finalQueryToSend, abortSignal, prompt_id!, - sendOptions, + { + ...sendOptions, + ...(goalBinding + ? { + goalPermit: goalBinding.permit, + goalTurnKey: goalBinding.turnKey, + goalSignal: goalBinding.controller.signal, + goalOrigin: goalBinding.origin, + getQueuedGoalTurnKey: () => + goalQueueRef?.current?.peekNextUserBatchKey(true), + } + : userAdmission + ? { + goalTurnKey: userAdmission.turnKey, + goalSignal: turnController!.signal, + goalOrigin: 'user' as const, + getQueuedGoalTurnKey: () => + goalQueueRef?.current?.peekNextUserBatchKey(true), + } + : {}), + }, ); - const processingStatus = await processGeminiStreamEvents( + const processingResult = await processGeminiStreamEvents( stream, userMessageTimestamp, - abortSignal, + processingSignal, + submitType, + turnAdmission, ); + if ( + !goalBinding && + turnAdmission && + activeGoalTurnRef.current?.controller === + turnAdmission.controller && + activeGoalTurnRef.current.turnKey === turnAdmission.turnKey + ) { + goalBinding = activeGoalTurnRef.current; + } + keepGoalBinding = processingResult.scheduledToolContinuation; - if (processingStatus === StreamProcessingStatus.UserCancelled) { + if ( + processingResult.status === StreamProcessingStatus.UserCancelled + ) { cleanupReviewLease = true; submitPromptOnCompleteRef.current = null; - isSubmittingQueryRef.current = false; metadata?.onDeliveryFailed?.(); return; } @@ -2971,22 +3327,44 @@ export const useGeminiStream = ( ); immediateDuplicateToolResponses.responses.forEach( ({ request, response }, index) => { - config - .getChatRecordingService?.() - ?.recordToolResult?.(finalized[index].responseParts, { + const goalContext = request.goalContext; + config.getChatRecordingService?.()?.recordToolResult?.( + finalized[index].responseParts, + { callId: request.callId, status: response.error ? 'error' : 'success', resultDisplay: response.resultDisplay, error: response.error, errorType: response.errorType, - }); + }, + goalContext + ? request.name === ToolNames.GET_GOAL || + request.name === ToolNames.UPDATE_GOAL + ? { + goalContext: { ...goalContext }, + provenance: 'goal_runtime' as const, + } + : { goalContext: { ...goalContext } } + : undefined, + ); }, ); await submitQuery( responseParts, SendMessageType.ToolResult, immediateDuplicateToolResponses.promptId, + { goalBinding }, ); + if ( + goalBinding && + !turnCancelledRef.current && + !abortControllerRef.current?.signal.aborted && + goalTurnBindingsRef.current.get(goalBinding.permit.turnId) === + goalBinding && + !goalBinding.controller.signal.aborted + ) { + keepGoalBinding = true; + } } // Only clear auto-retry countdown errors (those with an active timer). // Do NOT clear static error+hint from handleErrorEvent — those should @@ -2996,12 +3374,14 @@ export const useGeminiStream = ( clearRetryCountdown(); } else if ( pendingRetryErrorItemRef.current && - !lastPromptErroredRef.current + !lastPromptErroredRef.current && + !goalTerminalErrorRef.current ) { // A countdown-originated error item lingers after the timer // expired and the retry succeeded. Clear it so it does not // stay on screen. Terminal errors (handleErrorEvent) set - // lastPromptErroredRef and are intentionally left visible. + // lastPromptErroredRef (or goalTerminalErrorRef for Goal turns) + // and are intentionally left visible. clearRetryCountdown(); } const loopDetected = loopDetectedRef.current; @@ -3011,7 +3391,7 @@ export const useGeminiStream = ( handleLoopDetectedEvent(); } - if (lastPromptErroredRef.current) { + if (lastPromptErroredRef.current || goalTerminalErrorRef.current) { metadata?.onDeliveryFailed?.(); } else { metadata?.onDelivered?.(); @@ -3053,8 +3433,13 @@ export const useGeminiStream = ( if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { - lastPromptErroredRef.current = true; - const retryHint = t('Press Ctrl+Y to retry'); + if (submitType !== SendMessageType.Goal) { + lastPromptErroredRef.current = true; + } + const retryHint = + submitType !== SendMessageType.Goal + ? t('Press Ctrl+Y to retry') + : undefined; // Store error with hint as a pending item (same as handleErrorEvent) setPendingRetryErrorItem({ type: 'error' as const, @@ -3081,7 +3466,38 @@ export const useGeminiStream = ( if (activeModelStreamsRef.current === 0) { setIsResponding(false); } - isSubmittingQueryRef.current = false; + if (goalBinding) { + let retainGoalBinding = + keepGoalBinding && !goalBinding.controller.signal.aborted; + if (retainGoalBinding) { + try { + const currentPermit = config + .getGoalRuntime() + .permitForTurn(goalBinding.turnKey); + retainGoalBinding = + currentPermit !== undefined && + sameGoalPermit(currentPermit, goalBinding.permit); + } catch { + // Tests and early initialization may not expose a ready runtime. + } + } + if (!retainGoalBinding) { + await failClosedGoalTurn( + goalBinding, + 'Goal turn ended without a valid continuation', + ); + } + } + if ( + turnAdmission && + !goalBinding && + activeGoalAdmissionRef.current?.controller === + turnAdmission.controller && + activeGoalAdmissionRef.current.turnKey === turnAdmission.turnKey + ) { + activeGoalAdmissionRef.current = null; + } + releaseSubmissionLease(); } }); }, @@ -3109,6 +3525,11 @@ export const useGeminiStream = ( dualOutput, drainSteerAtBoundary, midTurnDrainRef, + goalQueueRef, + bindGoalTurn, + failClosedGoalTurn, + releaseUndeliveredGoalTurn, + setSubmissionInFlight, ], ); @@ -3166,6 +3587,12 @@ export const useGeminiStream = ( await submitQuery(lastPrompt, SendMessageType.Retry); }, [streamingState, addItem, clearRetryCountdown, submitQuery]); + const preemptGoalTurn = useCallback((reason: string) => { + const active = activeGoalAdmissionRef.current; + if (!active || active.controller.signal.aborted) return; + active.controller.abort(reason); + }, []); + const handleApprovalModeChange = useCallback( async (newApprovalMode: ApprovalMode) => { // Auto-approve pending tool calls when switching to auto-approval modes @@ -3337,6 +3764,102 @@ export const useGeminiStream = ( !t.request.isClientInitiated && !historyCallIdsWithResponse.has(t.request.callId), ); + let toolGoalPermit: GoalTurnPermit | undefined; + const toolGoalContexts = geminiTools.map( + (toolCall) => toolCall.request.goalContext, + ); + try { + toolGoalPermit = sharedGoalPermit(toolGoalContexts); + } catch (error) { + const callIds = geminiTools.map((toolCall) => toolCall.request.callId); + markToolsAsSubmitted(callIds); + const reason = getErrorMessage(error); + const bindings = new Map(); + const active = activeGoalTurnRef.current; + if (active) { + bindings.set(active.turnKey, active); + } + for (const permit of toolGoalContexts) { + if (!permit) continue; + const existing = goalTurnBindingsRef.current.get(permit.turnId); + const binding = + existing ?? + ({ + permit: { ...permit }, + turnKey: `goal-runtime:${permit.turnId}`, + controller: new AbortController(), + origin: 'runtime', + } satisfies GoalTurnBinding); + bindings.set(binding.turnKey, binding); + } + for (const binding of bindings.values()) { + await failClosedGoalTurn(binding, reason); + } + addItem( + { + type: MessageType.ERROR, + text: reason, + }, + Date.now(), + ); + return; + } + if (!toolGoalPermit && toolGoalContexts.length > 0) { + const active = activeGoalTurnRef.current; + let activeGoalPermitValid = false; + if (active) { + try { + const runtime = config.getGoalRuntime(); + const currentPermit = runtime.permitForTurn(active.turnKey); + activeGoalPermitValid = + currentPermit !== undefined && + sameGoalPermit(currentPermit, active.permit); + } catch { + // A missing runtime means this is an ordinary non-Goal batch. + } + } + if (active && activeGoalPermitValid) { + markToolsAsSubmitted( + geminiTools.map((toolCall) => toolCall.request.callId), + ); + const reason = 'ToolResult batch is missing the active Goal context'; + await failClosedGoalTurn(active, reason); + addItem( + { + type: MessageType.ERROR, + text: reason, + }, + Date.now(), + ); + return; + } + } + let toolGoalBinding: GoalTurnBinding | undefined; + if (toolGoalPermit) { + const existing = goalTurnBindingsRef.current.get(toolGoalPermit.turnId); + if (existing && !sameGoalPermit(existing.permit, toolGoalPermit)) { + markToolsAsSubmitted( + geminiTools.map((toolCall) => toolCall.request.callId), + ); + const reason = 'ToolResult batch has a stale Goal context'; + await failClosedGoalTurn(existing, reason); + addItem( + { + type: MessageType.ERROR, + text: reason, + }, + Date.now(), + ); + return; + } + toolGoalBinding = + existing ?? + bindGoalTurn( + toolGoalPermit, + `goal-runtime:${toolGoalPermit.turnId}`, + 'runtime', + ); + } const didRefreshManagedMemory = await refreshMemoryAfterManagedWrite( config, completedAndReadyToSubmitTools.map((toolCall) => ({ @@ -3388,6 +3911,12 @@ export const useGeminiStream = ( } if (geminiTools.length === 0 && pendingDuplicateResponses.length === 0) { + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation ended without a result', + ); + } return; } @@ -3447,15 +3976,26 @@ export const useGeminiStream = ( (entry) => entry.responseParts, ); orderedResponses.forEach(({ request, response, status }, index) => { - config - .getChatRecordingService?.() - ?.recordToolResult?.(finalizedResponses[index].responseParts, { + const goalContext = request.goalContext; + config.getChatRecordingService?.()?.recordToolResult?.( + finalizedResponses[index].responseParts, + { callId: request.callId, status, resultDisplay: response.resultDisplay, error: response.error, errorType: response.errorType, - }); + }, + goalContext + ? request.name === ToolNames.GET_GOAL || + request.name === ToolNames.UPDATE_GOAL + ? { + goalContext: { ...goalContext }, + provenance: 'goal_runtime' as const, + } + : { goalContext: { ...goalContext } } + : undefined, + ); }); if ( @@ -3465,6 +4005,12 @@ export const useGeminiStream = ( markToolsAsSubmitted( geminiTools.map((toolCall) => toolCall.request.callId), ); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was cancelled', + ); + } return; } @@ -3490,6 +4036,12 @@ export const useGeminiStream = ( (toolCall) => toolCall.request.callId, ); markToolsAsSubmitted(callIdsToMarkAsSubmitted); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was cancelled', + ); + } return; } @@ -3543,6 +4095,49 @@ export const useGeminiStream = ( markToolsAsSubmitted(callIdsToMarkAsSubmitted); + const terminatesGoalTurn = geminiTools.some( + (toolCall) => toolCall.response.terminateTurn === true, + ); + if (terminatesGoalTurn && toolGoalBinding) { + geminiClient.addHistory({ role: 'user', parts: responsesToSend }); + try { + await config.getChatRecordingService()?.flush(); + const runtime = await config.getGoalRuntimeReady(); + const currentPermit = runtime.permitForTurn(toolGoalBinding.turnKey); + if ( + currentPermit && + sameGoalPermit(currentPermit, toolGoalBinding.permit) + ) { + await runtime.finishTurn(toolGoalBinding.permit); + const snapshot = runtime.getSnapshot(); + const status = snapshot.goal?.status; + if ( + status === 'complete' || + status === 'blocked' || + status === 'usage_limited' + ) { + addItem( + { + type: 'goal_state', + snapshot, + cause: status, + }, + Date.now(), + ); + } + } + } catch (error) { + await failClosedGoalTurn( + toolGoalBinding, + `Goal turn could not finish: ${getErrorMessage(error)}`, + ); + } finally { + // Idempotent with the release inside failClosedGoalTurn; also covers the success path. + releaseGoalTurn(toolGoalBinding); + } + return; + } + // Fire tool-use summary generation in parallel with the next API call. // The fast-model latency is hidden behind the main-model streaming. // Fire-and-forget: failures are silent and never block the turn. @@ -3555,8 +4150,13 @@ export const useGeminiStream = ( // fast model happily synthesizes "Attempted to read files" from a // batch that was mostly failures). cleanSummary can reject output // prefixes but not prevent this kind of polluted-input hallucination. + // Goal tools already render authoritative lifecycle copy, which a + // generated summary can contradict while verification is pending. const successfulTools = geminiTools.filter( - (tc) => tc.status === 'success', + (tc) => + tc.status === 'success' && + tc.request.name !== ToolNames.GET_GOAL && + tc.request.name !== ToolNames.UPDATE_GOAL, ); if (successfulTools.length > 0) { const toolInfoForSummary = successfulTools.map((tc) => ({ @@ -3635,6 +4235,12 @@ export const useGeminiStream = ( // Don't continue if model was switched due to quota error if (modelSwitchedFromQuotaError) { + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation stopped after a model switch', + ); + } return; } @@ -3656,6 +4262,12 @@ export const useGeminiStream = ( }); if (backgroundLaunchExhaustedCapacity) { geminiClient?.addHistory({ role: 'user', parts: responsesToSend }); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation stopped: background capacity exhausted', + ); + } return; } @@ -3665,7 +4277,10 @@ export const useGeminiStream = ( const drained = turnCancelledRef.current || abortControllerRef.current?.signal.aborted ? [] - : (midTurnDrainRef?.current?.() ?? []); + : (midTurnDrainRef?.current?.( + false, + Boolean(activeGoalAdmissionRef.current), + ) ?? []); let drainedSteer: SteerInput | undefined; if (drained.length > 0) { const midTurnAbort = @@ -3695,6 +4310,20 @@ export const useGeminiStream = ( abortControllerRef.current?.signal.aborted ) { drainedSteer?.restore(); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was cancelled', + ); + } + return; + } + if (toolGoalBinding?.controller.signal.aborted) { + drainedSteer?.restore(); + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was preempted', + ); return; } @@ -3702,6 +4331,7 @@ export const useGeminiStream = ( steerInput: drainedSteer, onDelivered: drainedSteer?.accept, onDeliveryFailed: drainedSteer?.restore, + goalBinding: toolGoalBinding, }); }, [ @@ -3715,6 +4345,9 @@ export const useGeminiStream = ( addItem, dualOutput, resolveDrainedSteerMessages, + bindGoalTurn, + failClosedGoalTurn, + releaseGoalTurn, ], ); @@ -3832,9 +4465,39 @@ export const useGeminiStream = ( todoWorkChainId?: string; onDelivered?: () => void; onDeliveryFailed?: () => void; + displayed?: boolean; }> >([]); const [notificationTrigger, setNotificationTrigger] = useState(0); + const goalQueuePendingCount = + goalQueueRef?.current?.getPendingSubmissionCount?.() ?? 0; + const claimSystemGoalTurn = useCallback((): { + ready: boolean; + claimGoalTurn?: () => QueuedGoalTurn | undefined; + } => { + if (goalQueueRef?.current?.hasQueuedUserMessages?.()) { + return { ready: false }; + } + let goalOwnsTurn = false; + try { + goalOwnsTurn = goalRequiresExactPermit( + config.getGoalRuntime().getSnapshot(), + ); + } catch { + goalOwnsTurn = false; + } + if (!goalOwnsTurn) return { ready: true }; + if ((goalQueueRef?.current?.getPendingSubmissionCount?.() ?? 0) === 0) { + return { ready: false }; + } + return { + ready: true, + claimGoalTurn: () => { + if (goalQueueRef?.current?.hasQueuedUserMessages?.()) return undefined; + return goalQueueRef?.current?.claimGoalTurn?.(); + }, + }; + }, [config, goalQueueRef]); const getAutonomousLoopTickResolver = useCallback(() => { autonomousLoopTickResolverRef.current ??= new AutonomousLoopTickResolver(); @@ -4026,6 +4689,8 @@ export const useGeminiStream = ( // session's configuration, regardless of which producer's setState // triggered the commit. runOutsideAgentContext(() => { + const admission = claimSystemGoalTurn(); + if (!admission.ready) return; const queue = notificationQueueRef.current; const monitorRegistry = config.getMonitorRegistry(); for (let i = queue.length - 1; i >= 0; i--) { @@ -4047,15 +4712,28 @@ export const useGeminiStream = ( // Notification items (which pass through without preprocessing). if (targetType === SendMessageType.Cron) { const item = queue.shift()!; - addItem( - { type: 'notification' as const, text: item.displayText }, - Date.now(), - ); - submitQuery(item.modelText, item.sendMessageType, undefined, { + if (!item.displayed) { + addItem( + { type: 'notification' as const, text: item.displayText }, + Date.now(), + ); + item.displayed = true; + } + void submitQuery(item.modelText, item.sendMessageType, undefined, { notificationDisplayText: item.displayText, todoWorkChainId: item.todoWorkChainId, onDelivered: item.onDelivered, onDeliveryFailed: item.onDeliveryFailed, + onAdmissionFailed: () => { + queue.unshift(item); + }, + claimGoalTurn: admission.claimGoalTurn, + onGoalClaimDeferred: () => { + queue.unshift(item); + setNotificationTrigger((n) => n + 1); + }, + }).catch((error) => { + debugLogger.warn('Failed to admit cron notification', error); }); return; } @@ -4073,21 +4751,42 @@ export const useGeminiStream = ( const now = Date.now(); for (const item of batch) { - addItem( - { type: 'notification' as const, text: item.displayText }, - now, - ); + if (!item.displayed) { + addItem( + { type: 'notification' as const, text: item.displayText }, + now, + ); + item.displayed = true; + } } const combinedModelText = batch.map((e) => e.modelText).join('\n\n'); const combinedDisplayText = batch.map((e) => e.displayText).join('; '); - submitQuery(combinedModelText, targetType, undefined, { + void submitQuery(combinedModelText, targetType, undefined, { notificationDisplayText: combinedDisplayText, todoWorkChainId: batch[0]?.todoWorkChainId, + onAdmissionFailed: () => { + queue.unshift(...batch); + }, + claimGoalTurn: admission.claimGoalTurn, + onGoalClaimDeferred: () => { + queue.unshift(...batch); + setNotificationTrigger((n) => n + 1); + }, + }).catch((error) => { + debugLogger.warn('Failed to admit background notification', error); }); }); } - }, [streamingState, submitQuery, notificationTrigger, addItem, config]); + }, [ + streamingState, + submitQuery, + notificationTrigger, + addItem, + config, + claimSystemGoalTurn, + goalQueuePendingCount, + ]); // ─── Teammate message integration ───────────────────────── // Each entry carries the full nonce-tagged envelope (`modelText`, @@ -4096,7 +4795,7 @@ export const useGeminiStream = ( // notification queue uses, so teammate reports no longer dump the // whole raw envelope into the conversation as a user bubble. const teammateQueueRef = useRef< - Array<{ modelText: string; display: string }> + Array<{ modelText: string; display: string; displayed?: boolean }> >([]); const [teammateTrigger, setTeammateTrigger] = useState(0); @@ -4163,24 +4862,46 @@ export const useGeminiStream = ( ) { // React can flush this effect after restoring the teammate frame. runOutsideAgentContext(() => { + const admission = claimSystemGoalTurn(); + if (!admission.ready) return; const batch = teammateQueueRef.current.splice(0); // Render one compact `● …` line per teammate report; the full // envelope goes only to the model (the USER bubble is suppressed // for SendMessageType.Teammate in prepareQueryForGemini). for (const entry of batch) { - addItem( - { type: 'notification' as const, text: entry.display }, - Date.now(), - ); + if (!entry.displayed) { + addItem( + { type: 'notification' as const, text: entry.display }, + Date.now(), + ); + entry.displayed = true; + } } const modelText = batch.map((e) => e.modelText).join('\n\n'); const display = batch.map((e) => e.display).join('; '); - submitQuery(modelText, SendMessageType.Teammate, undefined, { + void submitQuery(modelText, SendMessageType.Teammate, undefined, { notificationDisplayText: display, + onAdmissionFailed: () => { + teammateQueueRef.current.unshift(...batch); + }, + claimGoalTurn: admission.claimGoalTurn, + onGoalClaimDeferred: () => { + teammateQueueRef.current.unshift(...batch); + setTeammateTrigger((n) => n + 1); + }, + }).catch((error) => { + debugLogger.warn('Failed to admit teammate notification', error); }); }); } - }, [streamingState, submitQuery, teammateTrigger, addItem]); + }, [ + streamingState, + submitQuery, + teammateTrigger, + addItem, + claimSystemGoalTurn, + goalQueuePendingCount, + ]); return { streamingState, @@ -4189,6 +4910,7 @@ export const useGeminiStream = ( pendingHistoryItems, thought, cancelOngoingRequest, + preemptGoalTurn, retryLastPrompt, pendingToolCalls: toolCalls, handleApprovalModeChange, diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index da8c4d4076..148f541c1b 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -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[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 = null; + act(() => { + submission = result.current.popNextSubmission(); + }); + + expect(submission).toMatchObject({ kind: 'user', modelText: '/model' }); + expect(result.current.messageQueue).toEqual(['/help']); + + let second: ReturnType = 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 = 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 = 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 = 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 = 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 = 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 = 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; + 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; + 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; + 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; + 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 = 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 = 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 = []; - 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(); }); }); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index da312b3cbc..a9b05d10fe 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -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[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([]); - // Synchronous mirror so non-React callbacks see the latest queue. + const [queuedGoalTurns, setQueuedGoalTurns] = useState([]); const queueRef = useRef([]); + const goalQueueRef = useRef([]); + 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[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, }; } diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 2e2483acc2..1ad5dfbca1 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -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(); @@ -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(); }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 208cf0a8d5..646da4a2dc 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -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() diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 5bac5e8e25..29baace5db 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -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 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', } diff --git a/packages/cli/src/ui/utils/goal-runtime.test.ts b/packages/cli/src/ui/utils/goal-runtime.test.ts new file mode 100644 index 0000000000..9c6d809dbf --- /dev/null +++ b/packages/cli/src/ui/utils/goal-runtime.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/utils/goal-runtime.ts b/packages/cli/src/ui/utils/goal-runtime.ts new file mode 100644 index 0000000000..73a23b1ec8 --- /dev/null +++ b/packages/cli/src/ui/utils/goal-runtime.ts @@ -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, +): Promise { + try { + await config.getGoalRuntimeReady(); + } catch (error) { + if (!(error instanceof GoalPersistenceUnavailableError)) throw error; + } +} diff --git a/packages/cli/src/ui/utils/historyUtils.test.ts b/packages/cli/src/ui/utils/historyUtils.test.ts index bbec090ad2..f12172627f 100644 --- a/packages/cli/src/ui/utils/historyUtils.test.ts +++ b/packages/cli/src/ui/utils/historyUtils.test.ts @@ -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', () => { diff --git a/packages/cli/src/ui/utils/historyUtils.ts b/packages/cli/src/ui/utils/historyUtils.ts index b0ab0c2aa3..591c096b2c 100644 --- a/packages/cli/src/ui/utils/historyUtils.ts +++ b/packages/cli/src/ui/utils/historyUtils.ts @@ -92,6 +92,7 @@ export function isSyntheticHistoryItem( case 'arena_agent_complete': case 'arena_session_complete': case 'goal_status': + case 'goal_state': return false; default: { diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index a463372447..0a6bbdcd99 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -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 = { + 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. diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index d712269362..385898bf9d 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -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 diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index 1570b6a19c..de00ff5340 100644 --- a/packages/core/src/core/client-goal.test.ts +++ b/packages/core/src/core/client-goal.test.ts @@ -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); }); }); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 16ccb2eba1..e7c8cbe383 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -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([ + ['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', diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index b9ab542d70..8cc5f093fe 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -4805,6 +4805,7 @@ export class CoreToolScheduler { : 'modelOverride' in toolResult ? { modelOverride: toolResult.modelOverride } : {}), + ...(toolResult.terminateTurn ? { terminateTurn: true } : {}), ...(processedImages.visionBridgeNotice !== undefined ? { visionBridgeNotice: processedImages.visionBridgeNotice } : {}), diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 7ae9a546ce..9c8838a4dd 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -148,6 +148,7 @@ export interface ToolCallResponseInfo { contentLength?: number; persistedOutputFiles?: string[]; modelOverride?: string; + terminateTurn?: boolean; visionBridgeNotice?: string; artifacts?: ToolArtifact[]; } diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index 698af1c1b3..87bf8ab22e 100644 --- a/packages/core/src/goals/goal-reducer.test.ts +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -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' }), { diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index ae2bf87b06..b7e54a36f1 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -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( diff --git a/packages/core/src/goals/goal-runtime.integration.test.ts b/packages/core/src/goals/goal-runtime.integration.test.ts index 6b4da97a5f..421537b463 100644 --- a/packages/core/src/goals/goal-runtime.integration.test.ts +++ b/packages/core/src/goals/goal-runtime.integration.test.ts @@ -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 }, }); }); diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index b52d9d17fb..f116f6e5f2 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -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(); + const appendGate = deferred(); + 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[] = []; diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index 06a5049b8b..66d2762b11 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -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); }; diff --git a/packages/core/src/goals/goal-tools.ts b/packages/core/src/goals/goal-tools.ts index a13e2497c4..92059a672e 100644 --- a/packages/core/src/goals/goal-tools.ts +++ b/packages/core/src/goals/goal-tools.ts @@ -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 & { getSnapshotForPermit?: GoalRuntime['getSnapshotForPermit']; diff --git a/packages/core/src/goals/goal-verifier.test.ts b/packages/core/src/goals/goal-verifier.test.ts index 1cce7a6202..4d7a68f46e 100644 --- a/packages/core/src/goals/goal-verifier.test.ts +++ b/packages/core/src/goals/goal-verifier.test.ts @@ -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 () => { diff --git a/packages/core/src/goals/goal-verifier.ts b/packages/core/src/goals/goal-verifier.ts index 8fda614902..af6e63bfec 100644 --- a/packages/core/src/goals/goal-verifier.ts +++ b/packages/core/src/goals/goal-verifier.ts @@ -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.`; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index a996383853..0afca36c0c 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -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; } /**