From ef510c550f014b17373362a81cdbd1ebf1fdf590 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:38:03 +0800 Subject: [PATCH 01/15] feat(cli): adopt Goal v3 in interactive TUI --- packages/cli/src/nonInteractiveCliCommands.ts | 7 + packages/cli/src/ui/AppContainer.test.tsx | 347 +++++++ packages/cli/src/ui/AppContainer.tsx | 380 ++++++-- .../cli/src/ui/commands/goalCommand.test.ts | 588 +++++------ packages/cli/src/ui/commands/goalCommand.ts | 416 ++++---- packages/cli/src/ui/commands/types.ts | 18 + packages/cli/src/ui/components/Footer.tsx | 15 +- .../cli/src/ui/components/GoalPill.test.tsx | 202 +++- packages/cli/src/ui/components/GoalPill.tsx | 170 ++-- .../ui/components/HistoryItemDisplay.test.tsx | 33 + .../src/ui/components/HistoryItemDisplay.tsx | 7 + .../messages/GoalStatusMessage.test.tsx | 94 ++ .../components/messages/GoalStatusMessage.tsx | 170 +++- .../ui/hooks/slashCommandProcessor.test.ts | 81 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 22 + .../cli/src/ui/hooks/useBranchCommand.test.ts | 49 +- packages/cli/src/ui/hooks/useBranchCommand.ts | 21 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 550 ++++++++++- packages/cli/src/ui/hooks/useGeminiStream.ts | 915 +++++++++++++++--- .../cli/src/ui/hooks/useMessageQueue.test.ts | 581 ++++++++--- packages/cli/src/ui/hooks/useMessageQueue.ts | 321 ++++-- .../cli/src/ui/hooks/useResumeCommand.test.ts | 44 +- 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 | 22 +- .../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-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 | 3 + 37 files changed, 4179 insertions(+), 1153 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/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 292bb802bda..cf8f48c31cd 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -184,6 +184,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 fe6154110cf..56d7658d616 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, 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'; @@ -1244,6 +1247,350 @@ 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(1); + 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('keeps a held user turn while the Goal resumes and drains it after completion', async () => { + let goalStatus: 'blocked' | 'active' | 'complete' = 'blocked'; + 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') => { + 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, + isTranscriptOpen: false, + pendingSubmissionCount: 1, + getPendingSubmissionCount: () => (userPopped ? 0 : 1), + popNextSubmission, + enqueueGoalTurn: vi.fn(), + restoreMessages: vi.fn(), + submitQuery, + submissionInFlightRef: { current: false }, + submissionSettledRevision: 0, + }), + ); + + await vi.waitFor(() => { + expect(popNextSubmission).toHaveBeenCalledWith('only'); + }); + expect(submitQuery).not.toHaveBeenCalled(); + + goalStatus = 'active'; + act(() => { + goalListener?.(); + }); + + await vi.waitFor(() => { + expect(popNextSubmission).toHaveBeenCalledWith('priority'); + }); + expect(submitQuery).not.toHaveBeenCalled(); + + goalStatus = 'complete'; + act(() => { + goalListener?.(); + }); + + await vi.waitFor(() => { + expect(submitQuery).toHaveBeenCalledWith( + 'held user work', + SendMessageType.UserQuery, + undefined, + expect.objectContaining({ + userAdmission: { turnKey: 'message-queue:held-user' }, + }), + ); + }); + view.unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('drains Goal controls before held user turns while paused', async () => { + const goalRuntime = { + getSnapshot: () => ({ goal: { status: 'paused' } }), + subscribe: () => vi.fn(), + } as unknown as ReturnType; + vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime); + + const submitQuery = vi.fn().mockResolvedValue(undefined); + let submissionPopped = false; + const popNextSubmission = vi.fn((mode = 'normal') => { + if (submissionPopped) return null; + submissionPopped = true; + return { + kind: 'user' as const, + modelText: + mode === 'only' ? '/goal edit revised objective' : 'held user work', + turnKey: + mode === 'only' + ? 'message-queue:goal-edit' + : 'message-queue:held-user', + }; + }); + + renderHook(() => + useQueuedSubmissionDrain({ + config: mockConfig, + isConfigInitialized: true, + streamingState: StreamingState.Idle, + isProcessing: false, + dialogsVisible: false, + isTranscriptOpen: false, + pendingSubmissionCount: 2, + getPendingSubmissionCount: () => 2, + popNextSubmission, + enqueueGoalTurn: vi.fn(), + restoreMessages: vi.fn(), + submitQuery, + submissionInFlightRef: { current: false }, + submissionSettledRevision: 0, + }), + ); + + await vi.waitFor(() => { + expect(popNextSubmission).toHaveBeenCalledWith('only'); + expect(submitQuery).toHaveBeenCalledWith( + '/goal edit revised objective', + SendMessageType.UserQuery, + undefined, + expect.objectContaining({ + userAdmission: { turnKey: 'message-queue:goal-edit' }, + }), + ); + }); + expect(submitQuery).not.toHaveBeenCalledWith( + 'held user work', + expect.anything(), + expect.anything(), + expect.anything(), + ); + }); + + 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, + isTranscriptOpen: 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, + isTranscriptOpen: 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(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f911d5c2ebb..98ff7a14e51 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'; @@ -74,6 +75,7 @@ import { GitWorktreeService, readWorktreeSessionMarker, isSessionRuntimeActive, + type GoalTurnHost, } from '@qwen-code/qwen-code-core'; import { applyCollapsePolicyAndSummary, @@ -144,7 +146,7 @@ import { computeApiTruncationIndex, isRealUserTurn, } from './utils/historyMapping.js'; -import { restoreGoalFromHistory } from './utils/restoreGoal.js'; +import { waitForGoalRuntime } from './utils/goal-runtime.js'; import { useVimModeState, useVimModeActions, @@ -197,7 +199,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'; @@ -347,6 +350,172 @@ export function shouldDrainMessageQueue({ ); } +export function useQueuedSubmissionDrain({ + config, + isConfigInitialized, + streamingState, + isProcessing, + dialogsVisible, + isTranscriptOpen, + pendingSubmissionCount, + getPendingSubmissionCount, + popNextSubmission, + enqueueGoalTurn, + restoreMessages, + submitQuery, + submissionInFlightRef, + submissionSettledRevision, +}: { + config: Config; + isConfigInitialized: boolean; + streamingState: StreamingState; + isProcessing: boolean; + dialogsVisible: boolean; + isTranscriptOpen: 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; + } | 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 + ) { + return; + } else { + admissionFailureRef.current = null; + } + } + if ( + !shouldDrainMessageQueue({ + isConfigInitialized, + streamingState, + isProcessing, + dialogsVisible, + messageQueueLength: pendingSubmissionCount, + }) || + isTranscriptOpen + ) { + return; + } + + let goalControlMode: Parameters[0] = 'normal'; + try { + const status = config.getGoalRuntime().getSnapshot().goal?.status; + if ( + status === 'blocked' || + status === 'usage_limited' || + status === 'paused' + ) { + goalControlMode = 'only'; + } else 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, + }; + }; + 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, + isTranscriptOpen, + pendingSubmissionCount, + popNextSubmission, + queueDrainNonce, + restoreMessages, + streamingState, + submissionInFlightRef, + submissionSettledRevision, + submitQuery, + ]); +} + export function getSpeculativeToolResult(response: unknown): { text: string; status: ToolCallStatus; @@ -748,6 +917,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()), ); @@ -802,13 +972,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(), ); @@ -1068,7 +1231,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 @@ -1925,8 +2091,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, @@ -1935,6 +2128,7 @@ export const AppContainer = (props: AppContainerProps) => { pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + preemptGoalTurn, retryLastPrompt, handleApprovalModeChange, activePtyId, @@ -1967,6 +2161,7 @@ export const AppContainer = (props: AppContainerProps) => { availableTerminalHeightRef, terminalWidthRef, midTurnRestoreRef, + goalQueueRef, ); cancelOngoingRequestRef.current = cancelOngoingRequest; @@ -2076,48 +2271,86 @@ 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 submission = popAllMessages(); + const submission = popAllMessages(releaseQueuedGoalReservations); if (submission === null) return null; restoredSubmissionRef.current = submission; submittedPromptProvenanceUnavailableRef.current = false; return submission.modelText; - }, [popAllMessages]); + }, [popAllMessages, releaseQueuedGoalReservations]); - // 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. @@ -2348,9 +2581,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; } @@ -2474,9 +2713,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; } @@ -2488,7 +2733,7 @@ export const AppContainer = (props: AppContainerProps) => { agentViewState, streamingState, isProcessing, - submitUserQuery, + submitQuery, handleSlashCommand, config, geminiClient, @@ -2593,7 +2838,7 @@ 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 popped = popAllMessages(); + const popped = popAllMessages(releaseQueuedGoalReservations); if (popped) { restoredSubmissionRef.current = popped; submittedPromptProvenanceUnavailableRef.current = false; @@ -2766,6 +3011,7 @@ export const AppContainer = (props: AppContainerProps) => { [ buffer, popAllMessages, + releaseQueuedGoalReservations, historyManager, logger, geminiClient, @@ -4139,48 +4385,22 @@ 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; - } - // Don't silently auto-submit queued messages while the transcript is open - // (it isn't part of `dialogsVisible`). Resume draining once it closes. - if (isTranscriptOpenRef.current) 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, - // Re-run the drain when the transcript closes so queued messages resume. isTranscriptOpen, - 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 e9a7b1027fb..fe31cdda3e3 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -4,34 +4,102 @@ * 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 { 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 { +function goalSnapshot( + overrides: Partial> = {}, +): GoalSnapshotV2 { 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; + 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, + }, + }; } -describe('goalCommand', () => { - beforeEach(() => __resetActiveGoalStoreForTests()); - afterEach(() => __resetActiveGoalStoreForTests()); +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) { + const getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); + const config = { getGoalRuntimeReady } as unknown as Config; + const context = createMockCommandContext({ services: { config } }); + return { context, getGoalRuntimeReady }; +} + +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' }], + ['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' }], + ] 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', () => { it('is available in interactive, non-interactive, and ACP modes', () => { expect(goalCommand.supportedModes).toEqual([ 'interactive', @@ -40,349 +108,219 @@ 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('rejects invalid set and edit commands before runtime admission', async () => { + const { runtime } = makeRuntime(noGoalSnapshot()); + const { context, getGoalRuntimeReady } = makeContext(runtime); - 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); + 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('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('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, ''); - 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), - }, + expect(result).toEqual({ + type: 'goal_control', + operation: { kind: 'status' }, + response: { snapshot }, }); - const result = await goalCommand.action!(ctx, 'do x'); - expect(result).toMatchObject({ type: 'message', messageType: 'error' }); - expect((result as { content: string }).content).toMatch(/disabled/i); + expect(getGoalRuntimeReady).toHaveBeenCalledTimes(1); + expect(getSnapshot).toHaveBeenCalledTimes(1); + expect(getGoalRuntimeReady.mock.invocationCallOrder[0]).toBeLessThan( + getSnapshot.mock.invocationCallOrder[0]!, + ); + expect(dispatch).not.toHaveBeenCalled(); }); - 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 }, - }); - 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, - }); - }, - ); + 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); - it('clears existing goal on clear keyword and emits a cleared card', async () => { - const cfg = makeConfig(); - const ctx = createMockCommandContext({ - services: { config: cfg as unknown as Config }, - }); - await goalCommand.action!(ctx, 'write hello'); - const before = (ctx.ui.addItem as ReturnType).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 result = await goalCommand.action!(context, 'Ship it'); - it('returns a clear message outside interactive mode', async () => { - const cfg = makeConfig(); - const ctx = createMockCommandContext({ - executionMode: 'acp', - services: { config: cfg as unknown as Config }, + expect(dispatch).toHaveBeenCalledWith({ + action: 'create', + objective: 'Ship it', }); - 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', + 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('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('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'); - it('registers the hook and submits an instructional prompt on set', async () => { - const ctx = createMockCommandContext({ - services: { config: makeConfig() as unknown as Config }, + expect(dispatch).toHaveBeenCalledWith({ + action: 'replace', + objective: 'Replace it', + expectedGoalId: 'goal-1', + expectedRevision: 4, }); - 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', + expect(result).toEqual({ + type: 'goal_control', + operation: { kind: 'set', objective: 'Replace it' }, + response: { snapshot: after }, + cause: 'replace', }); }); - it('shows active goal status when re-invoked with empty args', async () => { - const ctx = createMockCommandContext({ - services: { config: makeConfig() as unknown as Config }, - }); - await goalCommand.action!(ctx, 'do x'); - const result = await goalCommand.action!(ctx, ''); - expect((result as { content: string }).content).toMatch( - /Goal active: do x/, - ); - }); + it('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; - 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; - - notifyGoalTerminal('sess-1', { - kind: 'achieved', - condition: 'do x', - iterations: 3, - durationMs: 12_345, - lastReason: 'quoted evidence from transcript', - }); + for (const [args, operation, request] of cases) { + const snapshot = goalSnapshot(); + const { dispatch, runtime } = makeRuntime(snapshot); + const { context } = makeContext(runtime); - 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', - }); + 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('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, - }, - }); + it.each(['edit new objective', 'pause', 'resume'])( + 'rejects %j when no Goal exists', + async (args) => { + const { dispatch, runtime } = makeRuntime(noGoalSnapshot()); + const { context } = makeContext(runtime); - await goalCommand.action!(ctx, 'do x'); + const result = await goalCommand.action!(context, args); - notifyGoalTerminal('sess-1', { - kind: 'achieved', - condition: 'do x', - iterations: 3, - durationMs: 12_345, - lastReason: 'quoted evidence from transcript', - }); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + content: expect.stringMatching(/no goal/i), + }); + expect(dispatch).not.toHaveBeenCalled(); + }, + ); - 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('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); - 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!(context, 'clear'); + + expect(result).toEqual({ + type: 'goal_control', + operation: { kind: 'clear' }, + response: { snapshot }, }); - 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/); + expect(dispatch).not.toHaveBeenCalled(); }); - 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, - }); + it('works with a bare config that exposes no trust or 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 addItem = ctx.ui.addItem as ReturnType; - const beforeClearCount = addItem.mock.calls.length; + const result = await goalCommand.action!(context, 'set Bare Goal'); - // /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(dispatch).toHaveBeenCalledWith({ + action: 'create', + objective: 'Bare Goal', }); - 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/, - ); + expect(result).toMatchObject({ type: 'goal_control' }); }); - 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('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 } as unknown as Config; + const context = createMockCommandContext({ services: { config } }); - 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 result = await goalCommand.action!(context, 'status objective'); - const lastItem = addItem.mock.calls.at(-1)![0]; - expect(lastItem).toMatchObject({ - kind: 'aborted', - lastReason: 'Goal max iterations reached', + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Goal persistence is unavailable', }); + expect(result).not.toHaveProperty('response'); + expect(context.ui.addItem).not.toHaveBeenCalled(); }); - 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', - iterations: 2, - durationMs: 12_000, - lastReason: 'the required branch does not exist', + 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.', }); - - 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/); }); }); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index c3d157ecf7b..4d4985c9d49 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -4,31 +4,34 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { + GoalControlRequest, + GoalStateResponse, + GoalStateCause, + GoalTerminalEvent, +} from '@qwen-code/qwen-code-core'; +import { + getActiveGoal, + getLastGoalTerminal, + registerGoalHook, + unregisterGoalHook, +} 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 { - getActiveGoal, - getLastGoalTerminal, - registerGoalHook, - unregisterGoalHook, - type GoalTerminalEvent, -} from '@qwen-code/qwen-code-core'; +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. -const CLEAR_KEYWORDS = new Set([ +const LEGACY_CLEAR_KEYWORDS = new Set([ 'clear', 'stop', 'off', @@ -37,190 +40,275 @@ 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.`; +function formatLegacyTerminalSummary(event: GoalTerminalEvent): string { + const title = + event.kind === 'achieved' + ? 'Goal achieved' + : event.kind === 'failed' + ? 'Goal could not be achieved' + : 'Goal aborted'; + const stats: string[] = []; + 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(); + return `${title}${subtitle}\nGoal: ${event.condition}${reason ? `\nLast check: ${reason}` : ''}`; +} -const formatTurns = (n: number) => `${n} ${n === 1 ? 'turn' : 'turns'}`; +async function runLegacyGoalCommand( + context: CommandContext, + args: string, +): Promise { + const { config } = context.services; + if (!config) return errorMessage('Configuration is not available.'); -function assertNeverGoalKind(kind: never): never { - throw new Error(`Unexpected terminal goal kind: ${kind}`); -} + 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`).', + }; + } -function terminalGoalTitle(kind: GoalTerminalEvent['kind']): string { - switch (kind) { - case 'achieved': - return 'Goal achieved'; - case 'failed': - return 'Goal could not be achieved'; - case 'aborted': - return 'Goal aborted'; - default: - return assertNeverGoalKind(kind); + if (LEGACY_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}`, + }; } -} -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') - 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}`; + 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; } -function infoMessage(content: string): MessageActionReturn { - return { type: 'message', messageType: 'info', content }; +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 (keyword === 'clear') 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}`, - ); - } - // 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`).', + ): Promise => { + if (context.executionMode !== 'interactive') { + return ( + (await runLegacyGoalCommand(context, args)) ?? { + type: 'message', + messageType: 'info', + content: 'Command executed successfully.', + } ); } + const { config } = context.services; + if (!config) return errorMessage('Configuration is not available.'); + + const operation = parseGoalCommand(args); + if (operation.kind === 'error') return errorMessage(operation.message); - // ── 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.'); + try { + const runtime = await config.getGoalRuntimeReady(); + const snapshot = runtime.getSnapshot(); + if (operation.kind === 'status') { + return goalControl(operation, { snapshot }); } - 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}`); + + 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, + ); } - return; - } - // ── Branch 3: gates ────────────────────────────────────────────────── - 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.', - ); - } + if (!current) { + if (operation.kind === 'clear') { + return goalControl(operation, { snapshot }); + } + return errorMessage(`Cannot ${operation.kind}: no Goal is active.`); + } - // ── 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 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 a41306770f7..592b4d26499 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. @@ -268,6 +285,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 1ece51c8595..b902f0732f6 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 a4dd5e8a7aa..eda37a72084 100644 --- a/packages/cli/src/ui/components/GoalPill.test.tsx +++ b/packages/cli/src/ui/components/GoalPill.test.tsx @@ -4,62 +4,176 @@ * 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'; + +const NOW = 10_000; + +function snapshot( + status: NonNullable['status'], + activity: GoalSnapshotV2['activity'] = 'idle', + overrides: Partial> = {}, +): GoalSnapshotV2 { + return { + 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, + }, + }; +} -function makeConfig(): Config { +const noGoalSnapshot: GoalSnapshotV2 = { + v: 2, + activity: 'idle', + goal: null, +}; + +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 { - getSessionId: () => 'sess-pill', - isTrustedFolder: () => true, - getDisableAllHooks: () => false, - getHookSystem: () => ({ - addFunctionHook: vi.fn().mockReturnValue('hook-pill'), - removeFunctionHook: vi.fn().mockReturnValue(true), - }), - } as unknown as Config; + runtime, + unsubscribe, + emit(next: GoalSnapshotV2) { + current = next; + for (const listener of listeners) listener(structuredClone(next)); + }, + }; } +const GoalProbe = () => { + const goalState = useFooterGoalState(); + return goalState ? : ; +}; + describe('GoalPill', () => { - beforeEach(() => __resetActiveGoalStoreForTests()); - afterEach(() => __resetActiveGoalStoreForTests()); + afterEach(() => { + vi.useRealTimers(); + }); - it('renders nothing when no goal is active', () => { - const { lastFrame, unmount } = renderWithProviders(, { - config: makeConfig(), - }); - expect(lastFrame()).toBe(''); - unmount(); + it.each([ + ['no goal', noGoalSnapshot, ''], + ['active and idle', snapshot('active', 'idle'), '◎ /goal active'], + ['active and running', snapshot('active', 'running'), '◎ /goal active'], + [ + 'active and verifying', + snapshot('active', 'verifying'), + '○ /goal checking', + ], + ['paused', snapshot('paused'), '! /goal paused'], + ['blocked', snapshot('blocked'), '✖ /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 } = 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(''); + } }); - 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 } = renderPill({ + snapshot: snapshot('active', 'running'), }); - const { lastFrame, unmount } = renderWithProviders(, { - config, + expect(lastFrame()).toContain('(5s)'); + }); + + 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); }); - // 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/); + 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(); - unregisterGoalHook(config, 'sess-pill'); + expect(second.unsubscribe).toHaveBeenCalledOnce(); }); }); diff --git a/packages/cli/src/ui/components/GoalPill.tsx b/packages/cli/src/ui/components/GoalPill.tsx index 53caf0cb174..56b1951d605 100644 --- a/packages/cli/src/ui/components/GoalPill.tsx +++ b/packages/cli/src/ui/components/GoalPill.tsx @@ -7,79 +7,131 @@ 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'; -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(); +export function isLiveGoalSnapshot( + snapshot: GoalSnapshotV2 | undefined, +): boolean { + const status = snapshot?.goal?.status; + return status !== undefined && status !== 'complete'; +} + +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: '○', label: 'checking', color: theme.text.secondary } + : { icon: '◎', label: 'active', color: theme.text.accent }; + } + switch (goal.status) { + case 'paused': + return { icon: '!', label: 'paused', color: theme.status.warning }; + case 'blocked': + return { icon: '✖', 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(Date.now() - goal.setAt); + 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 1bdee560097..d524aa47da4 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 621667273d9..0a8c6877a7a 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 d2ea821fa20..d18289c1af0 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 f2d932cbb9e..367e8eaa94d 100644 --- a/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx +++ b/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx @@ -6,42 +6,157 @@ 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 ( + + + + + Goal cleared + + ); + } + + const lifecycle = (() => { + switch (goal.status) { + case 'active': + if (snapshot.activity === 'verifying') { + return { + prefix: '○', + color: theme.text.secondary, + title: 'Goal checking', + }; + } + return { + prefix: '◎', + 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: '✖', + color: theme.status.error, + title: 'Goal blocked', + }; + case 'usage_limited': + return { + prefix: '!', + color: theme.status.warning, + title: 'Goal usage limited', + }; + case 'complete': + return { + prefix: '✓', + 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 ( - {ICON.CIRCLE_EMPTY} + @@ -67,10 +182,8 @@ 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, + prefix: '◎', prefixColor: theme.text.accent, title: 'Goal set', }; @@ -82,7 +195,7 @@ const GoalStatusMessageInternal: React.FC = ({ }; case 'cleared': return { - prefix: ICON.CIRCLE_EMPTY, + prefix: '○', prefixColor: theme.text.secondary, title: 'Goal cleared', }; @@ -98,6 +211,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 +243,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 +251,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/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 11a6dab6588..c478d4d81ac 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -205,6 +205,7 @@ describe('useSlashCommandProcessor', () => { setIsProcessing = vi.fn(), settings: LoadedSettings = mockSettings, extensionRefreshState?: ExtensionRefreshState, + isIdleRef = { current: true }, ) => { mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands)); mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); @@ -222,7 +223,7 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleVimEnabled false, // isProcessing setIsProcessing, - { current: true }, // isIdleRef + isIdleRef, vi.fn(), // setGeminiMdFileCount createMockActions(), new Map(), // extensionsUpdateState @@ -448,6 +449,84 @@ 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('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 862b7905500..cade91bf513 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -1065,6 +1065,28 @@ export const useSlashCommandProcessor = ( }); } return { type: 'handled' }; + case 'goal_control': { + if (commandContext.ui.isIdleRef.current) { + 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 e431f96cde3..e53797ceb91 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(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(restoreGoalFromHistory).toHaveBeenCalledWith( - expect.any(Array), - config, - addItem, + + 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 983ab767ea2..7fd09d608d2 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 70cc35a1f8c..7b77cbded1c 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,159 @@ 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(() => '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]); + expect(options.getQueuedGoalTurnKey()).toBe('message-queue:next-user'); + 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 +1589,295 @@ 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('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('waits for a background agent when its launch exhausts capacity', async () => { const responseParts: Part[] = [ { @@ -1834,7 +2282,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 +2340,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 +2404,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 +7301,7 @@ describe('useGeminiStream', () => { '', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -6875,7 +7320,7 @@ describe('useGeminiStream', () => { '// This is a line comment', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -6894,7 +7339,7 @@ describe('useGeminiStream', () => { '/* This is a block comment */', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -10015,7 +10460,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 @@ -10067,14 +10512,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 }), ); }); @@ -10097,7 +10542,7 @@ describe('useGeminiStream', () => { 'Second query', expect.any(AbortSignal), expect.any(String), - { type: SendMessageType.UserQuery }, + expect.objectContaining({ type: SendMessageType.UserQuery }), ); }); }); @@ -10452,7 +10897,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, @@ -10484,11 +10929,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 () => { @@ -10570,7 +11012,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, @@ -10605,32 +11047,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 () => { @@ -10714,6 +11138,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 d2948685dd5..21c3dbabb74 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 @@ -432,7 +468,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 +481,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: () => 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 @@ -815,7 +985,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 @@ -905,6 +1076,7 @@ export const useGeminiStream = ( clearRetryCountdown, config, getPromptCount, + setSubmissionInFlight, ]); const applyVisionBridgeIfNeeded = useCallback( @@ -1915,27 +2087,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', @@ -1946,26 +2097,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( @@ -1973,9 +2105,11 @@ export const useGeminiStream = ( stream: AsyncIterable, userMessageTimestamp: number, signal: AbortSignal, - ): Promise => { + turnAdmission?: GoalTurnAdmission, + ): Promise => { let geminiMessageBuffer = ''; let thoughtBuffer = ''; + let scheduledToolContinuation = false; const toolCallRequests: ToolCallRequestInfo[] = []; const bufferedEvents: BufferedStreamEvent[] = []; let flushTimer: ReturnType | null = null; @@ -2104,6 +2238,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 { @@ -2117,7 +2259,10 @@ 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); @@ -2262,9 +2407,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 @@ -2316,7 +2478,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) { @@ -2369,6 +2534,7 @@ export const useGeminiStream = ( } if (executableToolCallRequests.length > 0) { + scheduledToolContinuation = true; scheduleToolCalls( executableToolCallRequests, signal, @@ -2376,7 +2542,10 @@ export const useGeminiStream = ( ); } } - return StreamProcessingStatus.Completed; + return { + status: StreamProcessingStatus.Completed, + scheduledToolContinuation, + }; }, [ handleContentEvent, @@ -2399,7 +2568,7 @@ export const useGeminiStream = ( setPendingHistoryItem, handleUserPromptSubmitBlockedEvent, handleStopHookLoopEvent, - handleActiveGoalEvent, + bindGoalTurn, addItem, commitItem, dualOutput, @@ -2418,7 +2587,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) { @@ -2429,23 +2597,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; } @@ -2580,9 +2732,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, @@ -2648,7 +2804,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); }, @@ -2664,15 +2824,41 @@ export const useGeminiStream = ( notificationDisplayText?: 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; @@ -2688,6 +2874,8 @@ export const useGeminiStream = ( !isTurnContinuation && !allowConcurrentBtwDuringResponse ) { + await releaseUndeliveredGoalTurn(metadata?.userAdmission?.turnKey); + metadata?.onAdmissionFailed?.(); metadata?.onDeliveryFailed?.(); return; } @@ -2698,12 +2886,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 @@ -2747,6 +2937,7 @@ export const useGeminiStream = ( if ( !isTurnContinuation && submitType !== SendMessageType.Notification && + submitType !== SendMessageType.Goal && !allowConcurrentBtwDuringResponse ) { setModelSwitchedFromQuotaError(false); @@ -2808,25 +2999,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 || @@ -2846,8 +3139,10 @@ export const useGeminiStream = ( } const finalQueryToSend = queryToSend; - lastPromptRef.current = finalQueryToSend; - lastPromptErroredRef.current = false; + if (submitType !== SendMessageType.Goal) { + lastPromptRef.current = finalQueryToSend; + lastPromptErroredRef.current = false; + } if ( submitType === SendMessageType.UserQuery || @@ -2892,11 +3187,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] @@ -2923,19 +3223,54 @@ 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(), + } + : userAdmission + ? { + goalTurnKey: userAdmission.turnKey, + goalSignal: turnController!.signal, + goalOrigin: 'user' as const, + getQueuedGoalTurnKey: () => + goalQueueRef?.current?.peekNextUserBatchKey(), + } + : {}), + ...(!allowConcurrentBtwDuringResponse && midTurnDrainRef + ? { getSteerInput: drainSteerAtBoundary } + : {}), + }, ); - const processingStatus = await processGeminiStreamEvents( + const processingResult = await processGeminiStreamEvents( stream, userMessageTimestamp, - abortSignal, + processingSignal, + 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; } @@ -2980,7 +3315,18 @@ export const useGeminiStream = ( 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 @@ -3047,7 +3393,9 @@ export const useGeminiStream = ( if (error instanceof UnauthorizedError) { onAuthError('Session expired or is unauthorized.'); } else if (!isNodeError(error) || error.name !== 'AbortError') { - lastPromptErroredRef.current = true; + if (submitType !== SendMessageType.Goal) { + lastPromptErroredRef.current = true; + } const retryHint = t('Press Ctrl+Y to retry'); // Store error with hint as a pending item (same as handleErrorEvent) setPendingRetryErrorItem({ @@ -3075,7 +3423,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(); } }); }, @@ -3103,6 +3482,11 @@ export const useGeminiStream = ( dualOutput, drainSteerAtBoundary, midTurnDrainRef, + goalQueueRef, + bindGoalTurn, + failClosedGoalTurn, + releaseUndeliveredGoalTurn, + setSubmissionInFlight, ], ); @@ -3160,6 +3544,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 @@ -3331,6 +3721,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 missingActiveGoalContext = false; + if (active) { + try { + const runtime = config.getGoalRuntime(); + const currentPermit = runtime.permitForTurn(active.turnKey); + missingActiveGoalContext = + currentPermit !== undefined && + sameGoalPermit(currentPermit, active.permit); + } catch { + // A missing runtime means this is an ordinary non-Goal batch. + } + } + if (active && missingActiveGoalContext) { + 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) => ({ @@ -3382,6 +3868,12 @@ export const useGeminiStream = ( } if (geminiTools.length === 0 && pendingDuplicateResponses.length === 0) { + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation ended without a result', + ); + } return; } @@ -3459,6 +3951,12 @@ export const useGeminiStream = ( markToolsAsSubmitted( geminiTools.map((toolCall) => toolCall.request.callId), ); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was cancelled', + ); + } return; } @@ -3484,6 +3982,12 @@ export const useGeminiStream = ( (toolCall) => toolCall.request.callId, ); markToolsAsSubmitted(callIdsToMarkAsSubmitted); + if (toolGoalBinding) { + await failClosedGoalTurn( + toolGoalBinding, + 'Goal tool continuation was cancelled', + ); + } return; } @@ -3537,6 +4041,48 @@ 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 { + 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. @@ -3549,8 +4095,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) => ({ @@ -3629,6 +4180,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; } @@ -3659,7 +4216,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 = @@ -3689,6 +4249,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; } @@ -3696,6 +4270,7 @@ export const useGeminiStream = ( steerInput: drainedSteer, onDelivered: drainedSteer?.accept, onDeliveryFailed: drainedSteer?.restore, + goalBinding: toolGoalBinding, }); }, [ @@ -3709,6 +4284,9 @@ export const useGeminiStream = ( addItem, dualOutput, resolveDrainedSteerMessages, + bindGoalTurn, + failClosedGoalTurn, + releaseGoalTurn, ], ); @@ -3825,9 +4403,39 @@ export const useGeminiStream = ( monitor?: { id: string; status: 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(); @@ -4013,6 +4621,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--) { @@ -4034,14 +4644,27 @@ 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, 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; } @@ -4058,20 +4681,41 @@ 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, + 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`, @@ -4080,7 +4724,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); @@ -4147,24 +4791,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, @@ -4173,6 +4839,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 da8c4d40767..e818c540eb0 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,332 @@ 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('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; + const submission = result.current.popNextSubmission(); + + expect(submission?.kind).toBe('goal'); + if (!submission || submission.kind !== 'goal') { + throw new Error('Expected a queued Goal turn'); + } + expect(submission.permit).toEqual({ + goalId: 'goal-copy', + revision: 3, + turnId: 'turn-copy', + }); + expect(submission.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?: () => number; + }; + + expect(queue.removeGoalTurns).toBeTypeOf('function'); + let removed = 0; + act(() => { + removed = queue.removeGoalTurns!(); + }); + + expect(removed).toBe(1); + expect(result.current.messageQueue).toEqual(['keep me']); + expect(result.current.pendingSubmissionCount).toBe(1); + expect(result.current.popNextSubmission()).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 +429,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 +448,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 +472,115 @@ 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; - act(() => { - popped = result.current.popAllMessages(); - }); + expect(removed).toEqual([[reservedKey]]); + }); + }); - expect(popped).toEqual({ - modelText: 'model one\n\nmodel two', - submittedPrompt: 'user one\n\nuser two', - }); + 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(); - it('omits aggregate provenance when any queued message lacks it', () => { - const { result } = renderHook(() => useMessageQueue()); + 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, + }); + }); - act(() => { - result.current.addMessage('model one', false, 'user one'); - result.current.addMessage('restored steer'); - }); + 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 popped: QueuedSubmission | null = null; - act(() => { - popped = result.current.popAllMessages(); - }); + let goalControl: ReturnType; + act(() => { + goalControl = result.current.popNextSubmission('priority'); + }); - expect(popped).toEqual({ - modelText: 'model one\n\nrestored steer', + 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 +613,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 +627,38 @@ describe('useMessageQueue', () => { drained = result.current.drainQueue(); }); + 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([ - 'steer now', + '/goal pause', + '/goal edit revised objective', '/goal clear', - '/goal replace the active goal', ]); - expect(result.current.messageQueue).toEqual(['/model']); + expect(result.current.messageQueue).toEqual([ + 'plain user text', + '/model', + ]); }); it('leaves goal commands queued at the idle boundary', () => { @@ -346,39 +759,20 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual(['steer now', 'newer input']); }); - - it('drops provenance when interrupted steer messages are restored', () => { - const { result } = renderHook(() => useMessageQueue()); - - act(() => { - result.current.addMessage('steer now', false, 'raw steer'); - }); - act(() => { - const drained = result.current.drainQueue(); - result.current.restoreMessages(drained); - }); - - let submission: QueuedSubmission | null = null; - act(() => { - submission = result.current.popNextTurn(); - }); - - expect(submission).toEqual({ modelText: 'steer now' }); - }); }); - describe('popNextTurn', () => { + describe('popNextSegment', () => { it('returns null when the queue is empty', () => { const { result } = renderHook(() => useMessageQueue()); - let submission: QueuedSubmission | null = null; + let segment: string | null = null; act(() => { - submission = result.current.popNextTurn(); + segment = result.current.popNextSegment(); }); - expect(submission).toBeNull(); + expect(segment).toBeNull(); }); - it('pops the first slash command and leaves the rest queued', () => { + it('pops the first item and leaves the rest queued', () => { const { result } = renderHook(() => useMessageQueue()); act(() => { @@ -386,15 +780,15 @@ describe('useMessageQueue', () => { result.current.addMessage('/help'); }); - let submission: QueuedSubmission | null = null; + let segment: string | null = null; act(() => { - submission = result.current.popNextTurn(); + segment = result.current.popNextSegment(); }); - expect(submission).toEqual({ modelText: '/model' }); + expect(segment).toBe('/model'); expect(result.current.messageQueue).toEqual(['/help']); }); - it('drains slash commands one item at a time across repeated calls', () => { + it('drains the queue one item at a time across repeated calls', () => { const { result } = renderHook(() => useMessageQueue()); act(() => { @@ -403,67 +797,22 @@ describe('useMessageQueue', () => { result.current.addMessage('/help'); }); - const submissions: Array = []; + const segments: Array = []; act(() => { - submissions.push(result.current.popNextTurn()); + segments.push(result.current.popNextSegment()); }); act(() => { - submissions.push(result.current.popNextTurn()); + segments.push(result.current.popNextSegment()); }); act(() => { - submissions.push(result.current.popNextTurn()); + segments.push(result.current.popNextSegment()); }); act(() => { - submissions.push(result.current.popNextTurn()); + segments.push(result.current.popNextSegment()); }); - expect(submissions).toEqual([ - { modelText: '/model' }, - { modelText: '/theme' }, - { modelText: '/help' }, - null, - ]); + expect(segments).toEqual(['/model', '/theme', '/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', - }); - }); }); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index da312b3cbc5..d7edc45478f 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -4,75 +4,224 @@ * 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: () => string | undefined; + hasQueuedUserMessages: () => boolean; + getPendingSubmissionCount: () => number; + claimGoalTurn: () => QueuedGoalTurn | undefined; + claimDirectUserAdmission: () => DirectUserAdmission; + removeGoalTurns: () => number; + 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[]; + popNextSegment: () => string | null; } -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( + () => 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((): number => { + const removed = goalQueueRef.current.length; + if (removed === 0) return 0; + goalQueueRef.current = []; + setQueuedGoalTurns([]); + return removed; + }, []); + + 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 +229,86 @@ 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 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); + }, + [], + ); - const popNextTurn = useCallback((): QueuedSubmission | null => { + const popNextSegment = useCallback((): string | 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)); + const [head, ...rest] = current; queueRef.current = rest; setQueuedMessages(rest); - return aggregateMessages(messages); + return head.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, + popNextSegment, }; } diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 2e2483acc2f..e1bdfcfe36e 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(), @@ -811,17 +798,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 208cf0a8d53..646da4a2dca 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 5bac5e8e259..29baace5db0 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 00000000000..2dd82159484 --- /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(false); + 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 00000000000..36cffe247b0 --- /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': + case 'verifier_reject': + return false; + 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 bbec090ad25..f12172627f4 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 b0ab0c2aa34..591c096b2c5 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 1e76ed08029..aa981c31436 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 394141ecaaf..be01307e278 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -16,7 +16,10 @@ import type { AtCommandRecordPayload, HistoryGap, } from '@qwen-code/qwen-code-core'; -import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core'; +import { + getToolResponseDisplayText, + parseGoalStateRecordPayloadV2, +} from '@qwen-code/qwen-code-core'; import type { HistoryItem, HistoryItemInfo, @@ -30,6 +33,7 @@ import { formatHistoryGapNotice, indexGapsByChild, } from './history-gap-notice.js'; +import { shouldDisplayGoalStateCause } from './goal-runtime.js'; /** * Extracts text content from a Content object's parts (excluding thought parts). @@ -269,6 +273,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) { @@ -317,6 +336,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/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 92cacff0539..f06b5f34805 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -2100,6 +2100,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 53823c89b1a..1717ce742ab 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -4744,6 +4744,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 488f9662065..c28896a4c10 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-tools.ts b/packages/core/src/goals/goal-tools.ts index a13e2497c4f..92059a672e6 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 1cce7a6202a..4d7a68f46ef 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 8fda6149022..af6e63bfecb 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 23d0e8efb0c..1e04f7685fe 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -540,6 +540,9 @@ export interface ToolResult { * turns within the same agentic loop. */ modelOverride?: string; + + /** End the current agent turn after recording this successful result. */ + terminateTurn?: boolean; } /** From 87b2a74687ac2a3223e4eb55c41b8149c6e732ed Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:14:04 +0800 Subject: [PATCH 02/15] fix(cli): restore Goal CI contracts --- 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/ui/commands/goalCommand.ts | 4 ++-- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4ee231dbcb9..5c7a1f579a6 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2463,6 +2463,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 08c5e41ad68..1164a804efe 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2002,6 +2002,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 95d293cece6..025beea9439 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2208,6 +2208,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/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 4d4985c9d49..a2dc4bb94f1 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -31,7 +31,7 @@ import { MessageType, type HistoryItemGoalStatus } from '../types.js'; import { installGoalTerminalObserver } from '../utils/restoreGoal.js'; import { formatDuration } from '../utils/formatters.js'; -const LEGACY_CLEAR_KEYWORDS = new Set([ +const CLEAR_KEYWORDS = new Set([ 'clear', 'stop', 'off', @@ -95,7 +95,7 @@ async function runLegacyGoalCommand( }; } - if (LEGACY_CLEAR_KEYWORDS.has(objective.toLowerCase())) { + if (CLEAR_KEYWORDS.has(objective.toLowerCase())) { const cleared = unregisterGoalHook(config, sessionId); if (!cleared) { return { From 3c8197b0dead02f4c7535dc514986905148807b9 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:38:15 +0000 Subject: [PATCH 03/15] 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. --- .../cli/src/ui/commands/goalCommand.test.ts | 7 +++++++ packages/cli/src/ui/commands/goalCommand.ts | 2 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 8 ++++++-- packages/cli/src/ui/hooks/useGeminiStream.ts | 15 +++++++++------ .../cli/src/ui/hooks/useMessageQueue.test.ts | 19 +++++++++++++++++++ packages/cli/src/ui/hooks/useMessageQueue.ts | 7 +++++-- .../cli/src/ui/hooks/useResumeCommand.test.ts | 3 +++ 7 files changed, 50 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index fe31cdda3e3..b0c0b2ac253 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -67,6 +67,12 @@ describe('parseGoalCommand', () => { ['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' }], @@ -76,6 +82,7 @@ describe('parseGoalCommand', () => { ['/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); }); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index a2dc4bb94f1..d00c614a481 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -207,7 +207,7 @@ export function parseGoalCommand(args: string): ParsedGoalCommand { if (tail.length === 0) { if (keyword === 'pause') return { kind: 'pause' }; if (keyword === 'resume') return { kind: 'resume' }; - if (keyword === 'clear') return { kind: 'clear' }; + if (CLEAR_KEYWORDS.has(keyword)) return { kind: 'clear' }; } return { kind: 'set', objective: input }; } diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 7b77cbded1c..adacd098a1e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -453,7 +453,9 @@ describe('useGeminiStream', () => { continuationContext: 'continue from the last accepted evidence', verifierFeedback: 'show the final verification result', }; - const peekNextUserBatchKey = vi.fn(() => 'message-queue:next-user'); + const peekNextUserBatchKey = vi.fn((goalTurnActive?: boolean) => + goalTurnActive ? undefined : 'message-queue:next-user', + ); const { result, mockSendMessageStream: streamMock } = renderTestHook( [], undefined, @@ -499,7 +501,9 @@ describe('useGeminiStream', () => { getQueuedGoalTurnKey: () => string | undefined; }; expect(options.goalSignal).not.toBe(streamMock.mock.calls[0][1]); - expect(options.getQueuedGoalTurnKey()).toBe('message-queue:next-user'); + // 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 }), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 21c3dbabb74..862b7df0758 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -482,7 +482,7 @@ export const useGeminiStream = ( terminalWidthRef?: React.RefObject, midTurnRestoreRef?: React.RefObject<((messages: string[]) => void) | null>, goalQueueRef?: React.RefObject<{ - peekNextUserBatchKey: () => string | undefined; + peekNextUserBatchKey: (goalTurnActive?: boolean) => string | undefined; claimDirectUserAdmission?: () => DirectUserAdmission; claimGoalTurn?: () => QueuedGoalTurn | undefined; hasQueuedUserMessages?: () => boolean; @@ -3232,7 +3232,7 @@ export const useGeminiStream = ( goalSignal: goalBinding.controller.signal, goalOrigin: goalBinding.origin, getQueuedGoalTurnKey: () => - goalQueueRef?.current?.peekNextUserBatchKey(), + goalQueueRef?.current?.peekNextUserBatchKey(true), } : userAdmission ? { @@ -3240,12 +3240,9 @@ export const useGeminiStream = ( goalSignal: turnController!.signal, goalOrigin: 'user' as const, getQueuedGoalTurnKey: () => - goalQueueRef?.current?.peekNextUserBatchKey(), + goalQueueRef?.current?.peekNextUserBatchKey(true), } : {}), - ...(!allowConcurrentBtwDuringResponse && midTurnDrainRef - ? { getSteerInput: drainSteerAtBoundary } - : {}), }, ); @@ -4207,6 +4204,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; } diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index e818c540eb0..b0a7c30f7a2 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -164,6 +164,25 @@ describe('useMessageQueue', () => { expect(queue.peekNextUserBatchKey!()).toBeUndefined(); }); + 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', diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index d7edc45478f..f18e5e5a0e9 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -43,7 +43,7 @@ export interface UseMessageQueueReturn { enqueueGoalTurn: ( input: Parameters[0], ) => void; - peekNextUserBatchKey: () => string | undefined; + peekNextUserBatchKey: (goalTurnActive?: boolean) => string | undefined; hasQueuedUserMessages: () => boolean; getPendingSubmissionCount: () => number; claimGoalTurn: () => QueuedGoalTurn | undefined; @@ -139,7 +139,10 @@ export function useMessageQueue(): UseMessageQueueReturn { ); const peekNextUserBatchKey = useCallback( - () => queueRef.current.find(({ text }) => !isSlashCommand(text))?.key, + (goalTurnActive = false) => + goalTurnActive + ? undefined + : queueRef.current.find(({ text }) => !isSlashCommand(text))?.key, [], ); const hasQueuedUserMessages = useCallback( diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index e1bdfcfe36e..1ad5dfbca1d 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -790,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(); From 336f7f88624c7b021e4a0a7fcb479ddd1624dd4b Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Wed, 29 Jul 2026 18:14:49 +0000 Subject: [PATCH 04/15] fix(cli): address review feedback on Goal queue and retry hint (#8005) --- packages/cli/src/ui/AppContainer.test.tsx | 4 +- packages/cli/src/ui/AppContainer.tsx | 8 +- packages/cli/src/ui/commands/goalCommand.ts | 27 +++- packages/cli/src/ui/hooks/useGeminiStream.ts | 5 +- .../cli/src/ui/hooks/useMessageQueue.test.ts | 126 ++++++++---------- packages/cli/src/ui/hooks/useMessageQueue.ts | 21 +-- 6 files changed, 98 insertions(+), 93 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index e3160bca65d..8be6a86c4a4 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1304,7 +1304,7 @@ describe('AppContainer State Management', () => { it('binds one Goal host that enqueues, preempts, and cleans up', async () => { const enqueueGoalTurn = vi.fn(); - const removeGoalTurns = vi.fn().mockReturnValue(1); + const removeGoalTurns = vi.fn().mockReturnValue([]); const preemptGoalTurn = vi.fn(); const submitQuery = vi.fn(); const unbind = vi.fn(); @@ -1861,6 +1861,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', @@ -1910,6 +1911,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', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d52f28b1072..dbbd83186c8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2322,12 +2322,16 @@ export const AppContainer = (props: AppContainerProps) => { ); const popAllQueuedMessages = useCallback((): string | null => { - const submission = popAllMessages(releaseQueuedGoalReservations); + 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, releaseQueuedGoalReservations]); + }, [popAllMessages, releaseQueuedGoalReservations, removeGoalTurns]); useEffect(() => { const host: GoalTurnHost = { diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index d00c614a481..9f95e328ecc 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -31,6 +31,9 @@ import { MessageType, type HistoryItemGoalStatus } from '../types.js'; import { installGoalTerminalObserver } from '../utils/restoreGoal.js'; import { formatDuration } from '../utils/formatters.js'; +// Mirrored by GOAL_CLEAR_KEYWORDS in +// packages/web-shell/client/utils/goalCondition.ts, whose test reads this +// literal and fails on drift. const CLEAR_KEYWORDS = new Set([ 'clear', 'stop', @@ -44,13 +47,25 @@ function formatLegacyTurns(count: number): string { return `${count} ${count === 1 ? 'turn' : 'turns'}`; } +function assertNeverTerminalKind(kind: never): never { + throw new Error(`Unexpected GoalTerminalKind: ${kind}`); +} + function formatLegacyTerminalSummary(event: GoalTerminalEvent): string { - const title = - event.kind === 'achieved' - ? 'Goal achieved' - : event.kind === 'failed' - ? 'Goal could not be achieved' - : 'Goal aborted'; + let title: string; + switch (event.kind) { + case 'achieved': + title = 'Goal achieved'; + break; + case 'failed': + title = 'Goal could not be achieved'; + break; + case 'aborted': + title = 'Goal aborted'; + break; + default: + title = assertNeverTerminalKind(event.kind); + } const stats: string[] = []; if (event.iterations > 0) stats.push(formatLegacyTurns(event.iterations)); if (typeof event.durationMs === 'number') { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 862b7df0758..f04b2b92ad0 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3393,7 +3393,10 @@ export const useGeminiStream = ( if (submitType !== SendMessageType.Goal) { lastPromptErroredRef.current = true; } - 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 (same as handleErrorEvent) setPendingRetryErrorItem({ type: 'error' as const, diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index b0a7c30f7a2..9c85b74563e 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -238,18 +238,22 @@ describe('useMessageQueue', () => { }); permit.revision = 99; - const submission = result.current.popNextSubmission(); + let submission: unknown; + act(() => { + submission = result.current.popNextSubmission(); + }); - expect(submission?.kind).toBe('goal'); - if (!submission || submission.kind !== 'goal') { - throw new Error('Expected a queued Goal turn'); - } - expect(submission.permit).toEqual({ + 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(submission.permit).not.toBe(permit); + expect(goalSubmission.permit).not.toBe(permit); }); it('creates a stable direct-user admission that claims a hidden Goal', () => { @@ -408,19 +412,24 @@ describe('useMessageQueue', () => { result.current.addMessage('keep me'); }); const queue = result.current as typeof result.current & { - removeGoalTurns?: () => number; + removeGoalTurns?: () => string[]; }; expect(queue.removeGoalTurns).toBeTypeOf('function'); - let removed = 0; + let removedKeys: string[] = []; act(() => { - removed = queue.removeGoalTurns!(); + removedKeys = queue.removeGoalTurns!(); }); - expect(removed).toBe(1); + expect(removedKeys).toHaveLength(1); + expect(removedKeys[0]).toMatch(/^goal-runtime:/); expect(result.current.messageQueue).toEqual(['keep me']); expect(result.current.pendingSubmissionCount).toBe(1); - expect(result.current.popNextSubmission()).toMatchObject({ + let kept: unknown; + act(() => { + kept = result.current.popNextSubmission(); + }); + expect(kept).toMatchObject({ kind: 'user', modelText: 'keep me', }); @@ -515,6 +524,44 @@ describe('useMessageQueue', () => { 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).toMatchObject({ + kind: 'user', + modelText: 'msg A\n\nmsg B', + submittedPrompt: 'prompt A\n\nprompt B', + }); + }); + + it('omits submittedPrompt when any message lacks one', () => { + const { result } = renderHook(() => useMessageQueue()); + act(() => { + result.current.addMessage('msg A', false, 'prompt A'); + result.current.addMessage('msg B'); + }); + + let popped: ReturnType = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + 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', () => { @@ -779,59 +826,4 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual(['steer now', 'newer input']); }); }); - - describe('popNextSegment', () => { - it('returns null when the queue is empty', () => { - const { result } = renderHook(() => useMessageQueue()); - - let segment: string | null = null; - act(() => { - segment = result.current.popNextSegment(); - }); - expect(segment).toBeNull(); - }); - - it('pops the first item and leaves the rest queued', () => { - const { result } = renderHook(() => useMessageQueue()); - - act(() => { - result.current.addMessage('/model'); - result.current.addMessage('/help'); - }); - - let segment: string | null = null; - act(() => { - segment = result.current.popNextSegment(); - }); - expect(segment).toBe('/model'); - expect(result.current.messageQueue).toEqual(['/help']); - }); - - it('drains the queue 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'); - }); - - const segments: Array = []; - act(() => { - segments.push(result.current.popNextSegment()); - }); - act(() => { - segments.push(result.current.popNextSegment()); - }); - act(() => { - segments.push(result.current.popNextSegment()); - }); - act(() => { - segments.push(result.current.popNextSegment()); - }); - - expect(segments).toEqual(['/model', '/theme', '/help', null]); - expect(result.current.messageQueue).toEqual([]); - }); - }); }); diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index f18e5e5a0e9..a9b05d10fe5 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -48,7 +48,7 @@ export interface UseMessageQueueReturn { getPendingSubmissionCount: () => number; claimGoalTurn: () => QueuedGoalTurn | undefined; claimDirectUserAdmission: () => DirectUserAdmission; - removeGoalTurns: () => number; + removeGoalTurns: () => string[]; popNextSubmission: ( goalControlMode?: GoalQueueControlMode, ) => QueuedSubmission | null; @@ -59,7 +59,6 @@ export interface UseMessageQueueReturn { ) => QueuedUserSubmission | null; restoreMessages: (messages: string[], submittedPrompt?: string) => void; drainQueue: (includeDeferred?: boolean, goalTurnActive?: boolean) => string[]; - popNextSegment: () => string | null; } interface QueuedMessage { @@ -171,12 +170,12 @@ export function useMessageQueue(): UseMessageQueueReturn { }; }, [claimGoalTurn, nextMessageKey]); - const removeGoalTurns = useCallback((): number => { - const removed = goalQueueRef.current.length; - if (removed === 0) return 0; + const removeGoalTurns = useCallback((): string[] => { + const keys = goalQueueRef.current.map(({ turnKey }) => turnKey); + if (keys.length === 0) return []; goalQueueRef.current = []; setQueuedGoalTurns([]); - return removed; + return keys; }, []); const popNextSubmission = useCallback( @@ -286,15 +285,6 @@ export function useMessageQueue(): UseMessageQueueReturn { [], ); - const popNextSegment = useCallback((): string | null => { - const current = queueRef.current; - if (current.length === 0) return null; - const [head, ...rest] = current; - queueRef.current = rest; - setQueuedMessages(rest); - return head.text; - }, []); - return { messageQueue: queuedMessages.map(({ text }) => text), pendingSubmissionCount: queuedMessages.length + queuedGoalTurns.length, @@ -312,6 +302,5 @@ export function useMessageQueue(): UseMessageQueueReturn { popAllMessages, restoreMessages, drainQueue, - popNextSegment, }; } From f100345fb0ac1c9caed4aaf8a905ae7e346b8499 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Wed, 29 Jul 2026 21:41:41 +0000 Subject: [PATCH 05/15] fix(cli): restore Goal trust gate and address review feedback (#8005) --- .../cli/src/ui/commands/goalCommand.test.ts | 46 ++++++++++++-- packages/cli/src/ui/commands/goalCommand.ts | 14 +++++ .../cli/src/ui/components/GoalPill.test.tsx | 7 ++- .../ui/hooks/slashCommandProcessor.test.ts | 48 ++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 9 ++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 37 +++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 19 ++++-- .../cli/src/ui/hooks/useMessageQueue.test.ts | 62 +++++++++++++++++++ 8 files changed, 230 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index b0c0b2ac253..22f89ab4dc3 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -49,11 +49,12 @@ function makeRuntime( return { dispatch, getSnapshot, runtime }; } -function makeContext(runtime: GoalRuntime) { +function makeContext(runtime: GoalRuntime, { trusted = true } = {}) { const getGoalRuntimeReady = vi.fn().mockResolvedValue(runtime); - const config = { getGoalRuntimeReady } as unknown as Config; + const isTrustedFolder = vi.fn(() => trusted); + const config = { getGoalRuntimeReady, isTrustedFolder } as unknown as Config; const context = createMockCommandContext({ services: { config } }); - return { context, getGoalRuntimeReady }; + return { context, getGoalRuntimeReady, isTrustedFolder }; } describe('parseGoalCommand', () => { @@ -289,7 +290,7 @@ describe('goalCommand', () => { expect(dispatch).not.toHaveBeenCalled(); }); - it('works with a bare config that exposes no trust or hook services', async () => { + 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 }); @@ -304,10 +305,45 @@ describe('goalCommand', () => { 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 } as unknown as Config; + const config = { + getGoalRuntimeReady, + isTrustedFolder: () => true, + } as unknown as Config; const context = createMockCommandContext({ services: { config } }); const result = await goalCommand.action!(context, 'status objective'); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 9f95e328ecc..8c99c6649eb 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -272,6 +272,20 @@ export const goalCommand: SlashCommand = { const operation = parseGoalCommand(args); if (operation.kind === 'error') return errorMessage(operation.message); + // 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.', + ); + } + try { const runtime = await config.getGoalRuntimeReady(); const snapshot = runtime.getSnapshot(); diff --git a/packages/cli/src/ui/components/GoalPill.test.tsx b/packages/cli/src/ui/components/GoalPill.test.tsx index eda37a72084..533d11e9326 100644 --- a/packages/cli/src/ui/components/GoalPill.test.tsx +++ b/packages/cli/src/ui/components/GoalPill.test.tsx @@ -109,7 +109,7 @@ describe('GoalPill', () => { ['complete', snapshot('complete'), ''], ])('renders accessible lifecycle text for %s', (_name, value, expected) => { vi.setSystemTime(NOW); - const { lastFrame } = renderPill({ snapshot: value }); + const { lastFrame, unmount } = renderPill({ snapshot: value }); if (expected) { expect(lastFrame()).toContain(expected); @@ -118,15 +118,18 @@ describe('GoalPill', () => { } else { expect(lastFrame()).toBe(''); } + // Active snapshots start a real elapsed-time interval; unmount clears it. + unmount(); }); it('adds the current active span to persisted active time', () => { vi.setSystemTime(NOW); - const { lastFrame } = renderPill({ + const { lastFrame, unmount } = renderPill({ snapshot: snapshot('active', 'running'), }); expect(lastFrame()).toContain('(5s)'); + unmount(); }); it('keeps paused elapsed time frozen while wall clock advances', () => { diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index c478d4d81ac..dd8c8de8f10 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -527,6 +527,54 @@ describe('useSlashCommandProcessor', () => { ); }); + 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('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 cade91bf513..e98b78a3cb2 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -1066,7 +1066,14 @@ export const useSlashCommandProcessor = ( } return { type: 'handled' }; case 'goal_control': { - if (commandContext.ui.isIdleRef.current) { + // `status` is a pure read that 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.operation.kind === 'status' || + commandContext.ui.isIdleRef.current; + if (rendersHere) { const snapshot = result.response.snapshot; if (snapshot.goal || result.cause === 'clear') { addItem( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index adacd098a1e..1e5a7e2b1cc 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -9690,6 +9690,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 { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f04b2b92ad0..997f5993317 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1825,8 +1825,14 @@ 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; + } // Persist any streamed reasoning (collapsed) above the error. commitPendingThought(userMessageTimestamp); if (pendingHistoryItemRef.current) { @@ -1844,7 +1850,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 ). @@ -2105,6 +2114,7 @@ export const useGeminiStream = ( stream: AsyncIterable, userMessageTimestamp: number, signal: AbortSignal, + submitType: SendMessageType, turnAdmission?: GoalTurnAdmission, ): Promise => { let geminiMessageBuffer = ''; @@ -2265,7 +2275,7 @@ export const useGeminiStream = ( }; case ServerGeminiEventType.Error: flushBufferedStreamEvents(); - handleErrorEvent(event.value, userMessageTimestamp); + handleErrorEvent(event.value, userMessageTimestamp, submitType); break; case ServerGeminiEventType.ChatCompressed: flushBufferedStreamEvents(); @@ -3250,6 +3260,7 @@ export const useGeminiStream = ( stream, userMessageTimestamp, processingSignal, + submitType, turnAdmission, ); if ( diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index 9c85b74563e..148f541c1b0 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -164,6 +164,30 @@ describe('useMessageQueue', () => { 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(() => { @@ -825,5 +849,43 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual(['steer now', 'newer input']); }); + + it('preserves submittedPrompt provenance when restoring one interrupted message', () => { + const { result } = renderHook(() => useMessageQueue()); + + act(() => { + result.current.restoreMessages(['steer now'], 'original prompt'); + }); + + let popped: ReturnType = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + expect(popped).toMatchObject({ + kind: 'user', + modelText: 'steer now', + submittedPrompt: 'original prompt', + }); + }); + + it('drops submittedPrompt provenance when restoring multiple messages', () => { + const { result } = renderHook(() => useMessageQueue()); + + act(() => { + result.current.restoreMessages(['first', 'second'], 'original prompt'); + }); + + let popped: ReturnType = null; + act(() => { + popped = result.current.popAllMessages(); + }); + + expect(popped).toMatchObject({ + kind: 'user', + modelText: 'first\n\nsecond', + }); + expect(popped!.submittedPrompt).toBeUndefined(); + }); }); }); From de3086d79630b85e17942a68a7ed7ffb427ce734 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Thu, 30 Jul 2026 01:29:29 +0000 Subject: [PATCH 06/15] fix(cli): address Goal review feedback on cancel release, icons, claim gate (#8005) --- packages/cli/src/ui/AppContainer.test.tsx | 83 ++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 7 +- .../components/messages/GoalStatusMessage.tsx | 21 ++-- packages/cli/src/ui/constants.ts | 2 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 99 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 1 + 6 files changed, 202 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 8be6a86c4a4..d986c8a5ebb 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -343,6 +343,7 @@ describe('AppContainer State Management', () => { restartReason: 'NONE', }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -530,6 +531,7 @@ describe('AppContainer State Management', () => { }); const addMessage = vi.fn(); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage, clearQueue: vi.fn(), @@ -1662,6 +1664,7 @@ describe('AppContainer State Management', () => { isReceivingContent: false, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -1709,6 +1712,7 @@ describe('AppContainer State Management', () => { isReceivingContent: false, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -1756,6 +1760,7 @@ describe('AppContainer State Management', () => { isReceivingContent: false, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -1804,6 +1809,7 @@ describe('AppContainer State Management', () => { isReceivingContent: false, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2015,6 +2021,7 @@ describe('AppContainer State Management', () => { }, ); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2075,6 +2082,7 @@ describe('AppContainer State Management', () => { }, ); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage, clearQueue: vi.fn(), @@ -2115,6 +2123,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(), @@ -2143,6 +2152,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(), @@ -2175,6 +2185,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(), @@ -2221,6 +2232,7 @@ describe('AppContainer State Management', () => { }, ); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2269,6 +2281,7 @@ describe('AppContainer State Management', () => { vimMode: 'INSERT', }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2315,6 +2328,7 @@ describe('AppContainer State Management', () => { }; mockedUseVimModeState.mockImplementation(useMockVimModeState); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2363,6 +2377,7 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2413,6 +2428,7 @@ describe('AppContainer State Management', () => { isReceivingContent: false, }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2522,6 +2538,7 @@ describe('AppContainer State Management', () => { setText: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -2591,6 +2608,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -2648,6 +2666,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: ['queued follow-up'], addMessage: vi.fn(), clearQueue: mockClearQueue, @@ -2683,6 +2702,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 @@ -2731,6 +2800,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -2812,6 +2882,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -2878,6 +2949,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: mockQueueMessage, clearQueue: vi.fn(), @@ -2952,6 +3024,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3020,6 +3093,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3083,6 +3157,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3148,6 +3223,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3223,6 +3299,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3296,6 +3373,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3366,6 +3444,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3429,6 +3508,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: ['queued thought'], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3508,6 +3588,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: [], addMessage: vi.fn(), clearQueue: vi.fn(), @@ -3583,6 +3664,7 @@ describe('AppContainer State Management', () => { retryLastPrompt: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), messageQueue: ['/model', 'hi'], addMessage: vi.fn(), clearQueue: mockClearQueue, @@ -3633,6 +3715,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 dbbd83186c8..a2c1f9a8283 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2841,7 +2841,11 @@ 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 popped = popAllMessages(releaseQueuedGoalReservations); + const goalTurnKeys = removeGoalTurns(); + if (goalTurnKeys.length > 0) { + releaseQueuedGoalReservations(goalTurnKeys); + } + const popped = popAllMessages(); if (popped) { restoredSubmissionRef.current = popped; submittedPromptProvenanceUnavailableRef.current = false; @@ -3015,6 +3019,7 @@ export const AppContainer = (props: AppContainerProps) => { buffer, popAllMessages, releaseQueuedGoalReservations, + removeGoalTurns, historyManager, logger, geminiClient, diff --git a/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx b/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx index 367e8eaa94d..2550a851b05 100644 --- a/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx +++ b/packages/cli/src/ui/components/messages/GoalStatusMessage.tsx @@ -8,6 +8,7 @@ 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'; @@ -51,7 +52,7 @@ const GoalStateCard: React.FC = ({ return ( - + {ICON.CIRCLE_EMPTY} Goal cleared @@ -63,13 +64,13 @@ const GoalStateCard: React.FC = ({ case 'active': if (snapshot.activity === 'verifying') { return { - prefix: '○', + prefix: ICON.CIRCLE_EMPTY, color: theme.text.secondary, title: 'Goal checking', }; } return { - prefix: '◎', + prefix: ICON.BULLSEYE, color: theme.text.accent, title: snapshot.activity === 'running' ? 'Goal running' : 'Goal active', @@ -82,7 +83,7 @@ const GoalStateCard: React.FC = ({ }; case 'blocked': return { - prefix: '✖', + prefix: ICON.CROSS, color: theme.status.error, title: 'Goal blocked', }; @@ -94,7 +95,7 @@ const GoalStateCard: React.FC = ({ }; case 'complete': return { - prefix: '✓', + prefix: ICON.CHECK, color: theme.status.success, title: 'Goal complete', }; @@ -156,7 +157,7 @@ const GoalStatusMessageInternal: React.FC = (props) => { return ( - + {ICON.CIRCLE_EMPTY} @@ -183,25 +184,25 @@ const GoalStatusMessageInternal: React.FC = (props) => { switch (kind) { case 'set': return { - prefix: '◎', + prefix: ICON.BULLSEYE, prefixColor: theme.text.accent, title: 'Goal set', }; case 'achieved': return { - prefix: '✓', + prefix: ICON.CHECK, prefixColor: theme.status.success, title: 'Goal achieved', }; case 'cleared': return { - prefix: '○', + prefix: ICON.CIRCLE_EMPTY, prefixColor: theme.text.secondary, title: 'Goal cleared', }; case 'failed': return { - prefix: '✖', + prefix: ICON.CROSS, prefixColor: theme.status.error, title: 'Goal could not be achieved', }; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 7fbf498a96f..df76e24d4bb 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/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 1e5a7e2b1cc..8e03b133c8a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -7651,6 +7651,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); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 997f5993317..1c90ddd9f00 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -4089,6 +4089,7 @@ export const useGeminiStream = ( `Goal turn could not finish: ${getErrorMessage(error)}`, ); } finally { + // Idempotent with the release inside failClosedGoalTurn; also covers the success path. releaseGoalTurn(toolGoalBinding); } return; From a255b044f96ff2711628b7774a20e939219a6c67 Mon Sep 17 00:00:00 2001 From: Qwen Code Autofix Date: Thu, 30 Jul 2026 06:04:47 +0000 Subject: [PATCH 07/15] fix(cli): address Goal review feedback on non-interactive guard, icons, and drain retry (#8005) --- packages/cli/src/ui/AppContainer.tsx | 8 +++++++- packages/cli/src/ui/commands/goalCommand.test.ts | 15 +++++++++++++++ packages/cli/src/ui/commands/goalCommand.ts | 11 +++++++++++ packages/cli/src/ui/components/GoalPill.test.tsx | 12 ++++++++---- packages/cli/src/ui/components/GoalPill.tsx | 11 ++++++++--- packages/core/src/tools/tools.ts | 5 ++++- 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a2c1f9a8283..7f5aa861621 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -396,6 +396,8 @@ export function useQueuedSubmissionDrain({ const admissionFailureRef = useRef<{ pendingSubmissionCount: number; goalQueueRevision: number; + streamingState: StreamingState; + isProcessing: boolean; } | null>(null); const [queueDrainNonce, setQueueDrainNonce] = useState(0); useEffect(() => { @@ -406,7 +408,9 @@ export function useQueuedSubmissionDrain({ admissionFailureRef.current = null; } else if ( pendingSubmissionCount <= admissionFailure.pendingSubmissionCount && - goalQueueRevision === admissionFailure.goalQueueRevision + goalQueueRevision === admissionFailure.goalQueueRevision && + streamingState === admissionFailure.streamingState && + isProcessing === admissionFailure.isProcessing ) { return; } else { @@ -451,6 +455,8 @@ export function useQueuedSubmissionDrain({ admissionFailureRef.current = { pendingSubmissionCount: getPendingSubmissionCount(), goalQueueRevision, + streamingState, + isProcessing, }; }; const request = diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index 22f89ab4dc3..ce2ba5b4ac2 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -116,6 +116,21 @@ describe('goalCommand', () => { ]); }); + it.each(['pause', 'resume', 'edit revised'] as const)( + 'rejects /goal %s in non-interactive mode', + async (args) => { + const context = createMockCommandContext({ + executionMode: 'non_interactive', + }); + const result = await goalCommand.action!(context, args); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + content: expect.stringMatching(/only available in interactive mode/i), + }); + }, + ); + it('rejects invalid set and edit commands before runtime admission', async () => { const { runtime } = makeRuntime(noGoalSnapshot()); const { context, getGoalRuntimeReady } = makeContext(runtime); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 8c99c6649eb..803d9c05ee2 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -258,6 +258,17 @@ export const goalCommand: SlashCommand = { args: string, ): 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.`, + ); + } return ( (await runLegacyGoalCommand(context, args)) ?? { type: 'message', diff --git a/packages/cli/src/ui/components/GoalPill.test.tsx b/packages/cli/src/ui/components/GoalPill.test.tsx index 533d11e9326..7aaee245f88 100644 --- a/packages/cli/src/ui/components/GoalPill.test.tsx +++ b/packages/cli/src/ui/components/GoalPill.test.tsx @@ -96,15 +96,19 @@ describe('GoalPill', () => { it.each([ ['no goal', noGoalSnapshot, ''], - ['active and idle', snapshot('active', 'idle'), '◎ /goal active'], - ['active and running', snapshot('active', 'running'), '◎ /goal active'], + ['active and idle', snapshot('active', 'idle'), '◎\uFE0E /goal active'], + [ + 'active and running', + snapshot('active', 'running'), + '◎\uFE0E /goal active', + ], [ 'active and verifying', snapshot('active', 'verifying'), - '○ /goal checking', + '○\uFE0E /goal checking', ], ['paused', snapshot('paused'), '! /goal paused'], - ['blocked', snapshot('blocked'), '✖ /goal blocked'], + ['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) => { diff --git a/packages/cli/src/ui/components/GoalPill.tsx b/packages/cli/src/ui/components/GoalPill.tsx index 56b1951d605..36326d14373 100644 --- a/packages/cli/src/ui/components/GoalPill.tsx +++ b/packages/cli/src/ui/components/GoalPill.tsx @@ -15,6 +15,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { useConfig } from '../contexts/ConfigContext.js'; import { theme } from '../semantic-colors.js'; +import { ICON } from '../constants.js'; const ELAPSED_REFRESH_MS = 1000; @@ -83,14 +84,18 @@ function presentation(snapshot: GoalSnapshotV2): { if (goal.status === 'active') { return snapshot.activity === 'verifying' - ? { icon: '○', label: 'checking', color: theme.text.secondary } - : { icon: '◎', label: 'active', color: theme.text.accent }; + ? { + 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: '✖', label: 'blocked', color: theme.status.error }; + return { icon: ICON.CROSS, label: 'blocked', color: theme.status.error }; case 'usage_limited': return { icon: '!', diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 1e04f7685fe..3bd57776e7a 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -541,7 +541,10 @@ export interface ToolResult { */ modelOverride?: string; - /** End the current agent turn after recording this successful result. */ + /** + * End the current Goal turn after recording this successful result. Only + * honored when the tool batch carries a Goal context; ignored otherwise. + */ terminateTurn?: boolean; } From 68df45e1cc4d06d8d8203fddd9d0decb2f1db9f5 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 04:09:36 +0000 Subject: [PATCH 08/15] 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 --- .../cli/src/ui/commands/goalCommand.test.ts | 49 ++++++++++++++++++- packages/cli/src/ui/commands/goalCommand.ts | 3 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 42 ++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 10 +++- .../cli/src/ui/utils/goal-runtime.test.ts | 2 +- packages/cli/src/ui/utils/goal-runtime.ts | 2 +- packages/core/src/goals/goal-reducer.test.ts | 22 +++++++++ packages/core/src/goals/goal-reducer.ts | 1 + .../goals/goal-runtime.integration.test.ts | 12 +++-- packages/core/src/goals/goal-runtime.test.ts | 25 ++++++++++ packages/core/src/goals/goal-runtime.ts | 27 ++++++++++ 11 files changed, 184 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index ce2ba5b4ac2..3e82a1cb2db 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Config, GoalRuntime, @@ -14,6 +14,23 @@ import type { import { goalCommand, parseGoalCommand } from './goalCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +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 { + ...actual, + registerGoalHook: mockRegisterGoalHook, + getActiveGoal: mockGetActiveGoal, + getLastGoalTerminal: mockGetLastGoalTerminal, + unregisterGoalHook: mockUnregisterGoalHook, + }; +}); + function goalSnapshot( overrides: Partial> = {}, ): GoalSnapshotV2 { @@ -108,6 +125,13 @@ describe('parseGoalCommand', () => { }); describe('goalCommand', () => { + beforeEach(() => { + mockRegisterGoalHook.mockReset(); + mockGetActiveGoal.mockReset(); + mockGetLastGoalTerminal.mockReset(); + mockUnregisterGoalHook.mockReset(); + }); + it('is available in interactive, non-interactive, and ACP modes', () => { expect(goalCommand.supportedModes).toEqual([ 'interactive', @@ -131,6 +155,29 @@ describe('goalCommand', () => { }, ); + it('strips the set keyword before forwarding to the legacy path in non-interactive mode', async () => { + mockRegisterGoalHook.mockReturnValue({ + condition: 'Ship it', + 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 }, + }); + + await goalCommand.action!(context, 'set Ship it'); + + expect(mockRegisterGoalHook).toHaveBeenCalledWith( + expect.objectContaining({ condition: 'Ship it' }), + ); + }); + it('rejects invalid set and edit commands before runtime admission', async () => { const { runtime } = makeRuntime(noGoalSnapshot()); const { context, getGoalRuntimeReady } = makeContext(runtime); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 803d9c05ee2..11938e77c96 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -269,8 +269,9 @@ export const goalCommand: SlashCommand = { `'/goal ${operation.kind}' is only available in interactive mode.`, ); } + const legacyArgs = operation.kind === 'set' ? operation.objective : args; return ( - (await runLegacyGoalCommand(context, args)) ?? { + (await runLegacyGoalCommand(context, legacyArgs)) ?? { type: 'message', messageType: 'info', content: 'Command executed successfully.', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 8a533de7e3e..d5c8a46ee41 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -9979,6 +9979,48 @@ 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('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 6f12e2bb961..b452fe670e6 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -649,6 +649,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' @@ -1832,6 +1833,8 @@ export const useGeminiStream = ( ) => { if (submitType !== SendMessageType.Goal) { lastPromptErroredRef.current = true; + } else { + goalTerminalErrorRef.current = true; } // Persist any streamed reasoning (collapsed) above the error. commitPendingThought(userMessageTimestamp); @@ -3150,6 +3153,7 @@ export const useGeminiStream = ( } const finalQueryToSend = queryToSend; + goalTerminalErrorRef.current = false; if (submitType !== SendMessageType.Goal) { lastPromptRef.current = finalQueryToSend; lastPromptErroredRef.current = false; @@ -3346,12 +3350,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; diff --git a/packages/cli/src/ui/utils/goal-runtime.test.ts b/packages/cli/src/ui/utils/goal-runtime.test.ts index 2dd82159484..9c6d809dbff 100644 --- a/packages/cli/src/ui/utils/goal-runtime.test.ts +++ b/packages/cli/src/ui/utils/goal-runtime.test.ts @@ -35,7 +35,7 @@ describe('waitForGoalRuntime', () => { 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(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 index 36cffe247b0..73a23b1ec8f 100644 --- a/packages/cli/src/ui/utils/goal-runtime.ts +++ b/packages/cli/src/ui/utils/goal-runtime.ts @@ -14,8 +14,8 @@ export function shouldDisplayGoalStateCause(cause: GoalStateCause): boolean { switch (cause) { case 'turn_finished': case 'verifier_accept': - case 'verifier_reject': return false; + case 'verifier_reject': case 'create': case 'replace': case 'edit': diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index 698af1c1b3b..8bc2131efc1 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 ' }, diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index ae2bf87b060..a8b57bcd59a 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, }); } diff --git a/packages/core/src/goals/goal-runtime.integration.test.ts b/packages/core/src/goals/goal-runtime.integration.test.ts index 6b4da97a5fc..421537b4630 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 b52d9d17fb9..c27968e5108 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,30 @@ 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('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 06a5049b8ba..4483f8b7e34 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,32 @@ export function createGoalRuntime( ) { return; } + if (snapshot.goal.turnCount >= MAX_GOAL_CONTINUATION_TURNS) { + void enqueue(async () => { + if (snapshot.goal?.status !== 'active') 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); }; From e611dd45e280924c15e4f6057b1136e950f1407f Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 04:43:03 +0000 Subject: [PATCH 09/15] fix(test): align client-goal turn count with continuation budget (#8005) --- packages/core/src/core/client-goal.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/client-goal.test.ts b/packages/core/src/core/client-goal.test.ts index 1570b6a19cb..de00ff53406 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); }); }); From 92dba3b9781406d41d009def9ea0a380a424cc39 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 07:30:39 +0000 Subject: [PATCH 10/15] 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. --- .../cli/src/ui/commands/goalCommand.test.ts | 55 +++++++++++++++++++ packages/cli/src/ui/commands/goalCommand.ts | 8 ++- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 40 ++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 2 +- 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/commands/goalCommand.test.ts b/packages/cli/src/ui/commands/goalCommand.test.ts index 3e82a1cb2db..fba17b218f5 100644 --- a/packages/cli/src/ui/commands/goalCommand.test.ts +++ b/packages/cli/src/ui/commands/goalCommand.test.ts @@ -178,6 +178,61 @@ describe('goalCommand', () => { ); }); + 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 }, + }); + + const result = await goalCommand.action!(context, `set ${keyword}`); + + expect(mockRegisterGoalHook).toHaveBeenCalledWith( + expect.objectContaining({ condition: keyword }), + ); + expect(mockUnregisterGoalHook).not.toHaveBeenCalled(); + expect(result).toMatchObject({ type: 'submit_prompt' }); + }, + ); + + it('still clears on a bare clear keyword in non-interactive mode', async () => { + mockUnregisterGoalHook.mockReturnValue({ + condition: 'Old goal', + iterations: 2, + 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!(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); diff --git a/packages/cli/src/ui/commands/goalCommand.ts b/packages/cli/src/ui/commands/goalCommand.ts index 11938e77c96..a67efd5aaef 100644 --- a/packages/cli/src/ui/commands/goalCommand.ts +++ b/packages/cli/src/ui/commands/goalCommand.ts @@ -79,6 +79,7 @@ function formatLegacyTerminalSummary(event: GoalTerminalEvent): string { async function runLegacyGoalCommand( context: CommandContext, args: string, + explicitSet = false, ): Promise { const { config } = context.services; if (!config) return errorMessage('Configuration is not available.'); @@ -110,7 +111,7 @@ async function runLegacyGoalCommand( }; } - if (CLEAR_KEYWORDS.has(objective.toLowerCase())) { + if (!explicitSet && CLEAR_KEYWORDS.has(objective.toLowerCase())) { const cleared = unregisterGoalHook(config, sessionId); if (!cleared) { return { @@ -269,9 +270,10 @@ export const goalCommand: SlashCommand = { `'/goal ${operation.kind}' is only available in interactive mode.`, ); } - const legacyArgs = operation.kind === 'set' ? operation.objective : args; + const explicitSet = operation.kind === 'set'; + const legacyArgs = explicitSet ? operation.objective : args; return ( - (await runLegacyGoalCommand(context, legacyArgs)) ?? { + (await runLegacyGoalCommand(context, legacyArgs, explicitSet)) ?? { type: 'message', messageType: 'info', content: 'Command executed successfully.', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d5c8a46ee41..924ed39ad6d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -10021,6 +10021,46 @@ describe('useGeminiStream', () => { }); }); + 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([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index b452fe670e6..483b4abe3d7 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3367,7 +3367,7 @@ export const useGeminiStream = ( handleLoopDetectedEvent(); } - if (lastPromptErroredRef.current) { + if (lastPromptErroredRef.current || goalTerminalErrorRef.current) { metadata?.onDeliveryFailed?.(); } else { metadata?.onDelivered?.(); From ad1bd8c78a8f123128987b453a66dfc59704c98a Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 09:59:54 +0000 Subject: [PATCH 11/15] fix(cli): rename misleading missingActiveGoalContext variable (#8005) --- packages/cli/src/ui/hooks/useGeminiStream.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 483b4abe3d7..88414ca4409 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3782,19 +3782,19 @@ export const useGeminiStream = ( } if (!toolGoalPermit && toolGoalContexts.length > 0) { const active = activeGoalTurnRef.current; - let missingActiveGoalContext = false; + let activeGoalPermitValid = false; if (active) { try { const runtime = config.getGoalRuntime(); const currentPermit = runtime.permitForTurn(active.turnKey); - missingActiveGoalContext = + activeGoalPermitValid = currentPermit !== undefined && sameGoalPermit(currentPermit, active.permit); } catch { // A missing runtime means this is an ordinary non-Goal batch. } } - if (active && missingActiveGoalContext) { + if (active && activeGoalPermitValid) { markToolsAsSubmitted( geminiTools.map((toolCall) => toolCall.request.callId), ); From 6683498d5674aca23ec329a4b21f363a92f1d8ee Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 19:47:06 +0000 Subject: [PATCH 12/15] 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. --- packages/cli/src/ui/AppContainer.test.tsx | 203 ++++++++++++------- packages/cli/src/ui/AppContainer.tsx | 24 ++- packages/cli/src/ui/hooks/useGeminiStream.ts | 9 + 3 files changed, 161 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d986c8a5ebb..7ac5a25a55f 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1382,8 +1382,8 @@ describe('AppContainer State Management', () => { expect(unbind).toHaveBeenCalledTimes(1); }); - it('keeps a held user turn while the Goal resumes and drains it after completion', async () => { - let goalStatus: 'blocked' | 'active' | 'complete' = 'blocked'; + 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 = { @@ -1400,6 +1400,8 @@ describe('AppContainer State Management', () => { 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 { @@ -1427,27 +1429,22 @@ describe('AppContainer State Management', () => { }), ); - await vi.waitFor(() => { - expect(popNextSubmission).toHaveBeenCalledWith('only'); - }); - expect(submitQuery).not.toHaveBeenCalled(); - - goalStatus = 'active'; - act(() => { - goalListener?.(); - }); - + // 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(); - goalStatus = 'complete'; + // 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, @@ -1461,65 +1458,60 @@ describe('AppContainer State Management', () => { expect(unsubscribe).toHaveBeenCalledOnce(); }); - it('drains Goal controls before held user turns while paused', async () => { - const goalRuntime = { - getSnapshot: () => ({ goal: { status: 'paused' } }), - subscribe: () => vi.fn(), - } as unknown as ReturnType; - vi.spyOn(mockConfig, 'getGoalRuntime').mockReturnValue(goalRuntime); + 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 submissionPopped = false; - const popNextSubmission = vi.fn((mode = 'normal') => { - if (submissionPopped) return null; - submissionPopped = true; - return { - kind: 'user' as const, - modelText: - mode === 'only' ? '/goal edit revised objective' : 'held user work', - turnKey: - mode === 'only' - ? 'message-queue:goal-edit' - : 'message-queue:held-user', - }; - }); - - renderHook(() => - useQueuedSubmissionDrain({ - config: mockConfig, - isConfigInitialized: true, - streamingState: StreamingState.Idle, - isProcessing: false, - dialogsVisible: false, - isTranscriptOpen: false, - pendingSubmissionCount: 2, - getPendingSubmissionCount: () => 2, - popNextSubmission, - enqueueGoalTurn: vi.fn(), - restoreMessages: vi.fn(), - submitQuery, - submissionInFlightRef: { current: false }, - submissionSettledRevision: 0, - }), - ); + 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}`, + }; + }); - await vi.waitFor(() => { - expect(popNextSubmission).toHaveBeenCalledWith('only'); - expect(submitQuery).toHaveBeenCalledWith( - '/goal edit revised objective', - SendMessageType.UserQuery, - undefined, - expect.objectContaining({ - userAdmission: { turnKey: 'message-queue:goal-edit' }, + const view = renderHook(() => + useQueuedSubmissionDrain({ + config: mockConfig, + isConfigInitialized: true, + streamingState: StreamingState.Idle, + isProcessing: false, + dialogsVisible: false, + isTranscriptOpen: false, + pendingSubmissionCount: 1, + getPendingSubmissionCount: () => (popped ? 0 : 1), + popNextSubmission, + enqueueGoalTurn: vi.fn(), + restoreMessages: vi.fn(), + submitQuery, + submissionInFlightRef: { current: false }, + submissionSettledRevision: 0, }), ); - }); - expect(submitQuery).not.toHaveBeenCalledWith( - 'held user work', - expect.anything(), - expect.anything(), - expect.anything(), - ); + + // 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 () => { @@ -2471,6 +2463,7 @@ describe('AppContainer State Management', () => { } | null; canUndoLastLoggedUserMessage: boolean; turnProducedMeaningfulContent: boolean; + wasGoalTurn?: boolean; }) => void; let capturedOnCancelSubmit: CapturedCancelSubmit | null = null; @@ -2915,6 +2908,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'; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7f5aa861621..65756f47862 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -433,13 +433,11 @@ export function useQueuedSubmissionDrain({ let goalControlMode: Parameters[0] = 'normal'; try { const status = config.getGoalRuntime().getSnapshot().goal?.status; - if ( - status === 'blocked' || - status === 'usage_limited' || - status === 'paused' - ) { - goalControlMode = 'only'; - } else if (status === 'active') { + // 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 { @@ -2863,6 +2861,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 diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 88414ca4409..8238290073f 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -440,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; } /** @@ -1061,6 +1069,7 @@ export const useGeminiStream = ( lastTurnUserItem: lastTurnUserItemRef.current, canUndoLastLoggedUserMessage: canUndoLastLoggedUserMessageRef.current, turnProducedMeaningfulContent: turnSawContentEventRef.current, + wasGoalTurn: activeGoalTurnRef.current !== null, }); } finally { setIsResponding(false); From bc4327075ccc15a7a3bbc1b7e90c7126d21da8ef Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Fri, 31 Jul 2026 23:05:33 +0000 Subject: [PATCH 13/15] 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. --- .../ui/hooks/slashCommandProcessor.test.ts | 35 ++++++++++++++++++ .../cli/src/ui/hooks/slashCommandProcessor.ts | 11 +++--- packages/core/src/goals/goal-reducer.test.ts | 23 ++++++++++++ packages/core/src/goals/goal-reducer.ts | 8 ++++- packages/core/src/goals/goal-runtime.test.ts | 36 +++++++++++++++++++ 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index dd8c8de8f10..adf551ebf46 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -575,6 +575,41 @@ describe('useSlashCommandProcessor', () => { ); }); + 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 e98b78a3cb2..639557aee26 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -1066,12 +1066,13 @@ export const useSlashCommandProcessor = ( } return { type: 'handled' }; case 'goal_control': { - // `status` is a pure read that 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. + // 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.operation.kind === 'status' || + result.cause === undefined || commandContext.ui.isIdleRef.current; if (rendersHere) { const snapshot = result.response.snapshot; diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index 8bc2131efc1..87bf8ab22e4 100644 --- a/packages/core/src/goals/goal-reducer.test.ts +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -243,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 a8b57bcd59a..b7e54a36f19 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -125,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.test.ts b/packages/core/src/goals/goal-runtime.test.ts index c27968e5108..f8b160e760b 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -948,6 +948,42 @@ describe('goal runtime', () => { 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('returns a bounded catalog without exposing full evidence content', async () => { const journal = fakeGoalJournal(); let records: readonly RuntimeRecord[] = []; From db773c38cc0c7b75fb32438f78fb43c900c8eb02 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Sat, 1 Aug 2026 00:53:57 +0000 Subject: [PATCH 14/15] test(cli): cover Goal tool-batch fail-close guards (#8005) --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 924ed39ad6d..0516b4b4346 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -1765,6 +1765,273 @@ describe('useGeminiStream', () => { ); }); + 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', From e1d6c77cff7e9d51411ea1843cd1c69b40297be0 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot Date: Sat, 1 Aug 2026 09:26:31 +0000 Subject: [PATCH 15/15] 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. --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 131 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 38 +++-- packages/core/src/goals/goal-runtime.test.ts | 48 +++++++ packages/core/src/goals/goal-runtime.ts | 9 +- 4 files changed, 217 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 0516b4b4346..cd34365da5d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2149,6 +2149,137 @@ describe('useGeminiStream', () => { 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[] = [ { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8238290073f..d9edb0fc9da 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3323,15 +3323,26 @@ 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( @@ -3961,15 +3972,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 ( diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index f8b160e760b..f116f6e5f2c 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -984,6 +984,54 @@ describe('goal runtime', () => { 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 4483f8b7e34..66d2762b11e 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -305,8 +305,15 @@ 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') return; + 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 = {