mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
* feat(cli): adopt Goal v3 in interactive TUI * fix(cli): restore Goal CI contracts * fix(cli): stop a queued message from stalling the Goal loop (#8005) Reserve the next Goal turn only for a user batch the queue will actually release: peekNextUserBatchKey now mirrors the two-lane drain gate, so an active Goal turn no longer binds a permit to a held plain message and the loop keeps running. Also release the Goal turn binding on the background-capacity early return, drop a redundant getSteerInput spread, recognise the legacy /goal clear aliases in interactive mode, and restore the rollback assertion for loadPausedBackgroundAgents. * fix(cli): address review feedback on Goal queue and retry hint (#8005) * fix(cli): restore Goal trust gate and address review feedback (#8005) * fix(cli): address Goal review feedback on cancel release, icons, claim gate (#8005) * fix(cli): address Goal review feedback on non-interactive guard, icons, and drain retry (#8005) * fix(cli): address review feedback on Goal v3 TUI (#8005) - Strip 'set' keyword before forwarding to legacy path in non-interactive /goal set, preventing the keyword from leaking into the goal condition - Add goalTerminalErrorRef to prevent post-stream cleanup from wiping Goal turn terminal errors - Add 50-turn continuation budget to Goal runtime, matching the legacy MAX_GOAL_ITERATIONS cap - Display verifier rejection status cards (verifier_reject cause) - Clear stale lastReason when editing a goal objective * fix(test): align client-goal turn count with continuation budget (#8005) * fix(cli): address Goal set-clear and delivery-error review feedback (#8005) Non-interactive `/goal set <clear-keyword>` now sets a literal objective instead of clearing the active goal, by bypassing the clear-keyword check for explicit set operations. Goal-turn stream errors now fire onDeliveryFailed rather than onDelivered by including goalTerminalErrorRef in the post-stream delivery dispatch. * fix(cli): rename misleading missingActiveGoalContext variable (#8005) * fix(cli): stop Goal queue from stranding input and orphaning prompts (#8005) Only an active Goal turn holds ordinary input now; paused, blocked and usage_limited states drain the queue normally so a user whose Goal is merely paused (e.g. via Escape) is never stranded waiting for /goal clear. A cancelled Goal continuation turn also strips its synthetic "no new real user input" prompt from the chat history. Previously the auto-restore branch bailed before its orphan strip ran (Goal turns add no UI user item), so the preamble survived and appendCuratedContent merged the user's next real message into it. * fix(core): address Goal resume-budget and mid-turn clear review feedback (#8005) Resuming a Goal that exhausted its 50-turn continuation budget was accepted and reported as `active`, then immediately re-transitioned to `usage_limited` without running a turn: the resume branch kept the exhausted `turnCount`, which `queueContinuation` re-checks. Resume now resets `turnCount` so an explicit resume grants a fresh continuation budget and the reported outcome matches the settled one. A mid-turn `/goal clear` with no active Goal was silently swallowed because its causeless `goal_control` result rendered only when idle. The handler now renders any causeless result (a `status` read or a no-Goal `clear`), which never broadcasts and so cannot double-render. * test(cli): cover Goal tool-batch fail-close guards (#8005) * fix(core,cli): guard budget-exhaustion identity and pass goalContext to tool-result recording (#8005) The budget-exhaustion callback in queueContinuation only checked goal status, not identity. A replace dispatched during the journal append could create a fresh goal that the stale callback then incorrectly usage-limited. Capture goalId/revision at enqueue time and return early on mismatch, matching the existing identity-guard pattern used by handleStartFailure and recordVerificationOutcome. The TUI recorded tool results without goalContext, so the evidence catalog never admitted tool-result records and any Goal whose objective depended on external state could not be verified. Pass request.goalContext at both recordToolResult call sites in useGeminiStream, tagging get_goal/update_goal results as goal_runtime to match the CoreToolScheduler pattern. --------- Co-authored-by: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
261 lines
8.6 KiB
TypeScript
261 lines
8.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Code
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useState, useCallback } from 'react';
|
|
import {
|
|
SessionService,
|
|
buildSessionRecoveryPlan,
|
|
type Config,
|
|
type SessionListItem,
|
|
} from '@qwen-code/qwen-code-core';
|
|
import {
|
|
buildResumedHistoryItems,
|
|
applyCollapsePolicyAndSummary,
|
|
} from '../utils/resumeHistoryUtils.js';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import { MessageType, type HistoryItemWithoutId } from '../types.js';
|
|
import {
|
|
hasBlockingBackgroundWork,
|
|
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;
|
|
settings: LoadedSettings;
|
|
historyManager: Pick<
|
|
UseHistoryManagerReturn,
|
|
'addItem' | 'clearItems' | 'loadHistory'
|
|
>;
|
|
startNewSession: (sessionId: string) => void;
|
|
setSessionName?: (name: string | null) => void;
|
|
remount?: () => void;
|
|
}
|
|
|
|
export interface UseResumeCommandResult {
|
|
isResumeDialogOpen: boolean;
|
|
/** Pre-filtered sessions for the picker (when multiple title matches). */
|
|
resumeMatchedSessions: SessionListItem[] | undefined;
|
|
openResumeDialog: (matchedSessions?: SessionListItem[]) => void;
|
|
closeResumeDialog: () => void;
|
|
/**
|
|
* Async — the implementation awaits SessionService and SessionStart hooks.
|
|
* Callers that need to chain post-resume work should `await` it; pure
|
|
* fire-and-forget callers (the resume dialog's `onSelect`) can ignore the
|
|
* promise.
|
|
*/
|
|
handleResume: (sessionId: string) => Promise<void>;
|
|
}
|
|
|
|
const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE =
|
|
"Stop the current session's running background tasks before resuming another session.";
|
|
|
|
export function useResumeCommand(
|
|
options: UseResumeCommandOptions,
|
|
): UseResumeCommandResult {
|
|
const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false);
|
|
const [resumeMatchedSessions, setResumeMatchedSessions] = useState<
|
|
SessionListItem[] | undefined
|
|
>();
|
|
|
|
const openResumeDialog = useCallback(
|
|
(matchedSessions?: SessionListItem[]) => {
|
|
setResumeMatchedSessions(matchedSessions);
|
|
setIsResumeDialogOpen(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const closeResumeDialog = useCallback(() => {
|
|
setIsResumeDialogOpen(false);
|
|
setResumeMatchedSessions(undefined);
|
|
}, []);
|
|
|
|
const {
|
|
config,
|
|
settings,
|
|
historyManager,
|
|
startNewSession,
|
|
setSessionName,
|
|
remount,
|
|
} = options;
|
|
|
|
const { addItem, clearItems, loadHistory } = historyManager;
|
|
const handleResume = useCallback(
|
|
async (sessionId: string) => {
|
|
if (!config) {
|
|
return;
|
|
}
|
|
|
|
if (hasBlockingBackgroundWork(config)) {
|
|
const blockedMessage: HistoryItemWithoutId = {
|
|
type: MessageType.ERROR,
|
|
text: BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE,
|
|
};
|
|
addItem(blockedMessage, Date.now());
|
|
closeResumeDialog();
|
|
return;
|
|
}
|
|
|
|
// Close dialog immediately to prevent input capture during async operations.
|
|
closeResumeDialog();
|
|
|
|
const oldSessionId = config.getSessionId();
|
|
let coreSwapped = false;
|
|
let uiSwapped = false;
|
|
let recoveredBackgroundAgentsNotice: string | null = null;
|
|
|
|
try {
|
|
const cwd = config.getTargetDir();
|
|
const sessionService = new SessionService(cwd);
|
|
const sessionData = await sessionService.loadSession(sessionId);
|
|
|
|
if (!sessionData) {
|
|
return;
|
|
}
|
|
|
|
// Restore session name tag from custom title.
|
|
const customTitle = sessionService.getSessionTitle(sessionId);
|
|
|
|
// Build UI history items.
|
|
const recoveryPlan = buildSessionRecoveryPlan({
|
|
sessionId,
|
|
conversation: sessionData.conversation,
|
|
historyGaps: sessionData.historyGaps,
|
|
});
|
|
const rawItems = buildResumedHistoryItems(sessionData, config);
|
|
const collapseOnResume =
|
|
settings.merged.ui?.history?.collapseOnResume ?? false;
|
|
const collapsePreviewCount =
|
|
settings.merged.ui?.history?.collapsePreviewCount ?? 0;
|
|
|
|
const uiHistoryItems = applyCollapsePolicyAndSummary(
|
|
rawItems,
|
|
collapseOnResume,
|
|
collapsePreviewCount,
|
|
);
|
|
if (
|
|
recoveryPlan.kind !== 'clean' &&
|
|
recoveryPlan.kind !== 'degraded_history' &&
|
|
recoveryPlan.visibleNotice
|
|
) {
|
|
const nextId = (uiHistoryItems.at(-1)?.id ?? 0) + 1;
|
|
uiHistoryItems.push({
|
|
id: nextId,
|
|
type: MessageType.INFO,
|
|
text: recoveryPlan.visibleNotice,
|
|
});
|
|
}
|
|
|
|
// 1. Swap core first. Matches useBranchCommand's core-before-UI
|
|
// pattern: if anything fails between core swap and UI swap,
|
|
// the catch block rolls core back to the old session so the
|
|
// user is not stranded with a half-live client.
|
|
resetBackgroundStateForSessionSwitch(config);
|
|
config.startNewSession(sessionId, sessionData);
|
|
coreSwapped = true;
|
|
await waitForGoalRuntime(config);
|
|
// Rebuild turn boundary tracking so rewind works within resumed sessions.
|
|
config
|
|
.getChatRecordingService()
|
|
?.rebuildTurnBoundaries(sessionData.conversation.messages);
|
|
await config.getGeminiClient()?.initialize?.();
|
|
|
|
const recovered = await config.loadPausedBackgroundAgents(sessionId);
|
|
if (recovered.length > 0) {
|
|
recoveredBackgroundAgentsNotice = config
|
|
.getBackgroundAgentResumeService()
|
|
.buildRecoveredBackgroundAgentsNotice(recovered.length);
|
|
}
|
|
|
|
// 2. Swap UI. Once this commits, rolling core back is unsafe —
|
|
// it would leave UI on the resumed session but recorder writing
|
|
// into the old JSONL (split-brain).
|
|
startNewSession(sessionId);
|
|
setSessionName?.(customTitle ?? null);
|
|
clearItems();
|
|
loadHistory(uiHistoryItems);
|
|
if (recoveredBackgroundAgentsNotice) {
|
|
addItem(
|
|
{
|
|
type: MessageType.INFO,
|
|
text: recoveredBackgroundAgentsNotice,
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
uiSwapped = true;
|
|
|
|
// SessionStart hook is handled during chat initialization so its
|
|
// additionalContext can be injected into the resumed model context.
|
|
|
|
// Refresh terminal UI.
|
|
remount?.();
|
|
} catch (error) {
|
|
if (coreSwapped && !uiSwapped) {
|
|
// Core switched to the resumed session but UI hasn't swapped
|
|
// yet — put core back on the old session, otherwise the
|
|
// recorder would keep writing new user messages into the
|
|
// orphaned session JSONL while UI still shows the old session.
|
|
try {
|
|
resetBackgroundStateForSessionSwitch(config);
|
|
config.startNewSession(oldSessionId, undefined);
|
|
// The forward path cleared the old session's in-memory
|
|
// background agents (resetBackgroundStateForSessionSwitch above,
|
|
// ~L158) before swapping core. After rolling core back to the old
|
|
// session, reload them so `list_agents` reflects the old session's
|
|
// still-on-disk sidecars again; otherwise the user lands back on
|
|
// the old session with an empty roster until the next process
|
|
// start or successful /resume. Best-effort — the guard inside
|
|
// loadPausedBackgroundAgents requires the session to already be
|
|
// current, which the startNewSession above satisfies.
|
|
await config
|
|
.loadPausedBackgroundAgents(oldSessionId)
|
|
.catch(() => {});
|
|
} catch (rollbackErr) {
|
|
config
|
|
.getDebugLogger()
|
|
.warn(
|
|
`Rollback after failed /resume init failed: ${rollbackErr}`,
|
|
);
|
|
}
|
|
}
|
|
addItem(
|
|
{
|
|
type: MessageType.ERROR,
|
|
text: `Failed to resume session: ${error instanceof Error ? error.message : String(error)}`,
|
|
} as HistoryItemWithoutId,
|
|
Date.now(),
|
|
);
|
|
closeResumeDialog();
|
|
remount?.();
|
|
}
|
|
},
|
|
[
|
|
closeResumeDialog,
|
|
config,
|
|
addItem,
|
|
clearItems,
|
|
loadHistory,
|
|
startNewSession,
|
|
setSessionName,
|
|
remount,
|
|
settings.merged.ui?.history?.collapseOnResume,
|
|
settings.merged.ui?.history?.collapsePreviewCount,
|
|
],
|
|
);
|
|
|
|
return {
|
|
isResumeDialogOpen,
|
|
resumeMatchedSessions,
|
|
openResumeDialog,
|
|
closeResumeDialog,
|
|
handleResume,
|
|
};
|
|
}
|
|
|
|
export { BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE };
|