From d0f49f56e4923ab57af79466dfec6b9a212c1fc6 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 29 Apr 2026 20:11:03 +0800 Subject: [PATCH 01/12] Add background agent resume support --- packages/cli/src/ui/AppContainer.tsx | 15 + .../BackgroundTasksDialog.test.tsx | 28 + .../background-view/BackgroundTasksDialog.tsx | 20 +- .../BackgroundTasksPill.test.tsx | 6 + .../background-view/BackgroundTasksPill.tsx | 10 +- .../ui/contexts/BackgroundTaskViewContext.tsx | 19 +- .../cli/src/ui/hooks/useResumeCommand.test.ts | 16 +- packages/cli/src/ui/hooks/useResumeCommand.ts | 22 +- .../core/src/agents/agent-transcript.test.ts | 103 +- packages/core/src/agents/agent-transcript.ts | 133 ++- .../agents/background-agent-resume.test.ts | 643 ++++++++++++ .../src/agents/background-agent-resume.ts | 974 ++++++++++++++++++ .../core/src/agents/background-tasks.test.ts | 31 + packages/core/src/agents/background-tasks.ts | 22 +- packages/core/src/agents/index.ts | 1 + .../core/src/agents/runtime/agent-headless.ts | 15 +- packages/core/src/config/config.ts | 39 + .../core/src/services/chatRecordingService.ts | 18 +- .../core/src/subagents/subagent-manager.ts | 27 +- packages/core/src/tools/agent/agent.ts | 54 +- packages/core/src/tools/send-message.test.ts | 28 +- packages/core/src/tools/send-message.ts | 31 +- packages/core/src/tools/task-stop.test.ts | 25 +- packages/core/src/tools/task-stop.ts | 24 +- 24 files changed, 2271 insertions(+), 33 deletions(-) create mode 100644 packages/core/src/agents/background-agent-resume.test.ts create mode 100644 packages/core/src/agents/background-agent-resume.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e33beca8ac0..7ce5d77d100 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -362,6 +362,21 @@ export const AppContainer = (props: AppContainerProps) => { ); historyManager.loadHistory(historyItems); + const recovered = await config.loadPausedBackgroundAgents( + config.getSessionId(), + ); + if (recovered.length > 0) { + historyManager.addItem( + { + type: MessageType.INFO, + text: config + .getBackgroundAgentResumeService() + .buildRecoveredBackgroundAgentsNotice(recovered.length), + }, + Date.now(), + ); + } + // Restore session name tag from custom title const title = config .getSessionService() diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 8d2ce1c2f7a..ab0dcc740c1 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -49,6 +49,8 @@ interface ProbeHandle { interface Harness { cancel: ReturnType; + resume: ReturnType; + abandon: ReturnType; setEntries: (next: readonly BackgroundTaskEntry[]) => void; pressKey: (key: { name?: string; sequence?: string }) => void; call: (fn: () => void) => void; @@ -64,11 +66,15 @@ function setup(initial: readonly BackgroundTaskEntry[]): Harness { }); const cancel = vi.fn(); + const resume = vi.fn(); + const abandon = vi.fn(); const config = { getBackgroundTaskRegistry: () => ({ cancel, setActivityChangeCallback: vi.fn(), }), + resumeBackgroundAgent: resume, + abandonBackgroundAgent: abandon, } as unknown as Config; const handle: { current: ProbeHandle | null } = { current: null }; @@ -108,6 +114,8 @@ function setup(initial: readonly BackgroundTaskEntry[]): Harness { return { cancel, + resume, + abandon, setEntries(next) { handlers.length = 0; act(() => handle.current!.setEntries(next)); @@ -196,4 +204,24 @@ describe('BackgroundTasksDialog', () => { h.setEntries([]); expect(h.probe.current!.state.selectedIndex).toBe(0); }); + + it('resumes a paused task with the r key', () => { + const paused = entry({ agentId: 'a', status: 'paused' }); + const h = setup([paused]); + + h.call(() => h.probe.current!.actions.openDialog()); + h.pressKey({ sequence: 'r' }); + + expect(h.resume).toHaveBeenCalledWith('a'); + }); + + it('abandons a paused task with the x key', () => { + const paused = entry({ agentId: 'a', status: 'paused' }); + const h = setup([paused]); + + h.call(() => h.probe.current!.actions.openDialog()); + h.pressKey({ sequence: 'x' }); + + expect(h.abandon).toHaveBeenCalledWith('a'); + }); }); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index 1f5f200677d..3a6b1600fa8 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -28,6 +28,7 @@ import { ToolNames, type BackgroundTaskEntry, } from '@qwen-code/qwen-code-core'; +import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; // Tool-name → display-name lookup (`run_shell_command` → `Shell`). const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( @@ -44,10 +45,10 @@ function formatActivityLabel(name: string, description: string | undefined) { : ''; return singleLineDesc ? `${display}(${singleLineDesc})` : display; } -import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; const STATUS_VERBS: Record = { running: 'Running', + paused: 'Paused', completed: 'Completed', failed: 'Failed', cancelled: 'Stopped', @@ -63,6 +64,12 @@ function terminalStatusPresentation( status: BackgroundTaskEntry['status'], ): StatusPresentation | null { switch (status) { + case 'paused': + return { + icon: '\u23F8', + color: theme.status.warning, + labelColor: theme.status.warningDim, + }; case 'completed': return { icon: '\u2714', @@ -362,6 +369,7 @@ export const BackgroundTasksDialog: React.FC = ({ enterDetail, exitDetail, cancelSelected, + resumeSelected, } = useBackgroundTaskViewActions(); const config = useConfig(); @@ -484,6 +492,10 @@ export const BackgroundTasksDialog: React.FC = ({ closeDialog(); return; } + if (key.sequence === 'r' && !key.ctrl && !key.meta) { + void resumeSelected(); + return; + } if (key.sequence === 'x' && !key.ctrl && !key.meta) { cancelSelected(); return; @@ -509,6 +521,10 @@ export const BackgroundTasksDialog: React.FC = ({ closeDialog(); return; } + if (key.sequence === 'r' && !key.ctrl && !key.meta) { + void resumeSelected(); + return; + } if (key.sequence === 'x' && !key.ctrl && !key.meta) { cancelSelected(); return; @@ -524,10 +540,12 @@ export const BackgroundTasksDialog: React.FC = ({ if (dialogMode === 'list') { hints.push('\u2191/\u2193 select', 'Enter view'); if (selectedEntry?.status === 'running') hints.push('x stop'); + if (selectedEntry?.status === 'paused') hints.push('r resume', 'x abandon'); hints.push('\u2190/Esc close'); } else { hints.push('\u2190 go back', 'Esc/Enter/Space close'); if (selectedEntry?.status === 'running') hints.push('x stop'); + if (selectedEntry?.status === 'paused') hints.push('r resume', 'x abandon'); } return ( diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx index ffdd53e8b0c..1d1c0f7fa1f 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx @@ -50,6 +50,12 @@ describe('getPillLabel', () => { ); }); + it('uses paused form when only paused entries remain', () => { + expect(getPillLabel([entry({ agentId: 'a', status: 'paused' })])).toBe( + '1 local agent paused', + ); + }); + it('uses plural done form when all entries are terminal', () => { expect( getPillLabel([ diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx index fc8edfb4bb6..b1ff2cc05e6 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx @@ -16,15 +16,21 @@ import { theme } from '../../semantic-colors.js'; import type { BackgroundTaskEntry } from '@qwen-code/qwen-code-core'; /** - * Pill label: counts running entries while any are running; once everything - * has terminated, switches to a "done" form so the pill still invites + * Pill label: prefer live running counts, then paused resumable counts; once + * everything is terminal, switch to a "done" form so the pill still invites * reopening the dialog to inspect final state. */ export function getPillLabel(entries: readonly BackgroundTaskEntry[]): string { const running = entries.filter((e) => e.status === 'running').length; + const paused = entries.filter((e) => e.status === 'paused').length; if (running > 0) { return running === 1 ? '1 local agent' : `${running} local agents`; } + if (paused > 0) { + return paused === 1 + ? '1 local agent paused' + : `${paused} local agents paused`; + } return entries.length === 1 ? '1 local agent done' : `${entries.length} local agents done`; diff --git a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx index cdb6a360511..429ed81594f 100644 --- a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx +++ b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx @@ -52,8 +52,10 @@ export interface BackgroundTaskViewActions { closeDialog(): void; enterDetail(): void; exitDetail(): void; - /** Cancel the currently selected entry (no-op if not running). */ + /** Stop or abandon the currently selected entry. */ cancelSelected(): void; + /** Resume the currently selected paused entry. */ + resumeSelected(): Promise; setPillFocused(focused: boolean): void; } @@ -85,6 +87,7 @@ const DEFAULT_ACTIONS: BackgroundTaskViewActions = { enterDetail: noop, exitDetail: noop, cancelSelected: noop, + resumeSelected: async () => {}, setPillFocused: noop, }; @@ -166,10 +169,20 @@ export function BackgroundTaskViewProvider({ if (!config) return; const target = entries[selectedIndex]; if (!target) return; - // cancel() is a no-op for non-running entries, so no pre-check here. + if (target.status === 'paused') { + config.abandonBackgroundAgent(target.agentId); + return; + } config.getBackgroundTaskRegistry().cancel(target.agentId); }, [config, entries, selectedIndex]); + const resumeSelected = useCallback(async () => { + if (!config) return; + const target = entries[selectedIndex]; + if (!target || target.status !== 'paused') return; + await config.resumeBackgroundAgent(target.agentId); + }, [config, entries, selectedIndex]); + const state: BackgroundTaskViewState = useMemo( () => ({ entries, @@ -190,6 +203,7 @@ export function BackgroundTaskViewProvider({ enterDetail, exitDetail, cancelSelected, + resumeSelected, setPillFocused, }), [ @@ -200,6 +214,7 @@ export function BackgroundTaskViewProvider({ enterDetail, exitDetail, cancelSelected, + resumeSelected, setPillFocused, ], ); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index a346e0ad9d9..65761b374eb 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -111,7 +111,11 @@ describe('useResumeCommand', () => { }); it('handleResume no-ops when config is null', async () => { - const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; const startNewSession = vi.fn(); const { result } = renderHook(() => @@ -135,7 +139,11 @@ describe('useResumeCommand', () => { resumeMocks.reset(); resumeMocks.createPendingLoadSession(); - const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() }; + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; const startNewSession = vi.fn(); const geminiClient = { initialize: vi.fn(), @@ -145,6 +153,10 @@ describe('useResumeCommand', () => { getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + getBackgroundAgentResumeService: () => ({ + buildRecoveredBackgroundAgentsNotice: vi.fn(), + }), getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), getDebugLogger: () => ({ warn: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index f9ca2df233a..ad95e0cb671 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -14,10 +14,14 @@ import { } from '@qwen-code/qwen-code-core'; import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { MessageType, type HistoryItem } from '../types.js'; export interface UseResumeCommandOptions { config: Config | null; - historyManager: Pick; + historyManager: Pick< + UseHistoryManagerReturn, + 'addItem' | 'clearItems' | 'loadHistory' + >; startNewSession: (sessionId: string) => void; setSessionName?: (name: string | null) => void; remount?: () => void; @@ -63,7 +67,7 @@ export function useResumeCommand( options ?? {}; const hasHistoryManager = !!historyManager; - const { clearItems, loadHistory } = historyManager || {}; + const { addItem, clearItems, loadHistory } = historyManager || {}; const handleResume = useCallback( async (sessionId: string) => { if (!config || !hasHistoryManager || !startNewSession) { @@ -101,6 +105,19 @@ export function useResumeCommand( ?.rebuildTurnBoundaries(sessionData.conversation.messages); await config.getGeminiClient()?.initialize?.(); + const recovered = await config.loadPausedBackgroundAgents(sessionId); + if (recovered.length > 0) { + addItem?.( + { + type: MessageType.INFO, + text: config + .getBackgroundAgentResumeService() + .buildRecoveredBackgroundAgentsNotice(recovered.length), + } as Omit, + Date.now(), + ); + } + // Fire SessionStart event after resuming session try { await config @@ -121,6 +138,7 @@ export function useResumeCommand( closeResumeDialog, config, hasHistoryManager, + addItem, clearItems, loadHistory, startNewSession, diff --git a/packages/core/src/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index ccf17d8c1fb..682e99f4ade 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -13,6 +13,8 @@ import { getAgentJsonlPath, getAgentMetaPath, attachJsonlTranscriptWriter, + readAgentMeta, + readLastTranscriptRecordUuidSync, writeAgentMeta, type AgentMeta, } from './agent-transcript.js'; @@ -116,6 +118,32 @@ describe('agent-transcript', () => { }); expect(fs.existsSync(metaPath)).toBe(true); }); + + it('reads back a previously-written meta sidecar', () => { + const metaPath = path.join( + tempDir, + 'subagents', + 's1', + 'agent-a.meta.json', + ); + writeAgentMeta(metaPath, { + agentId: 'a', + agentType: 'x', + description: 'd', + parentSessionId: 's', + parentAgentId: null, + createdAt: 'now', + status: 'running', + subagentName: 'explore', + resolvedApprovalMode: 'auto-edit', + }); + + expect(readAgentMeta(metaPath)).toMatchObject({ + agentId: 'a', + status: 'running', + subagentName: 'explore', + }); + }); }); describe('attachJsonlTranscriptWriter (canonical)', () => { @@ -139,7 +167,14 @@ describe('agent-transcript', () => { function makeWriter( jsonlPath: string, - extra: { initialUserPrompt?: string } = {}, + extra: { + initialUserPrompt?: string; + bootstrapHistory?: Array<{ + role: 'user' | 'model'; + parts: Array<{ text: string }>; + }>; + launchTaskPrompt?: string; + } = {}, ) { const emitter = new AgentEventEmitter(); const { cleanup } = attachJsonlTranscriptWriter(emitter, jsonlPath, { @@ -182,6 +217,45 @@ describe('agent-transcript', () => { expect(r.parentUuid).toBeNull(); }); + it('records fork bootstrap and launch prompt as system records before runtime events', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const { emitter, cleanup } = makeWriter(jsonlPath, { + bootstrapHistory: [ + { role: 'user', parts: [{ text: 'bootstrap env' }] }, + { role: 'model', parts: [{ text: 'bootstrap ack' }] }, + ], + initialUserPrompt: 'visible launch prompt', + launchTaskPrompt: 'Begin.', + }); + + emitter.emit(AgentEventType.ROUND_TEXT, { + subagentId: 'agent-x', + round: 1, + text: 'started', + thoughtText: '', + timestamp: Date.now(), + }); + cleanup(); + + const records = readJsonl(jsonlPath); + expect(records.map((record) => [record.type, record.subtype])).toEqual([ + ['system', 'agent_bootstrap'], + ['user', undefined], + ['system', 'agent_launch_prompt'], + ['assistant', undefined], + ]); + expect(records[0]?.systemPayload).toMatchObject({ + kind: 'fork', + history: [ + { role: 'user', parts: [{ text: 'bootstrap env' }] }, + { role: 'model', parts: [{ text: 'bootstrap ack' }] }, + ], + }); + expect(records[2]?.systemPayload).toMatchObject({ + displayText: 'Begin.', + }); + }); + it('writes a ROUND_TEXT event as an assistant record with text part', () => { const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); const { emitter, cleanup } = makeWriter(jsonlPath); @@ -404,5 +478,32 @@ describe('agent-transcript', () => { expect(fs.existsSync(jsonlPath)).toBe(false); }); + + it('appends onto an existing transcript when appendToExisting is enabled', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const first = makeWriter(jsonlPath, { + initialUserPrompt: 'initial prompt', + }); + first.cleanup(); + + const emitter = new AgentEventEmitter(); + const { cleanup } = attachJsonlTranscriptWriter(emitter, jsonlPath, { + agentId: 'agent-x', + agentName: 'explore', + agentColor: 'blue', + sessionId: 'session-1', + cwd: '/proj', + version: '1.2.3', + appendToExisting: true, + initialUserPrompt: 'resume prompt', + }); + + cleanup(); + + const records = readJsonl(jsonlPath); + expect(records).toHaveLength(2); + expect(records[1].parentUuid).toBe(records[0].uuid); + expect(readLastTranscriptRecordUuidSync(jsonlPath)).toBe(records[1].uuid); + }); }); }); diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index c2d73f1bf97..f03f1554b5c 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -29,8 +29,12 @@ import { type AgentRoundTextEvent, type AgentExternalMessageEvent, } from './runtime/agent-events.js'; -import { type ChatRecord } from '../services/chatRecordingService.js'; +import type { + AgentBootstrapRecordPayload, + ChatRecord, +} from '../services/chatRecordingService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { _recoverObjectsFromLine } from '../utils/jsonl-utils.js'; const debugLogger = createDebugLogger('AGENT_TRANSCRIPT'); @@ -94,6 +98,23 @@ export interface AgentMeta { parentAgentId: string | null; /** ISO 8601 creation time. */ createdAt: string; + /** + * Persisted lifecycle status. Background-resume discovery treats + * `running` as resumable work that was interrupted by process exit. + */ + status?: 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'; + /** ISO 8601 timestamp of the latest lifecycle transition. */ + lastUpdatedAt?: string; + /** Resolved approval mode used when the agent was launched. */ + resolvedApprovalMode?: string; + /** Canonical subagent config name used to recreate this agent. */ + subagentName?: string; + /** UI hint preserved for resumed task rows. */ + agentColor?: string; + /** Number of explicit resume attempts performed so far. */ + resumeCount?: number; + /** Last terminal error, if any. */ + lastError?: string; } /** @@ -108,6 +129,62 @@ export function writeAgentMeta(metaPath: string, meta: AgentMeta): void { } } +export function readAgentMeta(metaPath: string): AgentMeta | undefined { + try { + return JSON.parse(fs.readFileSync(metaPath, 'utf8')) as AgentMeta; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn(`Failed to read agent meta sidecar ${metaPath}:`, error); + } + return undefined; + } +} + +export function patchAgentMeta( + metaPath: string, + updates: Partial, +): AgentMeta | undefined { + const current = readAgentMeta(metaPath); + if (!current) return undefined; + const next: AgentMeta = { + ...current, + ...updates, + }; + writeAgentMeta(metaPath, next); + return next; +} + +export function readLastTranscriptRecordUuidSync( + jsonlPath: string, +): string | null { + try { + const raw = fs.readFileSync(jsonlPath, 'utf8'); + const lines = raw.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i]?.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as ChatRecord; + return parsed.uuid ?? null; + } catch { + const recovered = _recoverObjectsFromLine(trimmed); + const lastRecovered = recovered[recovered.length - 1]; + if (lastRecovered?.uuid) { + return lastRecovered.uuid; + } + } + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + debugLogger.warn( + `Failed to read last transcript record UUID from ${jsonlPath}:`, + error, + ); + } + } + return null; +} + export interface AttachJsonlOptions { /** Subagent identifier — populated on every record. */ agentId: string; @@ -128,6 +205,28 @@ export interface AttachJsonlOptions { * transcript is self-describing. Empty/omitted seeds nothing. */ initialUserPrompt?: string; + /** + * Exact bootstrap history that seeded the agent before its first runtime + * turn. Used by transcript-first resume to reconstruct fork constraints. + */ + bootstrapHistory?: Array; + /** + * Launching prompt that should be treated as the first model-facing task + * prompt during transcript-based resume. For forks this may differ from the + * bootstrap's visible user directive (e.g. `Begin.` vs full boilerplate). + */ + launchTaskPrompt?: string; + /** + * When true, continue appending onto an existing transcript rather than + * starting a fresh UUID chain. + */ + appendToExisting?: boolean; + /** + * Optional explicit parent UUID to use for the first appended record. + * Resume flows pass the last stable transcript UUID here so new records + * branch away from any dangling tail produced by an interrupted turn. + */ + initialParentUuid?: string | null; } export interface AttachJsonlTranscriptResult { @@ -151,7 +250,12 @@ export function attachJsonlTranscriptWriter( jsonlPath: string, options: AttachJsonlOptions, ): AttachJsonlTranscriptResult { - let lastUuid: string | null = null; + let lastUuid: string | null = + options.initialParentUuid !== undefined + ? options.initialParentUuid + : options.appendToExisting + ? readLastTranscriptRecordUuidSync(jsonlPath) + : null; let fd: number | null = null; let openFailed = false; @@ -255,14 +359,39 @@ export function attachJsonlTranscriptWriter( }); }; + const recordSystem = ( + subtype: NonNullable, + payload: ChatRecord['systemPayload'], + ) => { + append({ + ...baseFields('system'), + subtype, + systemPayload: payload, + }); + }; + const onExternalMessage = (event: AgentExternalMessageEvent) => { recordUserMessage(event.text); }; + if (options.bootstrapHistory && options.bootstrapHistory.length > 0) { + const payload: AgentBootstrapRecordPayload = { + kind: 'fork', + history: structuredClone(options.bootstrapHistory), + }; + recordSystem('agent_bootstrap', payload); + } + if (options.initialUserPrompt) { recordUserMessage(options.initialUserPrompt); } + if (options.launchTaskPrompt) { + recordSystem('agent_launch_prompt', { + displayText: options.launchTaskPrompt, + }); + } + emitter.on(AgentEventType.ROUND_TEXT, onRoundText); emitter.on(AgentEventType.TOOL_CALL, onToolCall); emitter.on(AgentEventType.TOOL_RESULT, onToolResult); diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts new file mode 100644 index 00000000000..abef3ada321 --- /dev/null +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -0,0 +1,643 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { Config } from '../config/config.js'; +import { BackgroundTaskRegistry } from './background-tasks.js'; +import { BackgroundAgentResumeService } from './background-agent-resume.js'; +import { + getAgentJsonlPath, + getAgentMetaPath, + writeAgentMeta, +} from './agent-transcript.js'; +import { AgentTerminateMode } from './runtime/agent-types.js'; +import { AgentEventEmitter } from './runtime/agent-events.js'; +import { AgentHeadless } from './runtime/agent-headless.js'; +import { + FORK_SUBAGENT_TYPE, + buildChildMessage, +} from '../tools/agent/fork-subagent.js'; + +describe('BackgroundAgentResumeService', () => { + let tempDir: string; + let registry: BackgroundTaskRegistry; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-agent-resume-')); + registry = new BackgroundTaskRegistry(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function createService() { + const subagentManager = { + loadSubagent: vi.fn(async (name: string) => + name === 'researcher' + ? { + name: 'researcher', + color: 'cyan', + } + : null, + ), + createAgentHeadless: vi.fn(), + }; + const hookSystem = { + fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), + fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), + }; + const config = { + storage: { + getProjectDir: () => tempDir, + }, + getBackgroundTaskRegistry: () => registry, + getSubagentManager: () => subagentManager, + getHookSystem: () => hookSystem, + getApprovalMode: () => 'default', + getProjectRoot: () => tempDir, + getCliVersion: () => 'test-version', + getGeminiClient: () => undefined, + getSkipStartupContext: () => true, + getTranscriptPath: () => path.join(tempDir, 'session.jsonl'), + } as unknown as Config; + + return { + service: new BackgroundAgentResumeService(config), + subagentManager, + hookSystem, + }; + } + + it('loads only interrupted running background agents as paused entries', async () => { + const sessionId = 'session-1'; + const runningAgentId = 'agent-running'; + const completedAgentId = 'agent-completed'; + + const runningMetaPath = getAgentMetaPath( + tempDir, + sessionId, + runningAgentId, + ); + const completedMetaPath = getAgentMetaPath( + tempDir, + sessionId, + completedAgentId, + ); + + writeAgentMeta(runningMetaPath, { + agentId: runningAgentId, + agentType: 'researcher', + description: 'Investigate retry handling', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'auto-edit', + }); + writeAgentMeta(completedMetaPath, { + agentId: completedAgentId, + agentType: 'researcher', + description: 'Already done', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + subagentName: 'researcher', + resolvedApprovalMode: 'auto-edit', + }); + + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, runningAgentId), + [ + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Investigate retry handling' }], + }, + }), + JSON.stringify({ + uuid: 'u2', + parentUuid: 'u1', + sessionId, + timestamp: '2026-04-20T00:00:01.000Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'Working on it' }] }, + }), + ].join('\n') + '\n', + 'utf8', + ); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, completedAgentId), + '', + 'utf8', + ); + + const { service, subagentManager } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ + agentId: runningAgentId, + status: 'paused', + description: 'Investigate retry handling', + subagentType: 'researcher', + prompt: 'Investigate retry handling', + metaPath: runningMetaPath, + outputFile: getAgentJsonlPath(tempDir, sessionId, runningAgentId), + }); + expect(registry.get(runningAgentId)?.status).toBe('paused'); + expect(registry.get(completedAgentId)).toBeUndefined(); + expect(subagentManager.loadSubagent).toHaveBeenCalledTimes(1); + expect(subagentManager.loadSubagent).toHaveBeenCalledWith('researcher'); + }); + + it('keeps interrupted fork tasks visible as paused entries', async () => { + const sessionId = 'session-fork'; + const agentId = 'agent-fork'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: FORK_SUBAGENT_TYPE, + description: 'Implicit fork background task', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: FORK_SUBAGENT_TYPE, + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Implicit fork background task' }], + }, + }) + '\n', + 'utf8', + ); + + const { service, subagentManager } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ + agentId, + status: 'paused', + subagentType: FORK_SUBAGENT_TYPE, + prompt: 'Implicit fork background task', + }); + expect(subagentManager.loadSubagent).not.toHaveBeenCalled(); + }); + + it('keeps missing subagents visible so they can be abandoned later', async () => { + const sessionId = 'session-missing'; + const agentId = 'agent-missing'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'deleted-agent', + description: 'Background task whose agent file is gone', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'deleted-agent', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Background task whose agent file is gone' }], + }, + }) + '\n', + 'utf8', + ); + + const { service, subagentManager } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ + agentId, + status: 'paused', + subagentType: 'deleted-agent', + error: 'Subagent "deleted-agent" is no longer available.', + }); + expect(subagentManager.loadSubagent).toHaveBeenCalledWith('deleted-agent'); + }); + + it('falls back to legacy agentType metadata when resume fields are missing', async () => { + const sessionId = 'session-legacy'; + const agentId = 'agent-legacy'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Legacy background task', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Legacy background task' }] }, + }) + '\n', + 'utf8', + ); + + const { service, subagentManager } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ + agentId, + status: 'paused', + subagentType: 'researcher', + prompt: 'Legacy background task', + }); + expect(subagentManager.loadSubagent).toHaveBeenCalledWith('researcher'); + }); + + it('fires SubagentStart hooks when resuming and injects hook context', async () => { + const sessionId = 'session-resume'; + const agentId = 'agent-resume'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume with hooks', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'auto-edit', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Resume with hooks' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume with hooks', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume with hooks', + outputFile, + metaPath, + }); + + const execute = vi.fn( + async (_context: { get: (key: string) => unknown }) => undefined, + ); + const setExternalMessageProvider = vi.fn(); + const subagent = { + execute, + setExternalMessageProvider, + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const { service, subagentManager, hookSystem } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + hookSystem.fireSubagentStartEvent.mockResolvedValue({ + getAdditionalContext: () => 'resume-context', + }); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeDefined(); + expect(hookSystem.fireSubagentStartEvent).toHaveBeenCalledWith( + agentId, + 'researcher', + expect.anything(), + expect.any(AbortSignal), + ); + expect(execute).toHaveBeenCalledTimes(1); + const firstCall = execute.mock.calls[0]; + expect(firstCall).toBeDefined(); + const contextArg = firstCall![0]; + expect(contextArg).toBeDefined(); + if (!contextArg) { + throw new Error('Expected resume execute context'); + } + expect(contextArg.get('hook_context')).toBe('resume-context'); + expect(contextArg.get('task_prompt')).toBe('continue'); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('completed'); + }); + }); + + it('coalesces concurrent resume calls into a single running agent', async () => { + const sessionId = 'session-double'; + const agentId = 'agent-double'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume once', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Resume once' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume once', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume once', + outputFile, + metaPath, + }); + + let releaseExecute: (() => void) | undefined; + const execute = vi.fn( + () => + new Promise((resolve) => { + releaseExecute = resolve; + }), + ); + const subagent = { + execute, + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + const first = service.resumeBackgroundAgent(agentId, 'first message'); + const second = service.resumeBackgroundAgent(agentId, 'second message'); + + await vi.waitFor(() => { + expect(subagentManager.createAgentHeadless).toHaveBeenCalledTimes(1); + }); + expect(execute).toHaveBeenCalledTimes(1); + + releaseExecute?.(); + await Promise.all([first, second]); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('completed'); + }); + }); + + it('resumes fork agents from transcript bootstrap instead of current parent config', async () => { + const sessionId = 'session-fork-resume'; + const agentId = 'agent-fork-resume'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + const launchPrompt = 'Investigate the retry loop and patch it'; + + writeAgentMeta(metaPath, { + agentId, + agentType: FORK_SUBAGENT_TYPE, + description: launchPrompt, + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: FORK_SUBAGENT_TYPE, + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + [ + JSON.stringify({ + uuid: 'sys1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'system', + subtype: 'agent_bootstrap', + systemPayload: { + kind: 'fork', + history: [ + { role: 'user', parts: [{ text: 'bootstrap env' }] }, + { role: 'model', parts: [{ text: 'bootstrap ack' }] }, + ], + }, + }), + JSON.stringify({ + uuid: 'u1', + parentUuid: 'sys1', + sessionId, + timestamp: '2026-04-20T00:00:00.100Z', + type: 'user', + message: { role: 'user', parts: [{ text: launchPrompt }] }, + }), + JSON.stringify({ + uuid: 'sys2', + parentUuid: 'u1', + sessionId, + timestamp: '2026-04-20T00:00:00.200Z', + type: 'system', + subtype: 'agent_launch_prompt', + systemPayload: { + displayText: buildChildMessage(launchPrompt), + }, + }), + JSON.stringify({ + uuid: 'a1', + parentUuid: 'sys2', + sessionId, + timestamp: '2026-04-20T00:00:01.000Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'Working silently' }] }, + }), + ].join('\n') + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: launchPrompt, + subagentType: FORK_SUBAGENT_TYPE, + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: launchPrompt, + outputFile, + metaPath, + }); + + const execute = vi.fn(async (_context: unknown) => undefined); + const subagent = { + execute, + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const createSpy = vi + .spyOn(AgentHeadless, 'create') + .mockResolvedValue(subagent as unknown as AgentHeadless); + const { service, subagentManager } = createService(); + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeDefined(); + expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); + expect(createSpy).toHaveBeenCalledTimes(1); + const createArgs = createSpy.mock.calls[0]; + expect(createArgs).toBeDefined(); + expect(createArgs![2]).toMatchObject({ + initialMessages: [ + { role: 'user', parts: [{ text: 'bootstrap env' }] }, + { role: 'model', parts: [{ text: 'bootstrap ack' }] }, + { role: 'user', parts: [{ text: buildChildMessage(launchPrompt) }] }, + { role: 'model', parts: [{ text: 'Working silently' }] }, + ], + }); + expect(execute).toHaveBeenCalledTimes(1); + const executeCall = execute.mock.calls[0]; + expect(executeCall).toBeDefined(); + const contextArg = executeCall?.[0] as + | { get(key: string): unknown } + | undefined; + expect(contextArg).toBeDefined(); + if (!contextArg) { + throw new Error('Expected resume execute context'); + } + expect(contextArg.get('task_prompt')).toBe('continue'); + createSpy.mockRestore(); + }); + + it('keeps legacy fork tasks paused when transcript bootstrap is missing', async () => { + const sessionId = 'session-fork-legacy'; + const agentId = 'agent-fork-legacy'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: FORK_SUBAGENT_TYPE, + description: 'Legacy fork task', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: FORK_SUBAGENT_TYPE, + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Legacy fork task' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Legacy fork task', + subagentType: FORK_SUBAGENT_TYPE, + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Legacy fork task', + outputFile, + metaPath, + }); + + const createSpy = vi.spyOn(AgentHeadless, 'create'); + const { service } = createService(); + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeUndefined(); + expect(registry.get(agentId)?.status).toBe('paused'); + expect(registry.get(agentId)?.error).toContain( + 'bootstrap transcript is missing', + ); + expect(createSpy).not.toHaveBeenCalled(); + createSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts new file mode 100644 index 00000000000..bcedc2ef652 --- /dev/null +++ b/packages/core/src/agents/background-agent-resume.ts @@ -0,0 +1,974 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { Content, FunctionDeclaration, Part } from '@google/genai'; +import type { Config } from '../config/config.js'; +import * as jsonl from '../utils/jsonl-utils.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { + AgentEventEmitter, + AgentEventType, + type AgentToolCallEvent, +} from './runtime/agent-events.js'; +import { AgentTerminateMode } from './runtime/agent-types.js'; +import { AgentHeadless, ContextState } from './runtime/agent-headless.js'; +import { + getSubagentSessionDir, + readAgentMeta, + patchAgentMeta, + attachJsonlTranscriptWriter, +} from './agent-transcript.js'; +import type { ChatRecord } from '../services/chatRecordingService.js'; +import { getInitialChatHistory } from '../utils/environmentContext.js'; +import { getGitBranch } from '../utils/gitUtils.js'; +import { PermissionMode, type StopHookOutput } from '../hooks/types.js'; +import { runWithAgentContext } from '../tools/agent/agent-context.js'; +import { + FORK_AGENT, + FORK_SUBAGENT_TYPE, + runInForkContext, +} from '../tools/agent/fork-subagent.js'; +import type { + AgentCompletionStats, + BackgroundTaskEntry, +} from './background-tasks.js'; +import type { SubagentConfig } from '../subagents/types.js'; +import type { + PromptConfig, + RunConfig, + ToolConfig, +} from './runtime/agent-types.js'; +import type { + AgentBootstrapRecordPayload, + NotificationRecordPayload, +} from '../services/chatRecordingService.js'; + +const debugLogger = createDebugLogger('BACKGROUND_AGENT_RESUME'); + +const META_FILE_SUFFIX = '.meta.json'; + +export const DEFAULT_BACKGROUND_AGENT_CONTINUATION_MESSAGE = + 'Continue working on the current task from the last completed step.'; + +type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; + +interface TranscriptRecovery { + history: Content[]; + initialPrompt?: string; + lastStableUuid: string | null; + pendingUserMessage?: Content; + forkBootstrap?: { + history: Content[]; + taskPrompt: string; + runtimeHistory: Content[]; + }; +} + +interface ResolvedResumeTarget { + agentName: string; + isFork: boolean; + subagentConfig?: SubagentConfig; + unavailableReason?: string; +} + +interface ResumeOperation { + continuationMessages: string[]; + promise: Promise; +} + +function approvalModeToPermissionMode(mode?: string): PermissionMode { + switch (mode) { + case 'yolo': + return PermissionMode.Yolo; + case 'auto-edit': + return PermissionMode.AutoEdit; + case 'plan': + return PermissionMode.Plan; + case 'default': + default: + return PermissionMode.Default; + } +} + +function normalizeApprovalMode( + value: string | undefined, + fallback: ApprovalModeValue, +): ApprovalModeValue { + switch (value) { + case 'plan': + case 'default': + case 'auto-edit': + case 'yolo': + return value; + default: + return fallback; + } +} + +function createApprovalModeOverride( + base: Config, + mode: ApprovalModeValue, +): Config { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const override = Object.create(base) as any; + override.getApprovalMode = () => mode; + return override as Config; +} + +function isWhitespaceOnlyAssistant(record: ChatRecord): boolean { + if (record.type !== 'assistant' || !record.message?.parts?.length) { + return false; + } + const hasFunctionCall = record.message.parts.some( + (part) => !!part.functionCall, + ); + if (hasFunctionCall) return false; + return record.message.parts.every((part) => { + if (!('text' in part) || typeof part.text !== 'string') { + return false; + } + return part.text.trim().length === 0; + }); +} + +function extractFunctionCallIds(record: ChatRecord): string[] { + if (record.type !== 'assistant' || !record.message?.parts?.length) { + return []; + } + return record.message.parts + .map((part) => part.functionCall?.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0); +} + +function reconstructHistory( + records: ChatRecord[], + leafUuid?: string, +): ChatRecord[] { + if (records.length === 0) return []; + + const recordsByUuid = new Map(); + for (const record of records) { + const existing = recordsByUuid.get(record.uuid) ?? []; + existing.push(record); + recordsByUuid.set(record.uuid, existing); + } + + let currentUuid: string | null = + leafUuid ?? records[records.length - 1]!.uuid; + const uuidChain: string[] = []; + const visited = new Set(); + + while (currentUuid && !visited.has(currentUuid)) { + visited.add(currentUuid); + uuidChain.push(currentUuid); + const recordsForUuid = recordsByUuid.get(currentUuid); + if (!recordsForUuid?.length) break; + currentUuid = recordsForUuid[0]!.parentUuid; + } + + uuidChain.reverse(); + return uuidChain + .map((uuid) => recordsByUuid.get(uuid)?.[0]) + .filter((record): record is ChatRecord => !!record); +} + +function extractText(parts: Part[] | undefined): string { + if (!parts?.length) return ''; + return parts + .map((part) => ('text' in part && part.text ? part.text : '')) + .join('\n') + .trim(); +} + +function coalesceAdjacentUserHistory(messages: Content[]): Content[] { + const result: Content[] = []; + for (const message of messages) { + if ( + message.role === 'user' && + result.length > 0 && + result[result.length - 1]!.role === 'user' + ) { + result[result.length - 1] = { + ...result[result.length - 1]!, + parts: [ + ...(result[result.length - 1]!.parts ?? []), + ...structuredClone(message.parts ?? []), + ], + }; + continue; + } + result.push(structuredClone(message)); + } + return result; +} + +function recoverTranscript(records: ChatRecord[]): TranscriptRecovery { + const chain = reconstructHistory(records); + const filtered = chain.filter((record) => !isWhitespaceOnlyAssistant(record)); + const bootstrapRecord = filtered.find( + (record) => + record.type === 'system' && + record.subtype === 'agent_bootstrap' && + record.systemPayload, + ); + const launchPromptRecord = filtered.find( + (record) => + record.type === 'system' && + record.subtype === 'agent_launch_prompt' && + record.systemPayload, + ); + const initialPrompt = filtered.find((record) => record.type === 'user') + ? extractText( + filtered.find((record) => record.type === 'user')?.message?.parts, + ) + : undefined; + + const stableForBranch = [...filtered]; + while (stableForBranch.length > 0) { + const last = stableForBranch[stableForBranch.length - 1]!; + if (isWhitespaceOnlyAssistant(last)) { + stableForBranch.pop(); + continue; + } + if (extractFunctionCallIds(last).length > 0) { + stableForBranch.pop(); + continue; + } + break; + } + + const nonSystemStableRecords = stableForBranch.filter( + (record) => record.type !== 'system', + ); + const forkLaunchSeedUuid = + bootstrapRecord && nonSystemStableRecords[0]?.type === 'user' + ? nonSystemStableRecords[0].uuid + : null; + + const sanitized = [...nonSystemStableRecords]; + const pendingUserRecords: ChatRecord[] = []; + while (sanitized.length > 0) { + const last = sanitized[sanitized.length - 1]!; + if (last.type !== 'user') break; + pendingUserRecords.unshift(last); + sanitized.pop(); + } + + const pendingUserMessage = + pendingUserRecords.length > 0 + ? ({ + role: 'user', + parts: pendingUserRecords.flatMap((record) => + structuredClone(record.message?.parts ?? []), + ), + } as Content) + : undefined; + + return { + history: coalesceAdjacentUserHistory( + sanitized + .map((record) => record.message) + .filter((message): message is Content => message !== undefined), + ), + initialPrompt: initialPrompt || undefined, + lastStableUuid: + stableForBranch.length > 0 + ? stableForBranch[stableForBranch.length - 1]!.uuid + : null, + pendingUserMessage, + forkBootstrap: + bootstrapRecord?.systemPayload && + (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload).kind === + 'fork' && + typeof ( + launchPromptRecord?.systemPayload as + | NotificationRecordPayload + | undefined + )?.displayText === 'string' + ? { + history: structuredClone( + (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload) + .history, + ), + taskPrompt: ( + launchPromptRecord!.systemPayload as NotificationRecordPayload + ).displayText, + runtimeHistory: coalesceAdjacentUserHistory( + sanitized + .filter((record) => record.uuid !== forkLaunchSeedUuid) + .map((record) => record.message) + .filter((message): message is Content => message !== undefined), + ), + } + : undefined, + }; +} + +function getCompletionStats( + subagent: AgentHeadless, + liveToolCallCount: number, +): AgentCompletionStats { + const summary = subagent.getExecutionSummary(); + return { + totalTokens: summary.totalTokens, + toolUses: liveToolCallCount, + durationMs: summary.totalDurationMs, + }; +} + +function buildRecoveredNotice(count: number): string { + return count === 1 + ? 'Recovered 1 interrupted background agent. Open Background tasks and press r to resume.' + : `Recovered ${count} interrupted background agents. Open Background tasks and press r to resume.`; +} + +export class BackgroundAgentResumeService { + private readonly resumeOperations = new Map(); + + constructor(private readonly config: Config) {} + + async loadPausedBackgroundAgents( + sessionId: string, + ): Promise { + const projectDir = this.config.storage.getProjectDir(); + const dir = getSubagentSessionDir(projectDir, sessionId); + let files: string[]; + try { + files = await fs.readdir(dir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const registry = this.config.getBackgroundTaskRegistry(); + const recovered: BackgroundTaskEntry[] = []; + + for (const fileName of files) { + if (!fileName.endsWith(META_FILE_SUFFIX)) continue; + const metaPath = path.join(dir, fileName); + try { + const meta = readAgentMeta(metaPath); + if (!meta || meta.status !== 'running') continue; + if (registry.get(meta.agentId)) continue; + const subagentName = meta.subagentName ?? meta.agentType; + if (!subagentName) continue; + const target = await this.resolveResumeTarget(subagentName); + + const outputFile = path.join( + dir, + fileName.slice(0, -META_FILE_SUFFIX.length) + '.jsonl', + ); + const records = await jsonl.read(outputFile); + const recovery = recoverTranscript(records); + const parsedStartTime = Date.parse(meta.createdAt); + + const entry: BackgroundTaskEntry = { + agentId: meta.agentId, + description: meta.description, + subagentType: target.agentName, + status: 'paused', + startTime: Number.isFinite(parsedStartTime) + ? parsedStartTime + : Date.now(), + abortController: new AbortController(), + prompt: recovery.initialPrompt, + outputFile, + metaPath, + error: meta.lastError ?? target.unavailableReason, + }; + registry.register(entry); + recovered.push(entry); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Failed to load paused background agent from ${metaPath}:`, + error, + ); + } + } + + return recovered; + } + + async resumeBackgroundAgent( + agentId: string, + initialMessage?: string, + ): Promise { + const trimmedMessage = initialMessage?.trim(); + const existingOperation = this.resumeOperations.get(agentId); + if (existingOperation) { + if (trimmedMessage) { + const registry = this.config.getBackgroundTaskRegistry(); + if (!registry.queueMessage(agentId, trimmedMessage)) { + existingOperation.continuationMessages.push(trimmedMessage); + } + } + return existingOperation.promise; + } + + const operation: ResumeOperation = { + continuationMessages: trimmedMessage ? [trimmedMessage] : [], + promise: Promise.resolve(undefined), + }; + operation.promise = this.resumeBackgroundAgentInternal( + agentId, + operation, + ).finally(() => { + this.resumeOperations.delete(agentId); + }); + this.resumeOperations.set(agentId, operation); + return operation.promise; + } + + private async resumeBackgroundAgentInternal( + agentId: string, + operation: ResumeOperation, + ): Promise { + const registry = this.config.getBackgroundTaskRegistry(); + const existing = registry.get(agentId); + if (!existing || existing.status !== 'paused') { + return existing; + } + + const metaPath = existing.metaPath; + const outputFile = existing.outputFile; + if (!metaPath || !outputFile) { + return undefined; + } + + const meta = readAgentMeta(metaPath); + if (!meta) { + return undefined; + } + + const bgAbortController = new AbortController(); + bgAbortController.signal.addEventListener( + 'abort', + () => { + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + }); + }, + { once: true }, + ); + + registry.register({ + ...existing, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + stats: undefined, + recentActivities: [], + pendingMessages: [...(existing.pendingMessages ?? [])], + notified: false, + }); + + try { + const subagentName = meta.subagentName ?? meta.agentType; + const target = await this.resolveResumeTarget(subagentName); + if (!target.subagentConfig && !target.isFork) { + const reason = + target.unavailableReason || + `Subagent "${subagentName}" is no longer available.`; + patchAgentMeta(metaPath, { + lastError: reason, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, reason); + return undefined; + } + + const resolvedApprovalMode = normalizeApprovalMode( + meta.resolvedApprovalMode, + this.config.getApprovalMode() as ApprovalModeValue, + ); + const agentConfig = + resolvedApprovalMode !== this.config.getApprovalMode() + ? createApprovalModeOverride(this.config, resolvedApprovalMode) + : this.config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bgConfig = Object.create(agentConfig) as any; + bgConfig.getShouldAvoidPermissionPrompts = () => true; + + const records = await jsonl.read(outputFile); + const recovery = recoverTranscript(records); + const resumeHistory = target.isFork + ? [ + ...(recovery.forkBootstrap?.history ?? []), + { + role: 'user' as const, + parts: [{ text: recovery.forkBootstrap?.taskPrompt ?? '' }], + }, + ...(recovery.forkBootstrap?.runtimeHistory ?? []), + ] + : [ + ...(await getInitialChatHistory(bgConfig as Config)), + ...recovery.history, + ]; + const promptMessages = [...operation.continuationMessages]; + const continuationPrompt = + promptMessages.join('\n\n').trim() || + DEFAULT_BACKGROUND_AGENT_CONTINUATION_MESSAGE; + const initialMessagesOverride = recovery.pendingUserMessage + ? [ + { + ...structuredClone(recovery.pendingUserMessage), + parts: [ + ...structuredClone(recovery.pendingUserMessage.parts ?? []), + { text: `\n${continuationPrompt}` }, + ], + }, + ] + : undefined; + const writerInitialPrompt = continuationPrompt; + if (target.isFork && (!resumeHistory || resumeHistory.length === 0)) { + const reason = + 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; + patchAgentMeta(metaPath, { + lastError: reason, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, reason); + return undefined; + } + if (target.isFork && !recovery.forkBootstrap) { + const reason = + 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; + patchAgentMeta(metaPath, { + lastError: reason, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, reason); + return undefined; + } + + const bgEventEmitter = new AgentEventEmitter(); + const subagent = target.isFork + ? await this.createResumedForkSubagent( + bgConfig as Config, + bgEventEmitter, + resumeHistory ?? [], + ) + : await this.config + .getSubagentManager() + .createAgentHeadless(target.subagentConfig!, bgConfig as Config, { + eventEmitter: bgEventEmitter, + promptConfigOverrides: { + initialMessages: resumeHistory, + }, + }); + + const projectRoot = this.config.getProjectRoot(); + const { cleanup: cleanupJsonl } = attachJsonlTranscriptWriter( + bgEventEmitter, + outputFile, + { + agentId: meta.agentId, + agentName: target.agentName, + agentColor: target.subagentConfig?.color ?? meta.agentColor, + sessionId: meta.parentSessionId, + cwd: projectRoot, + version: this.config.getCliVersion() || 'unknown', + gitBranch: getGitBranch(projectRoot), + initialUserPrompt: writerInitialPrompt, + appendToExisting: true, + initialParentUuid: recovery.lastStableUuid, + }, + ); + + const nextResumeCount = (meta.resumeCount ?? 0) + 1; + patchAgentMeta(metaPath, { + status: 'running', + lastUpdatedAt: new Date().toISOString(), + resolvedApprovalMode, + subagentName: target.agentName, + agentColor: target.subagentConfig?.color ?? meta.agentColor, + resumeCount: nextResumeCount, + lastError: undefined, + }); + + const pendingMessages = [ + ...(registry.get(meta.agentId)?.pendingMessages ?? []), + ]; + const entry: BackgroundTaskEntry = { + ...existing, + subagentType: target.agentName, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + stats: undefined, + prompt: recovery.initialPrompt ?? existing.prompt, + recentActivities: [], + pendingMessages, + notified: false, + }; + registry.register(entry); + const lateContinuationMessages = operation.continuationMessages.slice( + promptMessages.length, + ); + for (const message of lateContinuationMessages) { + registry.queueMessage(meta.agentId, message); + } + + subagent.setExternalMessageProvider(() => + registry.drainMessages(meta.agentId), + ); + + const hookSystem = this.config.getHookSystem(); + const contextState = new ContextState(); + contextState.set('task_prompt', continuationPrompt); + if (initialMessagesOverride) { + contextState.set('initial_messages_override', initialMessagesOverride); + } + const resolvedMode = approvalModeToPermissionMode(resolvedApprovalMode); + await this.applySubagentStartHook(contextState, { + agentId: meta.agentId, + agentType: meta.agentType, + resolvedMode, + signal: bgAbortController.signal, + }); + const bgEmitter = subagent.getCore().getEventEmitter(); + let liveToolCallCount = 0; + + const refreshLiveStats = () => { + const target = registry.get(meta.agentId); + if (!target || target.status !== 'running') return; + target.stats = getCompletionStats(subagent, liveToolCallCount); + }; + const onToolCall = (event: AgentToolCallEvent) => { + liveToolCallCount += 1; + refreshLiveStats(); + registry.appendActivity(meta.agentId, { + name: event.name, + description: event.description, + at: event.timestamp, + }); + }; + const onUsageMetadata = () => { + refreshLiveStats(); + }; + + bgEmitter.on(AgentEventType.TOOL_CALL, onToolCall); + bgEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata); + + const runBody = async () => { + try { + await subagent.execute(contextState, bgAbortController.signal); + + if (hookSystem && !bgAbortController.signal.aborted) { + await this.runSubagentStopHookLoop(subagent, { + agentId: meta.agentId, + agentType: meta.agentType, + resolvedMode, + signal: bgAbortController.signal, + }); + } + + const terminateMode = subagent.getTerminateMode(); + const finalText = subagent.getFinalText(); + const stats = getCompletionStats(subagent, liveToolCallCount); + if (terminateMode === AgentTerminateMode.GOAL) { + registry.complete(meta.agentId, finalText, stats); + patchAgentMeta(metaPath, { + status: 'completed', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); + } else if (terminateMode === AgentTerminateMode.CANCELLED) { + registry.finalizeCancelled(meta.agentId, finalText, stats); + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); + } else { + const failureText = + finalText || `Agent terminated with mode: ${terminateMode}`; + registry.fail(meta.agentId, failureText, stats); + patchAgentMeta(metaPath, { + status: 'failed', + lastUpdatedAt: new Date().toISOString(), + lastError: failureText, + }); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `[BackgroundAgentResume] Background agent failed: ${errorMessage}`, + ); + if (bgAbortController.signal.aborted) { + registry.finalizeCancelled( + meta.agentId, + errorMessage, + getCompletionStats(subagent, liveToolCallCount), + ); + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); + } else { + registry.fail( + meta.agentId, + errorMessage, + getCompletionStats(subagent, liveToolCallCount), + ); + patchAgentMeta(metaPath, { + status: 'failed', + lastUpdatedAt: new Date().toISOString(), + lastError: errorMessage, + }); + } + } finally { + bgEmitter.off(AgentEventType.TOOL_CALL, onToolCall); + bgEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata); + cleanupJsonl?.(); + } + }; + + const framedRunBody = () => + runWithAgentContext({ agentId: meta.agentId }, runBody); + void (target.isFork ? runInForkContext(framedRunBody) : framedRunBody()); + return entry; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.warn( + `[BackgroundAgentResume] Failed to resume background agent ${agentId}: ${errorMessage}`, + ); + patchAgentMeta(metaPath, { + lastError: errorMessage, + lastUpdatedAt: new Date().toISOString(), + }); + const latest = registry.get(agentId); + if (latest?.status === 'running') { + if (latest.abortController.signal.aborted) { + registry.finalizeCancelled(agentId, errorMessage); + } else { + this.restorePausedEntry(agentId, errorMessage); + } + } + return undefined; + } + } + + abandonBackgroundAgent(agentId: string): boolean { + const registry = this.config.getBackgroundTaskRegistry(); + const entry = registry.get(agentId); + if (!entry || entry.status !== 'paused' || !entry.metaPath) { + return false; + } + + patchAgentMeta(entry.metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); + registry.abandon(agentId); + return true; + } + + buildRecoveredBackgroundAgentsNotice(count: number): string { + return buildRecoveredNotice(count); + } + + private async resolveResumeTarget( + subagentName: string, + ): Promise { + if (subagentName === FORK_SUBAGENT_TYPE) { + return { + agentName: FORK_AGENT.name, + isFork: true, + subagentConfig: FORK_AGENT as SubagentConfig, + }; + } + + const subagentConfig = await this.config + .getSubagentManager() + .loadSubagent(subagentName); + if (!subagentConfig) { + return { + agentName: subagentName, + isFork: false, + unavailableReason: `Subagent "${subagentName}" is no longer available.`, + }; + } + + return { + agentName: subagentConfig.name, + isFork: false, + subagentConfig, + }; + } + + private restorePausedEntry( + agentId: string, + error?: string, + ): BackgroundTaskEntry | undefined { + const registry = this.config.getBackgroundTaskRegistry(); + const latest = registry.get(agentId); + if (!latest) return undefined; + + const pausedEntry: BackgroundTaskEntry = { + ...latest, + status: 'paused', + abortController: new AbortController(), + endTime: undefined, + result: undefined, + error, + stats: undefined, + recentActivities: [], + pendingMessages: [...(latest.pendingMessages ?? [])], + notified: false, + }; + registry.register(pausedEntry); + return pausedEntry; + } + + private async createResumedForkSubagent( + agentConfig: Config, + eventEmitter: AgentEventEmitter, + initialMessages: Content[], + ): Promise { + const geminiClient = this.config.getGeminiClient(); + const generationConfig = geminiClient?.getChat().getGenerationConfig(); + + let promptConfig: PromptConfig; + let toolConfig: ToolConfig; + if (generationConfig?.systemInstruction) { + const parentToolDecls: FunctionDeclaration[] = + ( + generationConfig.tools as Array<{ + functionDeclarations?: FunctionDeclaration[]; + }> + )?.flatMap((tool) => tool.functionDeclarations ?? []) ?? []; + + promptConfig = { + renderedSystemPrompt: generationConfig.systemInstruction as + | string + | Content, + initialMessages, + }; + toolConfig = { + tools: parentToolDecls.length > 0 ? parentToolDecls : ['*'], + }; + } else { + promptConfig = { + systemPrompt: FORK_AGENT.systemPrompt, + initialMessages, + }; + toolConfig = { tools: ['*'] }; + } + + return AgentHeadless.create( + FORK_AGENT.name, + agentConfig, + promptConfig, + {}, + {} as RunConfig, + toolConfig, + eventEmitter, + ); + } + + private async applySubagentStartHook( + contextState: ContextState, + opts: { + agentId: string; + agentType: string; + resolvedMode: PermissionMode; + signal?: AbortSignal; + }, + ): Promise { + const hookSystem = this.config.getHookSystem(); + if (!hookSystem) return; + + try { + const startHookOutput = await hookSystem.fireSubagentStartEvent( + opts.agentId, + opts.agentType, + opts.resolvedMode, + opts.signal, + ); + const additionalContext = startHookOutput?.getAdditionalContext(); + if (additionalContext) { + contextState.set('hook_context', additionalContext); + } + } catch (hookError) { + debugLogger.warn( + `[BackgroundAgentResume] SubagentStart hook failed, continuing execution: ${hookError}`, + ); + } + } + + private async runSubagentStopHookLoop( + subagent: AgentHeadless, + opts: { + agentId: string; + agentType: string; + resolvedMode: PermissionMode; + signal?: AbortSignal; + }, + ): Promise { + const { agentId, agentType, resolvedMode, signal } = opts; + const hookSystem = this.config.getHookSystem(); + if (!hookSystem) return; + + const transcriptPath = this.config.getTranscriptPath(); + let stopHookActive = false; + const maxIterations = 5; + + for (let i = 0; i < maxIterations; i++) { + try { + const stopHookOutput = await hookSystem.fireSubagentStopEvent( + agentId, + agentType, + transcriptPath, + subagent.getFinalText(), + stopHookActive, + resolvedMode, + signal, + ); + + const typedStopOutput = stopHookOutput as StopHookOutput | undefined; + if ( + !typedStopOutput?.isBlockingDecision() && + !typedStopOutput?.shouldStopExecution() + ) { + return; + } + + stopHookActive = true; + const continueContext = new ContextState(); + continueContext.set( + 'task_prompt', + typedStopOutput.getEffectiveReason(), + ); + await subagent.execute(continueContext, signal); + + if (signal?.aborted) return; + } catch (hookError) { + debugLogger.warn( + `[BackgroundAgentResume] SubagentStop hook failed, allowing stop: ${hookError}`, + ); + return; + } + } + + debugLogger.warn( + `[BackgroundAgentResume] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop`, + ); + } +} diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index e69155dd93b..700606f9258 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -228,6 +228,37 @@ describe('BackgroundTaskRegistry', () => { expect(abortController.signal.aborted).toBe(false); }); + it('abandons a paused agent without emitting a notification', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'paused-1', + description: 'paused agent', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.abandon('paused-1'); + + expect(registry.get('paused-1')!.status).toBe('cancelled'); + expect(registry.get('paused-1')!.notified).toBe(true); + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not treat paused entries as unfinalized work', () => { + registry.register({ + agentId: 'paused-1', + description: 'paused agent', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + }); + + expect(registry.hasUnfinalizedTasks()).toBe(false); + }); + it('lists running agents', () => { registry.register({ agentId: 'a', diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 8e30e182806..b0a2ed1bf1a 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -73,6 +73,7 @@ function escapeXml(text: string): string { export type BackgroundTaskStatus = | 'running' + | 'paused' | 'completed' | 'failed' | 'cancelled'; @@ -127,6 +128,8 @@ export interface BackgroundTaskEntry { recentActivities?: readonly BackgroundActivity[]; /** Absolute path to the agent's on-disk JSONL transcript file. */ outputFile?: string; + /** Absolute path to the agent's sidecar metadata file. */ + metaPath?: string; /** Messages queued by SendMessage, drained between tool rounds. */ pendingMessages?: string[]; /** @@ -259,6 +262,22 @@ export class BackgroundTaskRegistry { timer.unref?.(); } + /** + * Marks a paused interrupted task as intentionally discarded/cancelled + * without emitting a task-notification. Used when the user explicitly + * abandons a recovered task instead of resuming it. + */ + abandon(agentId: string): void { + const entry = this.agents.get(agentId); + if (!entry || entry.status !== 'paused') return; + + entry.status = 'cancelled'; + entry.endTime = Date.now(); + entry.notified = true; + debugLogger.info(`Abandoned paused background agent: ${agentId}`); + this.emitStatusChange(entry); + } + // Emit the terminal cancelled notification once the agent's natural // handler has confirmed that the reasoning loop ended because of the // abort (terminateMode === CANCELLED). Attaches the partial result and @@ -337,7 +356,8 @@ export class BackgroundTaskRegistry { */ hasUnfinalizedTasks(): boolean { for (const entry of this.agents.values()) { - if (!entry.notified) return true; + if (entry.status === 'running') return true; + if (entry.status === 'cancelled' && !entry.notified) return true; } return false; } diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts index b469ceddb17..092efec1794 100644 --- a/packages/core/src/agents/index.ts +++ b/packages/core/src/agents/index.ts @@ -17,3 +17,4 @@ export * from './backends/index.js'; export * from './arena/index.js'; export * from './runtime/index.js'; export * from './background-tasks.js'; +export * from './background-agent-resume.js'; diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index 66d7a2da3e2..8c85c46aa21 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -14,6 +14,7 @@ * For persistent interactive agents, see AgentInteractive (Phase 2). */ +import type { Content } from '@google/genai'; import type { Config } from '../../config/config.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import type { @@ -194,6 +195,9 @@ export class AgentHeadless { context: ContextState, externalSignal?: AbortSignal, ): Promise { + const initialMessagesOverride = context.get('initial_messages_override') as + | Content[] + | undefined; // Record the initial user turn in the observable message log before // anything that can throw — createChat / prepareTools failures still // get a transcript showing the task that was asked, which is what @@ -202,7 +206,9 @@ export class AgentHeadless { const initialTaskText = String( (context.get('task_prompt') as string) ?? 'Get Started!', ); - this.core.pushMessage('user', initialTaskText); + if (!initialMessagesOverride || initialMessagesOverride.length === 0) { + this.core.pushMessage('user', initialTaskText); + } const chat = await this.core.createChat(context); @@ -225,9 +231,10 @@ export class AgentHeadless { const toolsList = await this.core.prepareTools(); - const initialMessages = [ - { role: 'user' as const, parts: [{ text: initialTaskText }] }, - ]; + const initialMessages = + initialMessagesOverride && initialMessagesOverride.length > 0 + ? initialMessagesOverride + : [{ role: 'user' as const, parts: [{ text: initialTaskText }] }]; const startTime = Date.now(); this.core.executionStats.startTimeMs = startTime; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 52aa49394ba..1d916c6ce3a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -61,6 +61,7 @@ import { PermissionManager } from '../permissions/permission-manager.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; +import { BackgroundAgentResumeService } from '../agents/background-agent-resume.js'; import { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js'; import { DEFAULT_OTLP_ENDPOINT, @@ -546,6 +547,7 @@ export class Config { private promptRegistry!: PromptRegistry; private subagentManager!: SubagentManager; private readonly backgroundTaskRegistry = new BackgroundTaskRegistry(); + private backgroundAgentResumeService?: BackgroundAgentResumeService; private readonly backgroundShellRegistry = new BackgroundShellRegistry(); private extensionManager!: ExtensionManager; private skillManager: SkillManager | null = null; @@ -2492,6 +2494,43 @@ export class Config { return this.backgroundTaskRegistry; } + getBackgroundAgentResumeService(): BackgroundAgentResumeService { + if (!this.backgroundAgentResumeService) { + this.backgroundAgentResumeService = new BackgroundAgentResumeService( + this, + ); + } + return this.backgroundAgentResumeService; + } + + async loadPausedBackgroundAgents( + sessionId: string = this.getSessionId(), + ): Promise< + ReadonlyArray + > { + return this.getBackgroundAgentResumeService().loadPausedBackgroundAgents( + sessionId, + ); + } + + async resumeBackgroundAgent( + agentId: string, + initialMessage?: string, + ): Promise< + import('../agents/background-tasks.js').BackgroundTaskEntry | undefined + > { + return this.getBackgroundAgentResumeService().resumeBackgroundAgent( + agentId, + initialMessage, + ); + } + + abandonBackgroundAgent(agentId: string): boolean { + return this.getBackgroundAgentResumeService().abandonBackgroundAgent( + agentId, + ); + } + getBackgroundShellRegistry(): BackgroundShellRegistry { return this.backgroundShellRegistry; } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 21c94114d68..e5d5545caea 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -92,7 +92,9 @@ export interface ChatRecord { | 'notification' | 'cron' | 'custom_title' - | 'rewind'; + | 'rewind' + | 'agent_bootstrap' + | 'agent_launch_prompt'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -135,7 +137,8 @@ export interface ChatRecord { | AtCommandRecordPayload | CustomTitleRecordPayload | NotificationRecordPayload - | RewindRecordPayload; + | RewindRecordPayload + | AgentBootstrapRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -151,6 +154,17 @@ export interface NotificationRecordPayload { displayText: string; } +export interface AgentBootstrapRecordPayload { + /** Bootstrap kind for future-proof decoding. */ + kind: 'fork'; + /** + * Exact model-facing history prefix seeded before the agent emitted any + * runtime events. For forks, this includes the inherited parent context and + * the original first task prompt/user turn. + */ + history: Content[]; +} + /** * Stored payload for chat compression checkpoints. This allows us to rebuild the * effective chat history on resume while keeping the original UI-visible history. diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 186222edee5..1d603985e91 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -45,7 +45,6 @@ import { buildAgentContentGeneratorConfig } from '../models/content-generator-co import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; import { parseSubagentModelSelection } from './model-selection.js'; - const debugLogger = createDebugLogger('SUBAGENT_MANAGER'); import { BuiltinAgentRegistry } from './builtin-agents.js'; import { ToolDisplayNamesMigration } from '../tools/tool-names.js'; @@ -633,10 +632,28 @@ export class SubagentManager { options?: { eventEmitter?: AgentEventEmitter; hooks?: AgentHooks; + promptConfigOverrides?: Partial; + modelConfigOverrides?: Partial; + runConfigOverrides?: Partial; + toolConfigOverride?: ToolConfig; }, ): Promise { try { const runtimeConfig = await this.convertToRuntimeConfig(config); + const promptConfig: PromptConfig = { + ...runtimeConfig.promptConfig, + ...options?.promptConfigOverrides, + }; + const modelConfig: ModelConfig = { + ...runtimeConfig.modelConfig, + ...options?.modelConfigOverrides, + }; + const runConfig: RunConfig = { + ...runtimeConfig.runConfig, + ...options?.runConfigOverrides, + }; + const toolConfig = + options?.toolConfigOverride ?? runtimeConfig.toolConfig; // When the model selector specifies a different provider, build a // per-agent Config with a dedicated ContentGenerator so the subagent @@ -649,10 +666,10 @@ export class SubagentManager { return await AgentHeadless.create( config.name, agentContext, - runtimeConfig.promptConfig, - runtimeConfig.modelConfig, - runtimeConfig.runConfig, - runtimeConfig.toolConfig, + promptConfig, + modelConfig, + runConfig, + toolConfig, options?.eventEmitter, options?.hooks, ); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index ec2ed6e3f27..c78aa2504b7 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -61,6 +61,7 @@ import { getAgentJsonlPath, getAgentMetaPath, attachJsonlTranscriptWriter, + patchAgentMeta, writeAgentMeta, } from '../../agents/agent-transcript.js'; import { getGitBranch } from '../../utils/gitUtils.js'; @@ -640,6 +641,7 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter: AgentEventEmitter = this.eventEmitter, ): Promise<{ subagent: AgentHeadless; + initialMessages?: Content[]; taskPrompt: string; }> { const geminiClient = this.config.getGeminiClient(); @@ -740,7 +742,7 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter, ); - return { subagent, taskPrompt }; + return { subagent, initialMessages, taskPrompt }; } // Runs the SubagentStop hook after execution. On a blocking decision, feeds the @@ -1056,18 +1058,23 @@ class AgentToolInvocation extends BaseToolInvocation { // concurrent fork/subagent into the same transcript. const bgEventEmitter = new AgentEventEmitter(); let bgSubagent: AgentHeadless; + let bgInitialMessages: Content[] | undefined; + let bgTaskPrompt: string; if (isFork) { const fork = await this.createForkSubagent( bgConfig as Config, bgEventEmitter, ); bgSubagent = fork.subagent; + bgInitialMessages = fork.initialMessages; + bgTaskPrompt = fork.taskPrompt; } else { bgSubagent = await this.subagentManager.createAgentHeadless( subagentConfig, bgConfig as Config, { eventEmitter: bgEventEmitter }, ); + bgTaskPrompt = this.params.prompt; } const registry = this.config.getBackgroundTaskRegistry(); @@ -1100,6 +1107,8 @@ class AgentToolInvocation extends BaseToolInvocation { // self-describing — readers don't need to consult .meta.json to // know what the agent was asked to do. initialUserPrompt: this.params.prompt, + bootstrapHistory: isFork ? bgInitialMessages : undefined, + launchTaskPrompt: isFork ? bgTaskPrompt : undefined, }, ); writeAgentMeta(metaPath, { @@ -1112,7 +1121,23 @@ class AgentToolInvocation extends BaseToolInvocation { // top-level launches from the user session. parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), + status: 'running', + lastUpdatedAt: new Date().toISOString(), + resolvedApprovalMode, + subagentName: subagentConfig.name, + agentColor: subagentConfig.color, + resumeCount: 0, }); + bgAbortController.signal.addEventListener( + 'abort', + () => { + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + }); + }, + { once: true }, + ); registry.register({ agentId: hookOpts.agentId, @@ -1124,6 +1149,7 @@ class AgentToolInvocation extends BaseToolInvocation { toolUseId: this.callId, prompt: this.params.prompt, outputFile: jsonlPath, + metaPath, }); // Subscribe to the subagent's tool-call event stream so the @@ -1208,18 +1234,34 @@ class AgentToolInvocation extends BaseToolInvocation { const completionStats = getCompletionStats(); if (terminateMode === AgentTerminateMode.GOAL) { registry.complete(hookOpts.agentId, finalText, completionStats); + patchAgentMeta(metaPath, { + status: 'completed', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); } else if (terminateMode === AgentTerminateMode.CANCELLED) { registry.finalizeCancelled( hookOpts.agentId, finalText, completionStats, ); + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); } else { registry.fail( hookOpts.agentId, finalText || `Agent terminated with mode: ${terminateMode}`, completionStats, ); + patchAgentMeta(metaPath, { + status: 'failed', + lastUpdatedAt: new Date().toISOString(), + lastError: + finalText || `Agent terminated with mode: ${terminateMode}`, + }); } } catch (error) { const errorMsg = @@ -1235,8 +1277,18 @@ class AgentToolInvocation extends BaseToolInvocation { errorMsg, getCompletionStats(), ); + patchAgentMeta(metaPath, { + status: 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); } else { registry.fail(hookOpts.agentId, errorMsg, getCompletionStats()); + patchAgentMeta(metaPath, { + status: 'failed', + lastUpdatedAt: new Date().toISOString(), + lastError: errorMsg, + }); } } finally { bgEmitter.off(AgentEventType.TOOL_CALL, onToolCall); diff --git a/packages/core/src/tools/send-message.test.ts b/packages/core/src/tools/send-message.test.ts index 6cc0e1c3f79..e43792bac17 100644 --- a/packages/core/src/tools/send-message.test.ts +++ b/packages/core/src/tools/send-message.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SendMessageTool } from './send-message.js'; import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; import type { Config } from '../config/config.js'; @@ -14,11 +14,14 @@ describe('SendMessageTool', () => { let registry: BackgroundTaskRegistry; let config: Config; let tool: SendMessageTool; + let resumeBackgroundAgent: ReturnType; beforeEach(() => { registry = new BackgroundTaskRegistry(); + resumeBackgroundAgent = vi.fn(); config = { getBackgroundTaskRegistry: () => registry, + resumeBackgroundAgent, } as unknown as Config; tool = new SendMessageTool(config); }); @@ -118,6 +121,29 @@ describe('SendMessageTool', () => { expect(registry.get('agent-1')!.pendingMessages).toEqual([]); }); + it('resumes a paused task and injects the message as continuation input', async () => { + registry.register({ + agentId: 'agent-1', + description: 'test agent', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + }); + resumeBackgroundAgent.mockResolvedValue(registry.get('agent-1')); + + const result = await tool.validateBuildAndExecute( + { task_id: 'agent-1', message: 'pick up from the TODO list' }, + new AbortController().signal, + ); + + expect(resumeBackgroundAgent).toHaveBeenCalledWith( + 'agent-1', + 'pick up from the TODO list', + ); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('resumed'); + }); + it('includes task description in success display', async () => { registry.register({ agentId: 'agent-1', diff --git a/packages/core/src/tools/send-message.ts b/packages/core/src/tools/send-message.ts index 85dfdb93da2..64697bb464f 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -6,8 +6,9 @@ /** * @fileoverview SendMessage tool — lets the model send a text message to - * a running background task. The message is injected into the task's - * reasoning loop at the next tool-round boundary. + * a background task. Running tasks receive the message at the next tool-round + * boundary; paused recovered tasks are resumed first and take the message as + * their first continuation instruction. */ import type { Config } from '../config/config.js'; @@ -58,6 +59,28 @@ class SendMessageInvocation extends BaseToolInvocation< }; } + if (entry.status === 'paused') { + const resumed = await this.config.resumeBackgroundAgent( + this.params.task_id, + this.params.message, + ); + if (!resumed) { + return { + llmContent: `Error: Background task "${this.params.task_id}" could not be resumed.`, + returnDisplay: 'Task could not be resumed.', + error: { + message: `Task could not be resumed: ${this.params.task_id}`, + type: ToolErrorType.SEND_MESSAGE_NOT_RUNNING, + }, + }; + } + + return { + llmContent: `Background task "${this.params.task_id}" resumed with your message as the first continuation instruction.`, + returnDisplay: `Resumed ${entry.description}`, + }; + } + if (entry.status !== 'running') { return { llmContent: `Error: Background task "${this.params.task_id}" is not running (status: ${entry.status}). Cannot send messages to stopped tasks.`, @@ -88,7 +111,7 @@ export class SendMessageTool extends BaseDeclarativeTool< super( SendMessageTool.Name, ToolDisplayNames.SEND_MESSAGE, - 'Send a text message to a running background task. The message is delivered at the next tool-round boundary. Use this to provide additional instructions or context to a background task.', + 'Send a text message to a background task. Running tasks receive it at the next tool-round boundary. Paused recovered tasks are resumed first and use the message as their first continuation instruction.', Kind.Other, { type: 'object', @@ -96,7 +119,7 @@ export class SendMessageTool extends BaseDeclarativeTool< task_id: { type: 'string', description: - 'The ID of the running background task (from the launch response).', + 'The ID of the background task (from the launch response or a recovered paused task).', }, message: { type: 'string', diff --git a/packages/core/src/tools/task-stop.test.ts b/packages/core/src/tools/task-stop.test.ts index d5df835a3ea..10c5c053abe 100644 --- a/packages/core/src/tools/task-stop.test.ts +++ b/packages/core/src/tools/task-stop.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TaskStopTool } from './task-stop.js'; import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; import type { Config } from '../config/config.js'; @@ -14,11 +14,14 @@ describe('TaskStopTool', () => { let registry: BackgroundTaskRegistry; let config: Config; let tool: TaskStopTool; + let abandonBackgroundAgent: ReturnType; beforeEach(() => { registry = new BackgroundTaskRegistry(); + abandonBackgroundAgent = vi.fn(); config = { getBackgroundTaskRegistry: () => registry, + abandonBackgroundAgent, } as unknown as Config; tool = new TaskStopTool(config); }); @@ -91,4 +94,24 @@ describe('TaskStopTool', () => { expect(result.llmContent).toContain('Search for auth code'); expect(result.returnDisplay).toContain('Search for auth code'); }); + + it('cancels a paused agent through the resume service', async () => { + registry.register({ + agentId: 'agent-1', + description: 'Paused agent', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + }); + abandonBackgroundAgent.mockReturnValue(true); + + const result = await tool.validateBuildAndExecute( + { task_id: 'agent-1' }, + new AbortController().signal, + ); + + expect(abandonBackgroundAgent).toHaveBeenCalledWith('agent-1'); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('Cancelled paused background task'); + }); }); diff --git a/packages/core/src/tools/task-stop.ts b/packages/core/src/tools/task-stop.ts index 655b704508b..14430139663 100644 --- a/packages/core/src/tools/task-stop.ts +++ b/packages/core/src/tools/task-stop.ts @@ -5,7 +5,7 @@ */ /** - * @fileoverview TaskStop tool — lets the model cancel a running background task. + * @fileoverview TaskStop tool — lets the model stop a background task. */ import type { Config } from '../config/config.js'; @@ -54,6 +54,26 @@ class TaskStopInvocation extends BaseToolInvocation< }; } + if (entry.status === 'paused') { + const abandoned = this.config.abandonBackgroundAgent(this.params.task_id); + if (!abandoned) { + return { + llmContent: `Error: Background task "${this.params.task_id}" could not be cancelled from paused state.`, + returnDisplay: 'Task could not be cancelled.', + error: { + message: `Task could not be cancelled: ${this.params.task_id}`, + type: ToolErrorType.TASK_STOP_NOT_RUNNING, + }, + }; + } + + const desc = entry.description; + return { + llmContent: `Cancelled paused background task "${this.params.task_id}".\nDescription: ${desc}`, + returnDisplay: `Cancelled: ${desc}`, + }; + } + if (entry.status !== 'running') { return { llmContent: `Error: Background task "${this.params.task_id}" is not running (status: ${entry.status}).`, @@ -89,7 +109,7 @@ export class TaskStopTool extends BaseDeclarativeTool< super( TaskStopTool.Name, ToolDisplayNames.TASK_STOP, - 'Cancel a running background task by its ID. The task ID is returned when the task is launched.', + 'Stop a background task by its ID. Running tasks are cancelled; paused recovered tasks are abandoned without resuming them.', Kind.Other, { type: 'object', From 16e28a1e716d7cdc7f327607c1e7a37e58f71b8f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 29 Apr 2026 22:20:12 +0800 Subject: [PATCH 02/12] Fix CLI typecheck against core workspace sources --- packages/cli/tsconfig.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 82149d2ac5b..3063bed0811 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,8 +3,13 @@ "compilerOptions": { "outDir": "dist", "jsx": "react-jsx", + "baseUrl": ".", "lib": ["DOM", "DOM.Iterable", "ES2023"], - "types": ["node", "vitest/globals"] + "types": ["node", "vitest/globals"], + "paths": { + "@qwen-code/qwen-code-core": ["../core/index.ts"], + "@qwen-code/qwen-code-core/*": ["../core/src/*"] + } }, "include": [ "index.ts", From 278c7f9b27124070c62e5f412644fdd2f584d3be Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 29 Apr 2026 23:32:57 +0800 Subject: [PATCH 03/12] Fix background agent resume hook and UI blocking --- .../BackgroundTasksDialog.test.tsx | 24 ++++++ .../background-view/BackgroundTasksDialog.tsx | 20 ++++- .../ui/contexts/BackgroundTaskViewContext.tsx | 9 ++- .../agents/background-agent-resume.test.ts | 73 +++++++++++++++++++ .../src/agents/background-agent-resume.ts | 6 +- 5 files changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 0bb7e2c063d..3602d3f1558 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -244,4 +244,28 @@ describe('BackgroundTasksDialog', () => { expect(h.abandon).toHaveBeenCalledWith('a'); }); + + it('does not resume blocked paused tasks and surfaces the blocked reason', () => { + const blocked = entry({ + agentId: 'a', + status: 'paused', + error: 'Legacy fork bootstrap transcript is missing.', + }); + const h = setup([blocked]); + + h.call(() => h.probe.current!.actions.openDialog()); + expect(h.lastFrame()).not.toContain('r resume'); + expect(h.lastFrame()).toContain('x abandon'); + + h.pressKey({ sequence: 'r' }); + expect(h.resume).not.toHaveBeenCalled(); + + h.call(() => h.probe.current!.actions.enterDetail()); + const detailFrame = h.lastFrame(); + expect(detailFrame).toContain('Resume blocked'); + expect(detailFrame).toContain( + 'Legacy fork bootstrap transcript is missing.', + ); + expect(detailFrame).not.toContain('r resume'); + }); }); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index b5ee28909ab..79d2532aaba 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -272,7 +272,8 @@ const AgentDetailBody: React.FC<{ // row sits at the bottom of the Progress block. Cap at 5 in case the // registry ever raises its buffer. const activities = (entry.recentActivities ?? []).slice(-5); - const hasError = entry.status === 'failed' && Boolean(entry.error); + const hasError = Boolean(entry.error); + const errorTitle = entry.status === 'paused' ? 'Resume blocked' : 'Error'; // Prompt: show at most 5 newline-delimited segments, each row truncated // to one visual line. Append an ellipsis if the source had more. @@ -361,7 +362,7 @@ const AgentDetailBody: React.FC<{ - Error + {errorTitle} @@ -658,17 +659,28 @@ export const BackgroundTasksDialog: React.FC = ({ if (!dialogOpen) return null; + const selectedEntryAllowsResume = + selectedEntry?.kind === 'agent' && + selectedEntry.status === 'paused' && + !selectedEntry.error; + // Hint footer — context-sensitive. const hints: string[] = []; if (dialogMode === 'list') { hints.push('\u2191/\u2193 select', 'Enter view'); if (selectedEntry?.status === 'running') hints.push('x stop'); - if (selectedEntry?.status === 'paused') hints.push('r resume', 'x abandon'); + if (selectedEntryAllowsResume) hints.push('r resume'); + if (selectedEntry?.kind === 'agent' && selectedEntry.status === 'paused') { + hints.push('x abandon'); + } hints.push('\u2190/Esc close'); } else { hints.push('\u2190 go back', 'Esc/Enter/Space close'); if (selectedEntry?.status === 'running') hints.push('x stop'); - if (selectedEntry?.status === 'paused') hints.push('r resume', 'x abandon'); + if (selectedEntryAllowsResume) hints.push('r resume'); + if (selectedEntry?.kind === 'agent' && selectedEntry.status === 'paused') { + hints.push('x abandon'); + } } return ( diff --git a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx index 793cc79a702..0d025223b09 100644 --- a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx +++ b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx @@ -192,7 +192,14 @@ export function BackgroundTaskViewProvider({ const resumeSelected = useCallback(async () => { if (!config) return; const target = entries[selectedIndex]; - if (!target || target.status !== 'paused') return; + if ( + !target || + target.kind !== 'agent' || + target.status !== 'paused' || + target.error + ) { + return; + } await config.resumeBackgroundAgent(target.agentId); }, [config, entries, selectedIndex]); diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index abef3ada321..fb580ad1c6e 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -382,6 +382,79 @@ describe('BackgroundAgentResumeService', () => { }); }); + it('passes the sidechain transcript path to SubagentStop hooks on resume', async () => { + const sessionId = 'session-stop-hook'; + const agentId = 'agent-stop-hook'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume stop hook path', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Resume stop hook path' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume stop hook path', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume stop hook path', + outputFile, + metaPath, + }); + + const subagent = { + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const { service, subagentManager, hookSystem } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeDefined(); + await vi.waitFor(() => { + expect(hookSystem.fireSubagentStopEvent).toHaveBeenCalledWith( + agentId, + 'researcher', + outputFile, + 'done', + false, + expect.anything(), + expect.any(AbortSignal), + ); + }); + }); + it('coalesces concurrent resume calls into a single running agent', async () => { const sessionId = 'session-double'; const agentId = 'agent-double'; diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index bcedc2ef652..f13ba44097f 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -670,6 +670,7 @@ export class BackgroundAgentResumeService { await this.runSubagentStopHookLoop(subagent, { agentId: meta.agentId, agentType: meta.agentType, + transcriptPath: outputFile, resolvedMode, signal: bgAbortController.signal, }); @@ -918,15 +919,14 @@ export class BackgroundAgentResumeService { opts: { agentId: string; agentType: string; + transcriptPath: string; resolvedMode: PermissionMode; signal?: AbortSignal; }, ): Promise { - const { agentId, agentType, resolvedMode, signal } = opts; + const { agentId, agentType, transcriptPath, resolvedMode, signal } = opts; const hookSystem = this.config.getHookSystem(); if (!hookSystem) return; - - const transcriptPath = this.config.getTranscriptPath(); let stopHookActive = false; const maxIterations = 5; From d3d9319f899ba717bf634d5fc8cde76d48ce62bb Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Wed, 29 Apr 2026 23:46:10 +0800 Subject: [PATCH 04/12] Honor folder trust when resuming agents --- .../agents/background-agent-resume.test.ts | 73 +++++++++++++++++++ .../src/agents/background-agent-resume.ts | 27 ++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index fb580ad1c6e..4dab5307f7e 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -61,6 +61,7 @@ describe('BackgroundAgentResumeService', () => { getSubagentManager: () => subagentManager, getHookSystem: () => hookSystem, getApprovalMode: () => 'default', + isTrustedFolder: () => true, getProjectRoot: () => tempDir, getCliVersion: () => 'test-version', getGeminiClient: () => undefined, @@ -455,6 +456,78 @@ describe('BackgroundAgentResumeService', () => { }); }); + it('downgrades persisted privileged approval modes when folder trust is revoked', async () => { + const sessionId = 'session-untrusted'; + const agentId = 'agent-untrusted'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume after trust revoked', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'yolo', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Resume after trust revoked' }], + }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume after trust revoked', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume after trust revoked', + outputFile, + metaPath, + }); + + const createAgentHeadless = vi.fn().mockResolvedValue({ + execute: vi.fn(async () => undefined), + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }); + + const { service, subagentManager } = createService(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).config.isTrustedFolder = () => false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).config.getApprovalMode = () => 'default'; + subagentManager.createAgentHeadless = createAgentHeadless; + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeDefined(); + expect(createAgentHeadless).toHaveBeenCalledTimes(1); + const [, overriddenConfig] = createAgentHeadless.mock.calls[0]!; + expect(overriddenConfig.getApprovalMode()).toBe('default'); + }); + it('coalesces concurrent resume calls into a single running agent', async () => { const sessionId = 'session-double'; const agentId = 'agent-double'; diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index f13ba44097f..4fc2dcfb85a 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -110,6 +110,24 @@ function normalizeApprovalMode( } } +function reconcileResumedApprovalMode( + persistedMode: ApprovalModeValue, + parentMode: ApprovalModeValue, + isTrustedFolder: boolean, +): ApprovalModeValue { + if ( + isTrustedFolder || + (persistedMode !== 'auto-edit' && persistedMode !== 'yolo') + ) { + return persistedMode; + } + + if (parentMode === 'plan' || parentMode === 'default') { + return parentMode; + } + return 'default'; +} + function createApprovalModeOverride( base: Config, mode: ApprovalModeValue, @@ -487,9 +505,14 @@ export class BackgroundAgentResumeService { return undefined; } - const resolvedApprovalMode = normalizeApprovalMode( - meta.resolvedApprovalMode, + const parentApprovalMode = normalizeApprovalMode( this.config.getApprovalMode() as ApprovalModeValue, + 'default', + ); + const resolvedApprovalMode = reconcileResumedApprovalMode( + normalizeApprovalMode(meta.resolvedApprovalMode, parentApprovalMode), + parentApprovalMode, + this.config.isTrustedFolder(), ); const agentConfig = resolvedApprovalMode !== this.config.getApprovalMode() From ec6030b6cfd4aee823563e1c440dca9975e7b051 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 08:21:47 +0800 Subject: [PATCH 05/12] Fix background agent resume review follow-ups --- .../BackgroundTasksDialog.test.tsx | 28 +++++++++-- .../background-view/BackgroundTasksDialog.tsx | 26 ++++++++-- .../ui/contexts/BackgroundTaskViewContext.tsx | 2 +- .../cli/src/ui/hooks/useBackgroundTaskView.ts | 7 ++- packages/cli/tsconfig.json | 2 +- .../agents/background-agent-resume.test.ts | 50 ++++++++++++++++++- .../src/agents/background-agent-resume.ts | 45 +++++++++++------ packages/core/src/agents/background-tasks.ts | 5 ++ packages/core/src/tools/agent/agent.test.ts | 34 +++++++++++++ packages/core/src/tools/agent/agent.ts | 9 ++-- 10 files changed, 179 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx index 3602d3f1558..b264d8512d1 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.test.tsx @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useState } from 'react'; import { act } from '@testing-library/react'; import { render } from 'ink-testing-library'; -import type { BackgroundTaskEntry, Config } from '@qwen-code/qwen-code-core'; +import type { Config } from '@qwen-code/qwen-code-core'; import { BackgroundTasksDialog } from './BackgroundTasksDialog.js'; import { BackgroundTaskViewProvider, @@ -17,6 +17,7 @@ import { } from '../../contexts/BackgroundTaskViewContext.js'; import { ConfigContext } from '../../contexts/ConfigContext.js'; import { + type AgentDialogEntry, useBackgroundTaskView, type DialogEntry, } from '../../hooks/useBackgroundTaskView.js'; @@ -38,7 +39,7 @@ vi.mock('../../hooks/useKeypress.js', () => ({ const mockedUseBackgroundTaskView = vi.mocked(useBackgroundTaskView); const mockedUseKeypress = vi.mocked(useKeypress); -function entry(overrides: Partial = {}): DialogEntry { +function entry(overrides: Partial = {}): DialogEntry { return { kind: 'agent', agentId: 'a', @@ -249,7 +250,7 @@ describe('BackgroundTasksDialog', () => { const blocked = entry({ agentId: 'a', status: 'paused', - error: 'Legacy fork bootstrap transcript is missing.', + resumeBlockedReason: 'Legacy fork bootstrap transcript is missing.', }); const h = setup([blocked]); @@ -268,4 +269,25 @@ describe('BackgroundTasksDialog', () => { ); expect(detailFrame).not.toContain('r resume'); }); + + it('still allows resume for paused tasks that only have a stale error', () => { + const paused = entry({ + agentId: 'a', + status: 'paused', + error: 'Temporary resume setup failed.', + }); + const h = setup([paused]); + + h.call(() => h.probe.current!.actions.openDialog()); + expect(h.lastFrame()).toContain('r resume'); + + h.pressKey({ sequence: 'r' }); + expect(h.resume).toHaveBeenCalledWith('a'); + + h.call(() => h.probe.current!.actions.enterDetail()); + const detailFrame = h.lastFrame(); + expect(detailFrame).toContain('Error'); + expect(detailFrame).toContain('Temporary resume setup failed.'); + expect(detailFrame).toContain('r resume'); + }); }); diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index 79d2532aaba..1d1e4d31035 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -30,6 +30,7 @@ import { } from '@qwen-code/qwen-code-core'; import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { + type AgentDialogEntry, type DialogEntry, entryId, } from '../../hooks/useBackgroundTaskView.js'; @@ -249,7 +250,7 @@ const DetailBody: React.FC<{ ); const AgentDetailBody: React.FC<{ - entry: BackgroundTaskEntry; + entry: AgentDialogEntry; maxHeight: number; maxWidth: number; }> = ({ entry, maxHeight, maxWidth }) => { @@ -272,8 +273,9 @@ const AgentDetailBody: React.FC<{ // row sits at the bottom of the Progress block. Cap at 5 in case the // registry ever raises its buffer. const activities = (entry.recentActivities ?? []).slice(-5); + const blockedReason = entry.resumeBlockedReason; const hasError = Boolean(entry.error); - const errorTitle = entry.status === 'paused' ? 'Resume blocked' : 'Error'; + const hasBlockedReason = Boolean(blockedReason); // Prompt: show at most 5 newline-delimited segments, each row truncated // to one visual line. Append an ellipsis if the source had more. @@ -357,12 +359,28 @@ const AgentDetailBody: React.FC<{ )} + {hasBlockedReason && ( + + + + + Resume blocked + + + + + {blockedReason} + + + + )} + {hasError && ( - {errorTitle} + Error @@ -662,7 +680,7 @@ export const BackgroundTasksDialog: React.FC = ({ const selectedEntryAllowsResume = selectedEntry?.kind === 'agent' && selectedEntry.status === 'paused' && - !selectedEntry.error; + !selectedEntry.resumeBlockedReason; // Hint footer — context-sensitive. const hints: string[] = []; diff --git a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx index 0d025223b09..be3456f26f9 100644 --- a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx +++ b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx @@ -196,7 +196,7 @@ export function BackgroundTaskViewProvider({ !target || target.kind !== 'agent' || target.status !== 'paused' || - target.error + target.resumeBlockedReason ) { return; } diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskView.ts b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts index 7e4fe89fd30..5359a73391e 100644 --- a/packages/cli/src/ui/hooks/useBackgroundTaskView.ts +++ b/packages/cli/src/ui/hooks/useBackgroundTaskView.ts @@ -28,6 +28,11 @@ import { type Config, } from '@qwen-code/qwen-code-core'; +export type AgentDialogEntry = BackgroundTaskEntry & { + kind: 'agent'; + resumeBlockedReason?: string; +}; + /** * A unified view-model entry the dialog/pill/context render against. * Discriminated by `kind`; agent-shaped fields and shell-shaped fields @@ -35,7 +40,7 @@ import { * branch (just guarded by `kind === 'agent'`). */ export type DialogEntry = - | (BackgroundTaskEntry & { kind: 'agent' }) + | AgentDialogEntry | (BackgroundShellEntry & { kind: 'shell' }); export interface UseBackgroundTaskViewResult { diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 3063bed0811..988cc7e5b5f 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,7 +7,7 @@ "lib": ["DOM", "DOM.Iterable", "ES2023"], "types": ["node", "vitest/globals"], "paths": { - "@qwen-code/qwen-code-core": ["../core/index.ts"], + "@qwen-code/qwen-code-core": ["../core/src/index.ts"], "@qwen-code/qwen-code-core/*": ["../core/src/*"] } }, diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index 4dab5307f7e..ccd2cade0cb 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -250,11 +250,56 @@ describe('BackgroundAgentResumeService', () => { agentId, status: 'paused', subagentType: 'deleted-agent', - error: 'Subagent "deleted-agent" is no longer available.', + resumeBlockedReason: 'Subagent "deleted-agent" is no longer available.', }); expect(subagentManager.loadSubagent).toHaveBeenCalledWith('deleted-agent'); }); + it('keeps paused tasks resumable when they only carry a stale lastError', async () => { + const sessionId = 'session-stale-error'; + const agentId = 'agent-stale-error'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Interrupted task with stale error', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + lastError: 'Temporary resume setup failed', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Interrupted task with stale error' }], + }, + }) + '\n', + 'utf8', + ); + + const { service } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(1); + expect(recovered[0]).toMatchObject({ + agentId, + status: 'paused', + error: 'Temporary resume setup failed', + }); + expect(recovered[0]?.resumeBlockedReason).toBeUndefined(); + }); + it('falls back to legacy agentType metadata when resume fields are missing', async () => { const sessionId = 'session-legacy'; const agentId = 'agent-legacy'; @@ -780,9 +825,10 @@ describe('BackgroundAgentResumeService', () => { expect(resumed).toBeUndefined(); expect(registry.get(agentId)?.status).toBe('paused'); - expect(registry.get(agentId)?.error).toContain( + expect(registry.get(agentId)?.resumeBlockedReason).toContain( 'bootstrap transcript is missing', ); + expect(registry.get(agentId)?.error).toBeUndefined(); expect(createSpy).not.toHaveBeenCalled(); createSpy.mockRestore(); }); diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 4fc2dcfb85a..9620e6b3dca 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -55,6 +55,9 @@ const META_FILE_SUFFIX = '.meta.json'; export const DEFAULT_BACKGROUND_AGENT_CONTINUATION_MESSAGE = 'Continue working on the current task from the last completed step.'; +const LEGACY_FORK_RESUME_BLOCKED_REASON = + 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; + type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; interface TranscriptRecovery { @@ -81,6 +84,11 @@ interface ResumeOperation { promise: Promise; } +interface RestorePausedEntryOptions { + error?: string; + resumeBlockedReason?: string; +} + function approvalModeToPermissionMode(mode?: string): PermissionMode { switch (mode) { case 'yolo': @@ -387,6 +395,12 @@ export class BackgroundAgentResumeService { const recovery = recoverTranscript(records); const parsedStartTime = Date.parse(meta.createdAt); + const resumeBlockedReason = + target.unavailableReason || + (target.isFork && !recovery.forkBootstrap + ? LEGACY_FORK_RESUME_BLOCKED_REASON + : undefined); + const entry: BackgroundTaskEntry = { agentId: meta.agentId, description: meta.description, @@ -399,7 +413,9 @@ export class BackgroundAgentResumeService { prompt: recovery.initialPrompt, outputFile, metaPath, - error: meta.lastError ?? target.unavailableReason, + error: + meta.lastError === resumeBlockedReason ? undefined : meta.lastError, + resumeBlockedReason, }; registry.register(entry); recovered.push(entry); @@ -484,6 +500,7 @@ export class BackgroundAgentResumeService { endTime: undefined, result: undefined, error: undefined, + resumeBlockedReason: undefined, stats: undefined, recentActivities: [], pendingMessages: [...(existing.pendingMessages ?? [])], @@ -498,10 +515,10 @@ export class BackgroundAgentResumeService { target.unavailableReason || `Subagent "${subagentName}" is no longer available.`; patchAgentMeta(metaPath, { - lastError: reason, + lastError: undefined, lastUpdatedAt: new Date().toISOString(), }); - this.restorePausedEntry(agentId, reason); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); return undefined; } @@ -554,23 +571,21 @@ export class BackgroundAgentResumeService { : undefined; const writerInitialPrompt = continuationPrompt; if (target.isFork && (!resumeHistory || resumeHistory.length === 0)) { - const reason = - 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; + const reason = LEGACY_FORK_RESUME_BLOCKED_REASON; patchAgentMeta(metaPath, { - lastError: reason, + lastError: undefined, lastUpdatedAt: new Date().toISOString(), }); - this.restorePausedEntry(agentId, reason); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); return undefined; } if (target.isFork && !recovery.forkBootstrap) { - const reason = - 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; + const reason = LEGACY_FORK_RESUME_BLOCKED_REASON; patchAgentMeta(metaPath, { - lastError: reason, + lastError: undefined, lastUpdatedAt: new Date().toISOString(), }); - this.restorePausedEntry(agentId, reason); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); return undefined; } @@ -630,6 +645,7 @@ export class BackgroundAgentResumeService { endTime: undefined, result: undefined, error: undefined, + resumeBlockedReason: undefined, stats: undefined, prompt: recovery.initialPrompt ?? existing.prompt, recentActivities: [], @@ -781,7 +797,7 @@ export class BackgroundAgentResumeService { if (latest.abortController.signal.aborted) { registry.finalizeCancelled(agentId, errorMessage); } else { - this.restorePausedEntry(agentId, errorMessage); + this.restorePausedEntry(agentId, { error: errorMessage }); } } return undefined; @@ -839,7 +855,7 @@ export class BackgroundAgentResumeService { private restorePausedEntry( agentId: string, - error?: string, + options: RestorePausedEntryOptions = {}, ): BackgroundTaskEntry | undefined { const registry = this.config.getBackgroundTaskRegistry(); const latest = registry.get(agentId); @@ -851,7 +867,8 @@ export class BackgroundAgentResumeService { abortController: new AbortController(), endTime: undefined, result: undefined, - error, + error: options.error, + resumeBlockedReason: options.resumeBlockedReason, stats: undefined, recentActivities: [], pendingMessages: [...(latest.pendingMessages ?? [])], diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index b0a2ed1bf1a..e94865a9014 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -108,6 +108,11 @@ export interface BackgroundTaskEntry { endTime?: number; result?: string; error?: string; + /** + * Present only when the task is intentionally kept paused but cannot be + * safely resumed under the current conditions. + */ + resumeBlockedReason?: string; abortController: AbortController; stats?: AgentCompletionStats; toolUseId?: string; diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index ac0c55c2ea5..76bbcf50b63 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -1531,6 +1531,40 @@ describe('AgentTool', () => { expect(mockRegistry.register).toHaveBeenCalled(); }); + it('passes the sidechain transcript path to SubagentStop hooks for fresh background agents', async () => { + const mockHookSystem = { + fireSubagentStartEvent: vi.fn().mockResolvedValue(undefined), + fireSubagentStopEvent: vi.fn().mockResolvedValue(undefined), + } as unknown as HookSystem; + (config as unknown as Record)['getHookSystem'] = vi + .fn() + .mockReturnValue(mockHookSystem); + + const params: AgentParams = { + description: 'Start monitor', + prompt: 'Watch for changes', + subagent_type: 'monitor', + }; + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(params); + await invocation.execute(); + await vi.waitFor(() => { + expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenCalledWith( + expect.stringContaining('monitor-'), + 'monitor', + expect.stringMatching( + /^\/tmp\/qwen-test\/subagents\/test-session-id\/agent-monitor-.*\.jsonl$/, + ), + 'Monitor done', + false, + PermissionMode.AutoEdit, + expect.any(AbortSignal), + ); + }); + }); + it('should run in foreground when neither flag is set', async () => { const fgSubagent: SubagentConfig = { ...bgSubagent, diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index c78aa2504b7..88b9bb08612 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -753,15 +753,17 @@ class AgentToolInvocation extends BaseToolInvocation { opts: { agentId: string; agentType: string; + transcriptPath?: string; resolvedMode: PermissionMode; signal?: AbortSignal; }, ): Promise { - const { agentId, agentType, resolvedMode, signal } = opts; + const { agentId, agentType, transcriptPath, resolvedMode, signal } = opts; const hookSystem = this.config.getHookSystem(); if (!hookSystem) return; - const transcriptPath = this.config.getTranscriptPath(); + const effectiveTranscriptPath = + transcriptPath ?? this.config.getTranscriptPath(); let stopHookActive = false; const maxIterations = 5; @@ -770,7 +772,7 @@ class AgentToolInvocation extends BaseToolInvocation { const stopHookOutput = await hookSystem.fireSubagentStopEvent( agentId, agentType, - transcriptPath, + effectiveTranscriptPath, subagent.getFinalText(), stopHookActive, resolvedMode, @@ -1218,6 +1220,7 @@ class AgentToolInvocation extends BaseToolInvocation { await this.runSubagentStopHookLoop(bgSubagent, { agentId: hookOpts.agentId, agentType: hookOpts.agentType, + transcriptPath: jsonlPath, resolvedMode, signal: bgAbortController.signal, }); From a61286f001dcf5ceb24fef118dedea7f27b3770f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 11:38:47 +0800 Subject: [PATCH 06/12] Fix tasks command to include background agents --- .../cli/src/ui/commands/tasksCommand.test.ts | 82 ++++++++++++++++-- packages/cli/src/ui/commands/tasksCommand.ts | 83 ++++++++++++++++--- 2 files changed, 145 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/commands/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts index 9d4355b046c..420fe98db71 100644 --- a/packages/cli/src/ui/commands/tasksCommand.test.ts +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -8,7 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { tasksCommand } from './tasksCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core'; +import type { + BackgroundShellEntry, + BackgroundTaskEntry, +} from '@qwen-code/qwen-code-core'; + +type AgentTaskTestEntry = BackgroundTaskEntry & { + resumeBlockedReason?: string; +}; function entry( overrides: Partial = {}, @@ -25,16 +32,33 @@ function entry( }; } +function agentEntry( + overrides: Partial = {}, +): AgentTaskTestEntry { + return { + agentId: 'agent_aaaaaaaa', + description: 'Investigate flaky test failure', + subagentType: 'researcher', + status: 'running', + startTime: Date.now() - 7_000, + abortController: new AbortController(), + ...overrides, + }; +} + describe('tasksCommand', () => { let context: CommandContext; - let getAll: ReturnType; + let getShells: ReturnType; + let getAgents: ReturnType; beforeEach(() => { - getAll = vi.fn().mockReturnValue([]); + getShells = vi.fn().mockReturnValue([]); + getAgents = vi.fn().mockReturnValue([]); context = createMockCommandContext({ services: { config: { - getBackgroundShellRegistry: () => ({ getAll }), + getBackgroundShellRegistry: () => ({ getAll: getShells }), + getBackgroundTaskRegistry: () => ({ getAll: getAgents }), }, }, } as unknown as Parameters[0]); @@ -45,12 +69,12 @@ describe('tasksCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'No background shells.', + content: 'No background tasks.', }); }); - it('lists running and terminal entries with status / runtime / output path', async () => { - getAll.mockReturnValue([ + it('lists running and terminal shell entries with status / runtime / output path', async () => { + getShells.mockReturnValue([ entry({ shellId: 'bg_run', command: 'npm run dev', @@ -81,7 +105,7 @@ describe('tasksCommand', () => { if (!result || result.type !== 'message') { throw new Error('expected message result'); } - expect(result.content).toContain('Background shells (3 total)'); + expect(result.content).toContain('Background tasks (3 total)'); expect(result.content).toContain('[bg_run] running'); expect(result.content).toContain('pid=1111'); expect(result.content).toContain('npm run dev'); @@ -91,4 +115,46 @@ describe('tasksCommand', () => { 'output: /tmp/tasks/sess/shell-bg_done.output', ); }); + + it('includes background agent entries alongside shells', async () => { + getAgents.mockReturnValue([ + agentEntry({ + agentId: 'agent_run', + description: 'Fix flaky test and send patch', + subagentType: 'researcher', + status: 'running', + outputFile: '/tmp/tasks/sess/agent_run.jsonl', + }), + agentEntry({ + agentId: 'agent_pause', + description: 'Resume-safe task', + subagentType: 'researcher', + status: 'paused', + resumeBlockedReason: 'Subagent "researcher" is no longer available.', + }), + ]); + getShells.mockReturnValue([ + entry({ + shellId: 'bg_shell', + command: 'npm run dev', + status: 'running', + }), + ]); + + const result = await tasksCommand.action!(context, ''); + if (!result || result.type !== 'message') { + throw new Error('expected message result'); + } + + expect(result.content).toContain('Background tasks (3 total)'); + expect(result.content).toContain('[agent_run] running'); + expect(result.content).toContain( + 'researcher: Fix flaky test and send patch', + ); + expect(result.content).toContain('output: /tmp/tasks/sess/agent_run.jsonl'); + expect(result.content).toContain( + '[agent_pause] paused (resume blocked): Subagent "researcher" is no longer available.', + ); + expect(result.content).toContain('[bg_shell] running'); + }); }); diff --git a/packages/cli/src/ui/commands/tasksCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts index b58772e410c..a8c5c99b423 100644 --- a/packages/cli/src/ui/commands/tasksCommand.ts +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -4,13 +4,44 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { BackgroundShellEntry } from '@qwen-code/qwen-code-core'; +import { + buildBackgroundEntryLabel, + type BackgroundShellEntry, + type BackgroundTaskEntry, +} from '@qwen-code/qwen-code-core'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { formatDuration } from '../utils/formatters.js'; -function statusLabel(entry: BackgroundShellEntry): string { +type AgentTaskEntry = BackgroundTaskEntry & { + kind: 'agent'; + resumeBlockedReason?: string; +}; + +type ShellTaskEntry = BackgroundShellEntry & { kind: 'shell' }; + +type TaskEntry = AgentTaskEntry | ShellTaskEntry; + +function statusLabel(entry: TaskEntry): string { + if (entry.kind === 'agent') { + switch (entry.status) { + case 'completed': + return 'completed'; + case 'failed': + return `failed: ${entry.error ?? 'unknown error'}`; + case 'cancelled': + return 'cancelled'; + case 'paused': + return entry.resumeBlockedReason + ? `paused (resume blocked): ${entry.resumeBlockedReason}` + : 'paused'; + case 'running': + default: + return 'running'; + } + } + switch (entry.status) { case 'completed': return `completed (exit ${entry.exitCode ?? '?'})`; @@ -21,10 +52,25 @@ function statusLabel(entry: BackgroundShellEntry): string { case 'running': return 'running'; default: - return entry.status; + return 'running'; } } +function taskLabel(entry: TaskEntry): string { + if (entry.kind === 'agent') { + return buildBackgroundEntryLabel(entry); + } + return entry.command; +} + +function taskId(entry: TaskEntry): string { + return entry.kind === 'agent' ? entry.agentId : entry.shellId; +} + +function taskOutputPath(entry: TaskEntry): string | undefined { + return entry.kind === 'agent' ? entry.outputFile : entry.outputPath; +} + export const tasksCommand: SlashCommand = { name: 'tasks', get description() { @@ -42,31 +88,44 @@ export const tasksCommand: SlashCommand = { }; } - const entries = config.getBackgroundShellRegistry().getAll(); + const agentEntries: AgentTaskEntry[] = config + .getBackgroundTaskRegistry() + .getAll() + .map((entry) => ({ ...entry, kind: 'agent' as const })); + const shellEntries: ShellTaskEntry[] = config + .getBackgroundShellRegistry() + .getAll() + .map((entry) => ({ ...entry, kind: 'shell' as const })); + const entries = [...agentEntries, ...shellEntries].sort( + (a, b) => a.startTime - b.startTime, + ); if (entries.length === 0) { return { type: 'message' as const, messageType: 'info' as const, - content: 'No background shells.', + content: 'No background tasks.', }; } const now = Date.now(); - const lines: string[] = [ - `Background shells (${entries.length} total)`, - '', - ]; + const lines: string[] = [`Background tasks (${entries.length} total)`, '']; for (const entry of entries) { const endTime = entry.endTime ?? now; const runtime = formatDuration(endTime - entry.startTime, { hideTrailingZeros: true, }); - const pidPart = entry.pid !== undefined ? ` pid=${entry.pid}` : ''; + const pidPart = + entry.kind === 'shell' && entry.pid !== undefined + ? ` pid=${entry.pid}` + : ''; lines.push( - `[${entry.shellId}] ${statusLabel(entry)} ${runtime}${pidPart} ${entry.command}`, + `[${taskId(entry)}] ${statusLabel(entry)} ${runtime}${pidPart} ${taskLabel(entry)}`, ); - lines.push(` output: ${entry.outputPath}`); + const outputPath = taskOutputPath(entry); + if (outputPath) { + lines.push(` output: ${outputPath}`); + } } return { From 146a4a9e8400e5c64189f4a31e80b2b1caa29fb5 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 15:00:50 +0800 Subject: [PATCH 07/12] Harden background agent resume lifecycle --- .../cli/src/ui/hooks/useResumeCommand.test.ts | 55 +++ packages/core/src/agents/agent-transcript.ts | 21 +- .../agents/background-agent-resume.test.ts | 313 ++++++++++++++++++ .../src/agents/background-agent-resume.ts | 108 +++--- .../core/src/services/chatRecordingService.ts | 11 + packages/core/src/tools/agent/agent.test.ts | 49 +++ packages/core/src/tools/agent/agent.ts | 53 +-- packages/core/src/tools/shell.test.ts | 59 +++- packages/core/src/tools/shell.ts | 34 +- 9 files changed, 593 insertions(+), 110 deletions(-) diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 65761b374eb..0bb2efac28e 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -206,4 +206,59 @@ describe('useResumeCommand', () => { expect(historyManager.clearItems).toHaveBeenCalledTimes(1); expect(historyManager.loadHistory).toHaveBeenCalledTimes(1); }); + + it('adds a recovered-background-agents notice when paused agents are restored', async () => { + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + const startNewSession = vi.fn(); + const geminiClient = { + initialize: vi.fn(), + }; + const buildRecoveredBackgroundAgentsNotice = vi + .fn() + .mockReturnValue('Recovered 2 interrupted background agents.'); + + const config = { + getTargetDir: () => '/tmp', + getGeminiClient: () => geminiClient, + startNewSession: vi.fn(), + loadPausedBackgroundAgents: vi + .fn() + .mockResolvedValue([{ agentId: 'a' }, { agentId: 'b' }]), + getBackgroundAgentResumeService: () => ({ + buildRecoveredBackgroundAgentsNotice, + }), + getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), + getDebugLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('session-3'); + }); + + expect(config.loadPausedBackgroundAgents).toHaveBeenCalledWith('session-3'); + expect(buildRecoveredBackgroundAgentsNotice).toHaveBeenCalledWith(2); + expect(historyManager.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'Recovered 2 interrupted background agents.', + }), + expect.any(Number), + ); + }); }); diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index f03f1554b5c..eabfb36641f 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -35,6 +35,7 @@ import type { } from '../services/chatRecordingService.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { _recoverObjectsFromLine } from '../utils/jsonl-utils.js'; +import type { FunctionDeclaration, Content } from '@google/genai'; const debugLogger = createDebugLogger('AGENT_TRANSCRIPT'); @@ -209,7 +210,15 @@ export interface AttachJsonlOptions { * Exact bootstrap history that seeded the agent before its first runtime * turn. Used by transcript-first resume to reconstruct fork constraints. */ - bootstrapHistory?: Array; + bootstrapHistory?: Content[]; + /** + * Immutable launch-time system instruction for fork resume. + */ + bootstrapSystemInstruction?: string | Content; + /** + * Immutable launch-time tool declarations / allowlist for fork resume. + */ + bootstrapTools?: Array; /** * Launching prompt that should be treated as the first model-facing task * prompt during transcript-based resume. For forks this may differ from the @@ -378,6 +387,16 @@ export function attachJsonlTranscriptWriter( const payload: AgentBootstrapRecordPayload = { kind: 'fork', history: structuredClone(options.bootstrapHistory), + ...(options.bootstrapSystemInstruction !== undefined + ? { + systemInstruction: structuredClone( + options.bootstrapSystemInstruction, + ), + } + : {}), + ...(options.bootstrapTools !== undefined + ? { tools: structuredClone(options.bootstrapTools) } + : {}), }; recordSystem('agent_bootstrap', payload); } diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index ccd2cade0cb..ec41a44dc76 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -650,6 +650,11 @@ describe('BackgroundAgentResumeService', () => { await vi.waitFor(() => { expect(registry.get(agentId)?.status).toBe('completed'); }); + const provider = subagent.setExternalMessageProvider.mock.calls[0]?.[0] as + | (() => string[]) + | undefined; + expect(provider).toBeDefined(); + expect(provider?.()).toEqual(['second message']); }); it('resumes fork agents from transcript bootstrap instead of current parent config', async () => { @@ -686,6 +691,11 @@ describe('BackgroundAgentResumeService', () => { { role: 'user', parts: [{ text: 'bootstrap env' }] }, { role: 'model', parts: [{ text: 'bootstrap ack' }] }, ], + systemInstruction: { + role: 'system', + parts: [{ text: 'persisted system instruction' }], + }, + tools: [{ name: 'Bash' }, { name: 'Read' }], }, }), JSON.stringify({ @@ -756,6 +766,10 @@ describe('BackgroundAgentResumeService', () => { const createArgs = createSpy.mock.calls[0]; expect(createArgs).toBeDefined(); expect(createArgs![2]).toMatchObject({ + renderedSystemPrompt: { + role: 'system', + parts: [{ text: 'persisted system instruction' }], + }, initialMessages: [ { role: 'user', parts: [{ text: 'bootstrap env' }] }, { role: 'model', parts: [{ text: 'bootstrap ack' }] }, @@ -763,6 +777,9 @@ describe('BackgroundAgentResumeService', () => { { role: 'model', parts: [{ text: 'Working silently' }] }, ], }); + expect(createArgs?.[5]).toEqual({ + tools: [{ name: 'Bash' }, { name: 'Read' }], + }); expect(execute).toHaveBeenCalledTimes(1); const executeCall = execute.mock.calls[0]; expect(executeCall).toBeDefined(); @@ -832,4 +849,300 @@ describe('BackgroundAgentResumeService', () => { expect(createSpy).not.toHaveBeenCalled(); createSpy.mockRestore(); }); + + it('keeps fork tasks paused when bootstrap capabilities are missing', async () => { + const sessionId = 'session-fork-cap-legacy'; + const agentId = 'agent-fork-cap-legacy'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: FORK_SUBAGENT_TYPE, + description: 'Legacy fork task without capabilities', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: FORK_SUBAGENT_TYPE, + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + [ + JSON.stringify({ + uuid: 'sys1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'system', + subtype: 'agent_bootstrap', + systemPayload: { + kind: 'fork', + history: [{ role: 'user', parts: [{ text: 'bootstrap env' }] }], + }, + }), + JSON.stringify({ + uuid: 'u1', + parentUuid: 'sys1', + sessionId, + timestamp: '2026-04-20T00:00:00.100Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Legacy fork task' }] }, + }), + JSON.stringify({ + uuid: 'sys2', + parentUuid: 'u1', + sessionId, + timestamp: '2026-04-20T00:00:00.200Z', + type: 'system', + subtype: 'agent_launch_prompt', + systemPayload: { + displayText: buildChildMessage('Legacy fork task'), + }, + }), + ].join('\n') + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Legacy fork task without capabilities', + subagentType: FORK_SUBAGENT_TYPE, + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Legacy fork task', + outputFile, + metaPath, + }); + + const createSpy = vi.spyOn(AgentHeadless, 'create'); + const { service } = createService(); + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + + expect(resumed).toBeUndefined(); + expect(registry.get(agentId)?.status).toBe('paused'); + expect(registry.get(agentId)?.resumeBlockedReason).toContain( + 'runtime constraints are missing', + ); + expect(createSpy).not.toHaveBeenCalled(); + createSpy.mockRestore(); + }); + + it('does not persist cancelled status on generic launch interruption recovery', async () => { + const sessionId = 'session-running-shutdown'; + const agentId = 'agent-running-shutdown'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Interrupted by shutdown', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + + registry.register({ + agentId, + description: 'Interrupted by shutdown', + subagentType: 'researcher', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Interrupted by shutdown', + metaPath, + outputFile: getAgentJsonlPath(tempDir, sessionId, agentId), + }); + + registry.abortAll(); + + expect(readMetaStatus(metaPath)).toBe('running'); + }); + + it('keeps resumed tasks resumable after a generic shutdown abort', async () => { + const sessionId = 'session-resume-shutdown'; + const agentId = 'agent-resume-shutdown'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume then shutdown', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Resume then shutdown' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume then shutdown', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume then shutdown', + outputFile, + metaPath, + }); + + let releaseExecute: (() => void) | undefined; + const execute = vi.fn( + () => + new Promise((resolve) => { + releaseExecute = resolve; + }), + ); + const subagent = { + execute, + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.CANCELLED, + getFinalText: () => '', + }; + + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + expect(resumed).toBeDefined(); + registry.abortAll(); + releaseExecute?.(); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('cancelled'); + }); + expect(readMetaStatus(metaPath)).toBe('running'); + }); + + it('injects pending trailing user text via initial_messages_override', async () => { + const sessionId = 'session-pending-user'; + const agentId = 'agent-pending-user'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Pending user tail', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + [ + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'original task' }] }, + }), + JSON.stringify({ + uuid: 'a1', + parentUuid: 'u1', + sessionId, + timestamp: '2026-04-20T00:00:00.100Z', + type: 'assistant', + message: { role: 'model', parts: [{ text: 'working' }] }, + }), + JSON.stringify({ + uuid: 'u2', + parentUuid: 'a1', + sessionId, + timestamp: '2026-04-20T00:00:00.200Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'and another thing' }] }, + }), + ].join('\n') + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Pending user tail', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'original task', + outputFile, + metaPath, + }); + + const execute = vi.fn( + async (context: { get: (key: string) => unknown }) => { + const override = context.get('initial_messages_override') as + | Array<{ parts?: Array<{ text?: string }> }> + | undefined; + expect(override).toEqual([ + { + role: 'user', + parts: [{ text: 'and another thing' }, { text: '\ncontinue work' }], + }, + ]); + }, + ); + const subagent = { + execute, + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.GOAL, + getFinalText: () => 'done', + }; + + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + await service.resumeBackgroundAgent(agentId, 'continue work'); + + expect(subagentManager.createAgentHeadless).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + promptConfigOverrides: { + initialMessages: [ + { role: 'user', parts: [{ text: 'original task' }] }, + { role: 'model', parts: [{ text: 'working' }] }, + ], + }, + }), + ); + }); }); + +function readMetaStatus(metaPath: string): string | undefined { + const raw = fs.readFileSync(metaPath, 'utf8'); + return JSON.parse(raw).status; +} diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 9620e6b3dca..6e4e8b6f7cc 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -57,6 +57,8 @@ export const DEFAULT_BACKGROUND_AGENT_CONTINUATION_MESSAGE = const LEGACY_FORK_RESUME_BLOCKED_REASON = 'Fork background task cannot be safely resumed because its bootstrap transcript is missing.'; +const LEGACY_FORK_CAPABILITIES_BLOCKED_REASON = + 'Fork background task cannot be safely resumed because its launch-time runtime constraints are missing.'; type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; @@ -69,6 +71,8 @@ interface TranscriptRecovery { history: Content[]; taskPrompt: string; runtimeHistory: Content[]; + systemInstruction?: string | Content; + tools?: Array; }; } @@ -146,6 +150,17 @@ function createApprovalModeOverride( return override as Config; } +function persistBackgroundCancellation( + metaPath: string, + abortedBySignal: boolean, +): void { + patchAgentMeta(metaPath, { + status: abortedBySignal ? 'running' : 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); +} + function isWhitespaceOnlyAssistant(record: ChatRecord): boolean { if (record.type !== 'assistant' || !record.message?.parts?.length) { return false; @@ -321,6 +336,14 @@ function recoverTranscript(records: ChatRecord[]): TranscriptRecovery { (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload) .history, ), + systemInstruction: structuredClone( + (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload) + .systemInstruction, + ), + tools: structuredClone( + (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload) + .tools, + ), taskPrompt: ( launchPromptRecord!.systemPayload as NotificationRecordPayload ).displayText, @@ -399,7 +422,11 @@ export class BackgroundAgentResumeService { target.unavailableReason || (target.isFork && !recovery.forkBootstrap ? LEGACY_FORK_RESUME_BLOCKED_REASON - : undefined); + : target.isFork && + (!recovery.forkBootstrap?.systemInstruction || + !recovery.forkBootstrap?.tools) + ? LEGACY_FORK_CAPABILITIES_BLOCKED_REASON + : undefined); const entry: BackgroundTaskEntry = { agentId: meta.agentId, @@ -482,16 +509,6 @@ export class BackgroundAgentResumeService { } const bgAbortController = new AbortController(); - bgAbortController.signal.addEventListener( - 'abort', - () => { - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - }); - }, - { once: true }, - ); registry.register({ ...existing, @@ -588,6 +605,19 @@ export class BackgroundAgentResumeService { this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); return undefined; } + if ( + target.isFork && + (!recovery.forkBootstrap?.systemInstruction || + !recovery.forkBootstrap?.tools) + ) { + const reason = LEGACY_FORK_CAPABILITIES_BLOCKED_REASON; + patchAgentMeta(metaPath, { + lastError: undefined, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); + return undefined; + } const bgEventEmitter = new AgentEventEmitter(); const subagent = target.isFork @@ -595,6 +625,7 @@ export class BackgroundAgentResumeService { bgConfig as Config, bgEventEmitter, resumeHistory ?? [], + recovery.forkBootstrap!, ) : await this.config .getSubagentManager() @@ -727,11 +758,10 @@ export class BackgroundAgentResumeService { }); } else if (terminateMode === AgentTerminateMode.CANCELLED) { registry.finalizeCancelled(meta.agentId, finalText, stats); - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - lastError: undefined, - }); + persistBackgroundCancellation( + metaPath, + bgAbortController.signal.aborted, + ); } else { const failureText = finalText || `Agent terminated with mode: ${terminateMode}`; @@ -754,11 +784,10 @@ export class BackgroundAgentResumeService { errorMessage, getCompletionStats(subagent, liveToolCallCount), ); - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - lastError: undefined, - }); + persistBackgroundCancellation( + metaPath, + bgAbortController.signal.aborted, + ); } else { registry.fail( meta.agentId, @@ -882,36 +911,15 @@ export class BackgroundAgentResumeService { agentConfig: Config, eventEmitter: AgentEventEmitter, initialMessages: Content[], + bootstrap: NonNullable, ): Promise { - const geminiClient = this.config.getGeminiClient(); - const generationConfig = geminiClient?.getChat().getGenerationConfig(); - - let promptConfig: PromptConfig; - let toolConfig: ToolConfig; - if (generationConfig?.systemInstruction) { - const parentToolDecls: FunctionDeclaration[] = - ( - generationConfig.tools as Array<{ - functionDeclarations?: FunctionDeclaration[]; - }> - )?.flatMap((tool) => tool.functionDeclarations ?? []) ?? []; - - promptConfig = { - renderedSystemPrompt: generationConfig.systemInstruction as - | string - | Content, - initialMessages, - }; - toolConfig = { - tools: parentToolDecls.length > 0 ? parentToolDecls : ['*'], - }; - } else { - promptConfig = { - systemPrompt: FORK_AGENT.systemPrompt, - initialMessages, - }; - toolConfig = { tools: ['*'] }; - } + const promptConfig: PromptConfig = { + renderedSystemPrompt: structuredClone(bootstrap.systemInstruction!), + initialMessages, + }; + const toolConfig: ToolConfig = { + tools: structuredClone(bootstrap.tools!), + }; return AgentHeadless.create( FORK_AGENT.name, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index e5d5545caea..35be0ed5f00 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto'; import { type PartListUnion, type Content, + type FunctionDeclaration, type GenerateContentResponseUsageMetadata, createUserContent, createModelContent, @@ -163,6 +164,16 @@ export interface AgentBootstrapRecordPayload { * the original first task prompt/user turn. */ history: Content[]; + /** + * Immutable launch-time system instruction for the fork runtime. Resume must + * reuse this exact value rather than reading the current parent config. + */ + systemInstruction?: string | Content; + /** + * Immutable launch-time tool declarations / allowlist for the fork runtime. + * Resume must reuse this exact capability set or stay blocked. + */ + tools?: Array; } /** diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 76bbcf50b63..31b0398e78a 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -36,6 +36,7 @@ import { runWithAgentContext } from './agent-context.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as transcript from '../../agents/agent-transcript.js'; // Type for accessing protected methods in tests type AgentToolInvocation = { @@ -1700,6 +1701,54 @@ describe('AgentTool', () => { expect(meta.parentAgentId).toBe('explore-parent-42'); }); }); + + it('persists fork capability snapshots in the bootstrap transcript', async () => { + const forkParams: AgentParams = { + description: 'Fork task', + prompt: 'Investigate issue', + run_in_background: true, + }; + const generationConfig = { + systemInstruction: { + role: 'system', + parts: [{ text: 'parent system' }], + }, + tools: [{ functionDeclarations: [{ name: 'Bash' }, { name: 'Read' }] }], + }; + const geminiClient = { + getHistory: vi + .fn() + .mockReturnValue([{ role: 'model', parts: [{ text: 'Ready' }] }]), + getChat: vi.fn().mockReturnValue({ + getGenerationConfig: () => generationConfig, + }), + }; + vi.mocked(config.getGeminiClient).mockReturnValue( + geminiClient as unknown as ReturnType, + ); + + const attachSpy = vi.spyOn(transcript, 'attachJsonlTranscriptWriter'); + const createSpy = vi + .spyOn(AgentHeadless, 'create') + .mockResolvedValue(mockAgent); + + const invocation = ( + agentTool as AgentToolWithProtectedMethods + ).createInvocation(forkParams); + await invocation.execute(); + + expect(attachSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.objectContaining({ + bootstrapSystemInstruction: generationConfig.systemInstruction, + bootstrapTools: generationConfig.tools[0].functionDeclarations, + }), + ); + + attachSpy.mockRestore(); + createSpy.mockRestore(); + }); }); }); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 88b9bb08612..d42feb9a46d 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -66,6 +66,17 @@ import { } from '../../agents/agent-transcript.js'; import { getGitBranch } from '../../utils/gitUtils.js'; +function persistBackgroundCancellation( + metaPath: string, + abortedBySignal: boolean, +): void { + patchAgentMeta(metaPath, { + status: abortedBySignal ? 'running' : 'cancelled', + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); +} + export interface AgentParams { description: string; prompt: string; @@ -643,6 +654,8 @@ class AgentToolInvocation extends BaseToolInvocation { subagent: AgentHeadless; initialMessages?: Content[]; taskPrompt: string; + promptConfig: PromptConfig; + toolConfig: ToolConfig; }> { const geminiClient = this.config.getGeminiClient(); const rawHistory = geminiClient ? geminiClient.getHistory(true) : []; @@ -742,7 +755,7 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter, ); - return { subagent, initialMessages, taskPrompt }; + return { subagent, initialMessages, taskPrompt, promptConfig, toolConfig }; } // Runs the SubagentStop hook after execution. On a blocking decision, feeds the @@ -1062,6 +1075,8 @@ class AgentToolInvocation extends BaseToolInvocation { let bgSubagent: AgentHeadless; let bgInitialMessages: Content[] | undefined; let bgTaskPrompt: string; + let bgPromptConfig: PromptConfig | undefined; + let bgToolConfig: ToolConfig | undefined; if (isFork) { const fork = await this.createForkSubagent( bgConfig as Config, @@ -1070,6 +1085,8 @@ class AgentToolInvocation extends BaseToolInvocation { bgSubagent = fork.subagent; bgInitialMessages = fork.initialMessages; bgTaskPrompt = fork.taskPrompt; + bgPromptConfig = fork.promptConfig; + bgToolConfig = fork.toolConfig; } else { bgSubagent = await this.subagentManager.createAgentHeadless( subagentConfig, @@ -1110,6 +1127,11 @@ class AgentToolInvocation extends BaseToolInvocation { // know what the agent was asked to do. initialUserPrompt: this.params.prompt, bootstrapHistory: isFork ? bgInitialMessages : undefined, + bootstrapSystemInstruction: isFork + ? (bgPromptConfig?.renderedSystemPrompt ?? + bgPromptConfig?.systemPrompt) + : undefined, + bootstrapTools: isFork ? bgToolConfig?.tools : undefined, launchTaskPrompt: isFork ? bgTaskPrompt : undefined, }, ); @@ -1130,17 +1152,6 @@ class AgentToolInvocation extends BaseToolInvocation { agentColor: subagentConfig.color, resumeCount: 0, }); - bgAbortController.signal.addEventListener( - 'abort', - () => { - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - }); - }, - { once: true }, - ); - registry.register({ agentId: hookOpts.agentId, description: this.params.description, @@ -1248,11 +1259,10 @@ class AgentToolInvocation extends BaseToolInvocation { finalText, completionStats, ); - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - lastError: undefined, - }); + persistBackgroundCancellation( + metaPath, + bgAbortController.signal.aborted, + ); } else { registry.fail( hookOpts.agentId, @@ -1280,11 +1290,10 @@ class AgentToolInvocation extends BaseToolInvocation { errorMsg, getCompletionStats(), ); - patchAgentMeta(metaPath, { - status: 'cancelled', - lastUpdatedAt: new Date().toISOString(), - lastError: undefined, - }); + persistBackgroundCancellation( + metaPath, + bgAbortController.signal.aborted, + ); } else { registry.fail(hookOpts.agentId, errorMsg, getCompletionStats()); patchAgentMeta(metaPath, { diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 206f67d9944..fed0cd250cd 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -414,14 +414,14 @@ describe('ShellTool', () => { expect(registry.complete).not.toHaveBeenCalled(); }); - it('strips trailing & from the spawned command (managed path handles backgrounding)', async () => { + it('preserves a bare trailing & and lets the shell own its semantics', async () => { const invocation = shellTool.build({ command: 'node server.js &', is_background: true, }); await invocation.execute(mockAbortSignal); expect(mockShellExecutionService).toHaveBeenCalledWith( - 'node server.js', + 'node server.js &', '/test/dir', expect.any(Function), expect.any(AbortSignal), @@ -431,7 +431,7 @@ describe('ShellTool', () => { ); }); - it('does not strip a trailing && (logical AND would be syntactically broken)', async () => { + it('preserves a trailing && (logical AND would be syntactically broken otherwise)', async () => { const invocation = shellTool.build({ command: 'npm run dev &&', is_background: true, @@ -448,7 +448,7 @@ describe('ShellTool', () => { ); }); - it('does not strip an escaped trailing \\& (literal &)', async () => { + it('preserves an escaped trailing \\& (literal &)', async () => { const invocation = shellTool.build({ command: 'echo foo \\&', is_background: true, @@ -465,6 +465,57 @@ describe('ShellTool', () => { ); }); + it('preserves quoted trailing ampersands', async () => { + const invocation = shellTool.build({ + command: `printf '&'`, + is_background: true, + }); + await invocation.execute(mockAbortSignal); + expect(mockShellExecutionService).toHaveBeenCalledWith( + `printf '&'`, + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + { streamStdout: true }, + ); + }); + + it('preserves ampersands inside double-quoted script arguments', async () => { + const invocation = shellTool.build({ + command: `node -e "console.log('&')"`, + is_background: true, + }); + await invocation.execute(mockAbortSignal); + expect(mockShellExecutionService).toHaveBeenCalledWith( + `node -e "console.log('&')"`, + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + { streamStdout: true }, + ); + }); + + it('preserves ampersands inside command substitutions', async () => { + const invocation = shellTool.build({ + command: `echo $(printf '&')`, + is_background: true, + }); + await invocation.execute(mockAbortSignal); + expect(mockShellExecutionService).toHaveBeenCalledWith( + `echo $(printf '&')`, + expect.any(String), + expect.any(Function), + expect.any(AbortSignal), + false, + {}, + { streamStdout: true }, + ); + }); + it('does not forward the turn signal into the background shell', async () => { // Verifies: the AbortSignal handed to ShellExecutionService is the // entry's own controller, not the outer turn signal. Cancelling the diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 625dfe4c6df..ef849f9b1a8 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -48,20 +48,6 @@ import { const debugLogger = createDebugLogger('SHELL'); -/** - * Strip a single bare trailing `&` (bash background operator) from a - * command string. Returns the input unchanged if the trailing form is - * `&&` (logical AND), `\&` (escaped literal `&`), or there is no `&` - * at the end at all. Linear time, no regex backtracking risk. - */ -function stripTrailingBackgroundAmp(command: string): string { - const trimmed = command.trimEnd(); - if (!trimmed.endsWith('&')) return command; - if (trimmed.endsWith('&&')) return command; - if (trimmed.endsWith('\\&')) return command; - return trimmed.slice(0, -1).trimEnd(); -} - export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000; @@ -434,25 +420,7 @@ export class ShellToolInvocation extends BaseToolInvocation< shellExecutionConfig?: ShellExecutionConfig, ): Promise { const strippedCommand = stripShellWrapper(this.params.command); - // Strip a single bare trailing `&` (the bash background operator) before - // spawn: bash treats it as background-detach, exits the wrapper - // immediately, and the real child outlives the wrapper — the registry - // would settle as `completed` while the shell is still running, and - // chunked output would land on a closed stream. The managed path is - // itself the backgrounding mechanism, so the trailing `&` is redundant. - // - // Deliberately precise: do not touch `&&` (logical AND), `\&` (escaped - // literal `&`), or commands without a trailing `&`. Earlier `\s*&+\s*$` - // was both too greedy (it ate `&&` and `\&`) and a ReDoS hazard on - // long all-`&` inputs. Plain string checks here are linear and clearer - // than a lookbehind regex. - const noTrailingAmp = stripTrailingBackgroundAmp(strippedCommand); - if (noTrailingAmp !== strippedCommand) { - debugLogger.warn( - 'Stripped trailing & from background shell command — managed path handles backgrounding', - ); - } - const processedCommand = this.addCoAuthorToGitCommit(noTrailingAmp); + const processedCommand = this.addCoAuthorToGitCommit(strippedCommand); const cwd = this.params.directory || this.config.getTargetDir(); // Output goes under the project temp dir (which `ReadFileTool` From 034c5b2cb05d718f23e55528a7ffc70de94d556f Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 17:41:25 +0800 Subject: [PATCH 08/12] Fix background task cancellation persistence --- .../agents/background-agent-resume.test.ts | 74 ++++++ .../src/agents/background-agent-resume.ts | 10 +- .../core/src/agents/background-tasks.test.ts | 57 +++++ packages/core/src/agents/background-tasks.ts | 27 ++- packages/core/src/tools/agent/agent.ts | 10 +- packages/core/src/tools/shell.test.ts | 23 +- packages/core/src/tools/shell.ts | 213 ++++++++++++++++++ 7 files changed, 390 insertions(+), 24 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index ec41a44dc76..fb3ca459094 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -1037,6 +1037,80 @@ describe('BackgroundAgentResumeService', () => { expect(readMetaStatus(metaPath)).toBe('running'); }); + it('keeps explicit cancellation persisted after a resumed task stops', async () => { + const sessionId = 'session-resume-cancelled'; + const agentId = 'agent-resume-cancelled'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Resume then cancel', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'running', + subagentName: 'researcher', + resolvedApprovalMode: 'default', + }); + fs.writeFileSync( + outputFile, + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Resume then cancel' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Resume then cancel', + subagentType: 'researcher', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + prompt: 'Resume then cancel', + outputFile, + metaPath, + }); + + let releaseExecute: (() => void) | undefined; + const execute = vi.fn( + () => + new Promise((resolve) => { + releaseExecute = resolve; + }), + ); + const subagent = { + execute, + setExternalMessageProvider: vi.fn(), + getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }), + getExecutionSummary: () => ({ + totalTokens: 0, + totalDurationMs: 0, + }), + getTerminateMode: () => AgentTerminateMode.CANCELLED, + getFinalText: () => '', + }; + + const { service, subagentManager } = createService(); + subagentManager.createAgentHeadless.mockResolvedValue(subagent); + + const resumed = await service.resumeBackgroundAgent(agentId, 'continue'); + expect(resumed).toBeDefined(); + registry.cancel(agentId); + releaseExecute?.(); + await vi.waitFor(() => { + expect(registry.get(agentId)?.status).toBe('cancelled'); + }); + expect(readMetaStatus(metaPath)).toBe('cancelled'); + }); + it('injects pending trailing user text via initial_messages_override', async () => { const sessionId = 'session-pending-user'; const agentId = 'agent-pending-user'; diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 6e4e8b6f7cc..ac6f8fbebb5 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -152,10 +152,10 @@ function createApprovalModeOverride( function persistBackgroundCancellation( metaPath: string, - abortedBySignal: boolean, + persistedStatus: 'running' | 'cancelled', ): void { patchAgentMeta(metaPath, { - status: abortedBySignal ? 'running' : 'cancelled', + status: persistedStatus, lastUpdatedAt: new Date().toISOString(), lastError: undefined, }); @@ -760,7 +760,8 @@ export class BackgroundAgentResumeService { registry.finalizeCancelled(meta.agentId, finalText, stats); persistBackgroundCancellation( metaPath, - bgAbortController.signal.aborted, + registry.get(meta.agentId)?.persistedCancellationStatus ?? + 'cancelled', ); } else { const failureText = @@ -786,7 +787,8 @@ export class BackgroundAgentResumeService { ); persistBackgroundCancellation( metaPath, - bgAbortController.signal.aborted, + registry.get(meta.agentId)?.persistedCancellationStatus ?? + 'cancelled', ); } else { registry.fail( diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 700606f9258..fe9e47f5514 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BackgroundTaskRegistry } from './background-tasks.js'; +import * as transcript from './agent-transcript.js'; describe('BackgroundTaskRegistry', () => { let registry: BackgroundTaskRegistry; @@ -102,6 +103,34 @@ describe('BackgroundTaskRegistry', () => { expect(callback).not.toHaveBeenCalled(); }); + it('persists explicit cancellations as cancelled sidecar state', () => { + const patchSpy = vi + .spyOn(transcript, 'patchAgentMeta') + .mockImplementation(() => undefined); + try { + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + metaPath: '/tmp/test-1.meta.json', + }); + + registry.cancel('test-1'); + + expect(patchSpy).toHaveBeenCalledWith( + '/tmp/test-1.meta.json', + expect.objectContaining({ + status: 'cancelled', + lastError: undefined, + }), + ); + } finally { + patchSpy.mockRestore(); + } + }); + it('emits a fallback cancelled notification after the grace period when the natural handler never runs', () => { vi.useFakeTimers(); try { @@ -316,6 +345,34 @@ describe('BackgroundTaskRegistry', () => { expect(callback).toHaveBeenCalledTimes(2); }); + it('persists shutdown interruption as running sidecar state', () => { + const patchSpy = vi + .spyOn(transcript, 'patchAgentMeta') + .mockImplementation(() => undefined); + try { + registry.register({ + agentId: 'a', + description: 'agent a', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + metaPath: '/tmp/a.meta.json', + }); + + registry.abortAll(); + + expect(patchSpy).toHaveBeenCalledWith( + '/tmp/a.meta.json', + expect.objectContaining({ + status: 'running', + lastError: undefined, + }), + ); + } finally { + patchSpy.mockRestore(); + } + }); + it('hasUnfinalizedTasks reports cancelled-but-not-notified entries', () => { // Headless runs rely on this to keep the event loop alive after a // task_stop until the agent's natural handler has emitted the diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index e94865a9014..e8136fd110b 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -13,6 +13,7 @@ */ import { createDebugLogger } from '../utils/debugLogger.js'; +import { patchAgentMeta } from './agent-transcript.js'; const debugLogger = createDebugLogger('BACKGROUND_TASKS'); @@ -144,6 +145,19 @@ export interface BackgroundTaskEntry { * fires the notification with the real partial/final result). */ notified?: boolean; + /** + * Persisted sidecar status to write when the current cancellation settles. + * Explicit user cancellation uses `cancelled`; shutdown interruption keeps + * `running` so `/resume` can recover the work later. + */ + persistedCancellationStatus?: Extract< + BackgroundTaskStatus, + 'running' | 'cancelled' + >; +} + +interface CancelOptions { + persistedStatus?: Extract; } export interface NotificationMeta { @@ -251,13 +265,22 @@ export class BackgroundTaskRegistry { // case where a tool ignores AbortSignal and bgBody never settles — the // timeout lands on finalizeCancellationIfPending(), which is a no-op // once the natural handler has already emitted. - cancel(agentId: string): void { + cancel(agentId: string, options: CancelOptions = {}): void { const entry = this.agents.get(agentId); if (!entry || entry.status !== 'running') return; + const persistedStatus = options.persistedStatus ?? 'cancelled'; entry.abortController.abort(); entry.status = 'cancelled'; entry.endTime = Date.now(); + entry.persistedCancellationStatus = persistedStatus; + if (entry.metaPath) { + patchAgentMeta(entry.metaPath, { + status: persistedStatus, + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); + } debugLogger.info(`Background agent cancelled: ${agentId}`); this.emitStatusChange(entry); @@ -420,7 +443,7 @@ export class BackgroundTaskRegistry { abortAll(): void { for (const entry of Array.from(this.agents.values())) { - this.cancel(entry.agentId); + this.cancel(entry.agentId, { persistedStatus: 'running' }); // Shutdown path: no natural handler will run, so emit the cancelled // notification here to honour the one-notification-per-agent contract. this.finalizeCancellationIfPending(entry.agentId); diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index d42feb9a46d..9d8a31345d4 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -68,10 +68,10 @@ import { getGitBranch } from '../../utils/gitUtils.js'; function persistBackgroundCancellation( metaPath: string, - abortedBySignal: boolean, + persistedStatus: 'running' | 'cancelled', ): void { patchAgentMeta(metaPath, { - status: abortedBySignal ? 'running' : 'cancelled', + status: persistedStatus, lastUpdatedAt: new Date().toISOString(), lastError: undefined, }); @@ -1261,7 +1261,8 @@ class AgentToolInvocation extends BaseToolInvocation { ); persistBackgroundCancellation( metaPath, - bgAbortController.signal.aborted, + registry.get(hookOpts.agentId)?.persistedCancellationStatus ?? + 'cancelled', ); } else { registry.fail( @@ -1292,7 +1293,8 @@ class AgentToolInvocation extends BaseToolInvocation { ); persistBackgroundCancellation( metaPath, - bgAbortController.signal.aborted, + registry.get(hookOpts.agentId)?.persistedCancellationStatus ?? + 'cancelled', ); } else { registry.fail(hookOpts.agentId, errorMsg, getCompletionStats()); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index fed0cd250cd..e3758bad138 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -414,21 +414,16 @@ describe('ShellTool', () => { expect(registry.complete).not.toHaveBeenCalled(); }); - it('preserves a bare trailing & and lets the shell own its semantics', async () => { - const invocation = shellTool.build({ - command: 'node server.js &', - is_background: true, - }); - await invocation.execute(mockAbortSignal); - expect(mockShellExecutionService).toHaveBeenCalledWith( - 'node server.js &', - '/test/dir', - expect.any(Function), - expect.any(AbortSignal), - false, - {}, - { streamStdout: true }, + it('rejects a bare trailing & in managed background mode', async () => { + expect(() => + shellTool.build({ + command: 'node server.js &', + is_background: true, + }), + ).toThrow( + 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.', ); + expect(mockShellExecutionService).not.toHaveBeenCalled(); }); it('preserves a trailing && (logical AND would be syntactically broken otherwise)', async () => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index ef849f9b1a8..18bd00a00d4 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -51,6 +51,213 @@ const debugLogger = createDebugLogger('SHELL'); export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const DEFAULT_FOREGROUND_TIMEOUT_MS = 120000; +function trimTrailingShellComment(command: string): string { + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let escapeNext = false; + let commandSubstitutionDepth = 0; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]!; + + if (inSingleQuote) { + if (ch === "'") inSingleQuote = false; + continue; + } + + if (inBacktick) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '`') inBacktick = false; + continue; + } + + if (inDoubleQuote) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '"') { + inDoubleQuote = false; + continue; + } + if (ch === '$' && command[i + 1] === '(') { + commandSubstitutionDepth++; + i++; + continue; + } + if (ch === ')' && commandSubstitutionDepth > 0) { + commandSubstitutionDepth--; + } + continue; + } + + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === "'") { + inSingleQuote = true; + continue; + } + if (ch === '"') { + inDoubleQuote = true; + continue; + } + if (ch === '`') { + inBacktick = true; + continue; + } + if (ch === '$' && command[i + 1] === '(') { + commandSubstitutionDepth++; + i++; + continue; + } + if (ch === ')' && commandSubstitutionDepth > 0) { + commandSubstitutionDepth--; + continue; + } + if ( + ch === '#' && + commandSubstitutionDepth === 0 && + (i === 0 || /\s/.test(command[i - 1]!)) + ) { + return command.slice(0, i); + } + } + + return command; +} + +function hasTopLevelTrailingBackgroundOperator(command: string): boolean { + const commentTrimmed = trimTrailingShellComment(command); + const trimmed = commentTrimmed.trimEnd(); + if (!trimmed.endsWith('&')) return false; + + const trailingAmpIndex = trimmed.length - 1; + const previousNonWhitespaceIndex = (() => { + for (let i = trailingAmpIndex - 1; i >= 0; i--) { + if (!/\s/.test(trimmed[i]!)) return i; + } + return -1; + })(); + + if (previousNonWhitespaceIndex >= 0) { + const previous = trimmed[previousNonWhitespaceIndex]!; + if (previous === '&' || previous === '|' || previous === '\\') { + return false; + } + } + + let backslashCount = 0; + for (let i = trailingAmpIndex - 1; i >= 0 && trimmed[i] === '\\'; i--) { + backslashCount++; + } + if (backslashCount % 2 === 1) return false; + + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let escapeNext = false; + let commandSubstitutionDepth = 0; + + for (let i = 0; i <= trailingAmpIndex; i++) { + const ch = trimmed[i]!; + + if (inSingleQuote) { + if (ch === "'") inSingleQuote = false; + continue; + } + + if (inBacktick) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '`') inBacktick = false; + continue; + } + + if (inDoubleQuote) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '"') { + inDoubleQuote = false; + continue; + } + if (ch === '$' && trimmed[i + 1] === '(') { + commandSubstitutionDepth++; + i++; + continue; + } + if (ch === ')' && commandSubstitutionDepth > 0) { + commandSubstitutionDepth--; + } + continue; + } + + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === "'") { + inSingleQuote = true; + continue; + } + if (ch === '"') { + inDoubleQuote = true; + continue; + } + if (ch === '`') { + inBacktick = true; + continue; + } + if (ch === '$' && trimmed[i + 1] === '(') { + commandSubstitutionDepth++; + i++; + continue; + } + if (ch === ')' && commandSubstitutionDepth > 0) { + commandSubstitutionDepth--; + continue; + } + if (i === trailingAmpIndex) { + return commandSubstitutionDepth === 0; + } + } + + return false; +} + export interface ShellToolParams { command: string; is_background: boolean; @@ -728,6 +935,12 @@ export class ShellTool extends BaseDeclarativeTool< if (!params.command.trim()) { return 'Command cannot be empty.'; } + if ( + params.is_background && + hasTopLevelTrailingBackgroundOperator(params.command) + ) { + return 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.'; + } if (getCommandRoots(params.command).length === 0) { return 'Could not identify command root to obtain permission from user.'; } From b0557c13432341a46e9da815f19df5d3b502c3a5 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 17:45:09 +0800 Subject: [PATCH 09/12] Persist empty fork bootstrap transcripts --- .../core/src/agents/agent-transcript.test.ts | 38 +++++++++++++++++-- packages/core/src/agents/agent-transcript.ts | 9 ++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index 682e99f4ade..89f3262531e 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -20,6 +20,7 @@ import { } from './agent-transcript.js'; import { AgentEventEmitter, AgentEventType } from './runtime/agent-events.js'; import type { ChatRecord } from '../services/chatRecordingService.js'; +import type { Content, FunctionDeclaration } from '@google/genai'; describe('agent-transcript', () => { describe('path helpers', () => { @@ -169,10 +170,9 @@ describe('agent-transcript', () => { jsonlPath: string, extra: { initialUserPrompt?: string; - bootstrapHistory?: Array<{ - role: 'user' | 'model'; - parts: Array<{ text: string }>; - }>; + bootstrapHistory?: Content[]; + bootstrapSystemInstruction?: string | Content; + bootstrapTools?: Array; launchTaskPrompt?: string; } = {}, ) { @@ -256,6 +256,36 @@ describe('agent-transcript', () => { }); }); + it('writes bootstrap records even when inherited history is empty', () => { + const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); + const { cleanup } = makeWriter(jsonlPath, { + bootstrapHistory: [], + bootstrapSystemInstruction: { + role: 'system', + parts: [{ text: 'fork system' }], + }, + bootstrapTools: [{ name: 'Bash' }], + launchTaskPrompt: 'Begin.', + }); + + cleanup(); + + const records = readJsonl(jsonlPath); + expect(records.map((record) => [record.type, record.subtype])).toEqual([ + ['system', 'agent_bootstrap'], + ['system', 'agent_launch_prompt'], + ]); + expect(records[0]?.systemPayload).toMatchObject({ + kind: 'fork', + history: [], + systemInstruction: { + role: 'system', + parts: [{ text: 'fork system' }], + }, + tools: [{ name: 'Bash' }], + }); + }); + it('writes a ROUND_TEXT event as an assistant record with text part', () => { const jsonlPath = path.join(tempDir, 's', 'agent-x.jsonl'); const { emitter, cleanup } = makeWriter(jsonlPath); diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index eabfb36641f..59154d36da0 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -383,10 +383,15 @@ export function attachJsonlTranscriptWriter( recordUserMessage(event.text); }; - if (options.bootstrapHistory && options.bootstrapHistory.length > 0) { + const hasBootstrapPayload = + options.bootstrapHistory !== undefined || + options.bootstrapSystemInstruction !== undefined || + options.bootstrapTools !== undefined; + + if (hasBootstrapPayload) { const payload: AgentBootstrapRecordPayload = { kind: 'fork', - history: structuredClone(options.bootstrapHistory), + history: structuredClone(options.bootstrapHistory ?? []), ...(options.bootstrapSystemInstruction !== undefined ? { systemInstruction: structuredClone( From 700ac24e9bfef246376372836f86ef4fee42e265 Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 18:52:14 +0800 Subject: [PATCH 10/12] Align shell prompts with managed background mode --- .../core/__snapshots__/prompts.test.ts.snap | 60 +++++++++---------- packages/core/src/core/prompts.test.ts | 2 +- packages/core/src/core/prompts.ts | 8 +-- packages/core/src/tools/shell.test.ts | 24 ++++++++ packages/core/src/tools/shell.ts | 3 +- 5 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index ebe6a0d77e1..b617dc18282 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -121,7 +121,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -164,7 +164,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -355,7 +355,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -413,7 +413,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -599,7 +599,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -642,7 +642,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -828,7 +828,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -871,7 +871,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -1057,7 +1057,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -1100,7 +1100,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -1286,7 +1286,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -1329,7 +1329,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -1515,7 +1515,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -1558,7 +1558,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -1744,7 +1744,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -1787,7 +1787,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -1973,7 +1973,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -2016,7 +2016,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -2202,7 +2202,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -2247,7 +2247,7 @@ model: true user: start the server implemented in server.js model: -{"name": "run_shell_command", "arguments": {"command": "node server.js &", "is_background": true}} +{"name": "run_shell_command", "arguments": {"command": "node server.js", "is_background": true}} @@ -2454,7 +2454,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -2501,7 +2501,7 @@ model: -node server.js & +node server.js true @@ -2769,7 +2769,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -2814,7 +2814,7 @@ model: true user: start the server implemented in server.js model: -{"name": "run_shell_command", "arguments": {"command": "node server.js &", "is_background": true}} +{"name": "run_shell_command", "arguments": {"command": "node server.js", "is_background": true}} @@ -3021,7 +3021,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -3068,7 +3068,7 @@ model: -node server.js & +node server.js true @@ -3332,7 +3332,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -3375,7 +3375,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] @@ -3561,7 +3561,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the - **File Paths:** Always use absolute paths when referring to files with tools like 'read_file' or 'write_file'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the 'todo_write' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the 'agent' tool in order to reduce context usage. You should proactively use the 'agent' tool with specialized agents when the task at hand matches the agent's description. @@ -3604,7 +3604,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: run_shell_command for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: run_shell_command for 'node server.js' with is_background: true because it must run in the background] diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index d3e0877b6c5..a8eaa5f7947 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -333,7 +333,7 @@ describe('Model-specific tool call formats', () => { expect(prompt).toContain(''); expect(prompt).toContain('{"name": "run_shell_command"'); expect(prompt).toContain( - '"arguments": {"command": "node server.js &", "is_background": true}', + '"arguments": {"command": "node server.js", "is_background": true}', ); expect(prompt).toContain(''); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 7c221b58a30..bb911359edb 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -263,7 +263,7 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t - **File Paths:** Always use absolute paths when referring to files with tools like '${ToolNames.READ_FILE}' or '${ToolNames.WRITE_FILE}'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). - **Command Execution:** Use the '${ToolNames.SHELL}' tool for running shell commands, remembering the safety rule to explain modifying commands first. -- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Background Processes:** Use background execution with \`is_background: true\` for commands that are unlikely to stop on their own, e.g. \`node server.js\`. Do not append a trailing \`&\` when using the shell tool's managed background mode. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Task Management:** Use the '${ToolNames.TODO_WRITE}' tool proactively for complex, multi-step tasks to track progress and provide visibility to users. This tool helps organize work systematically and ensures no requirements are missed. - **Subagent Delegation:** When doing file search, prefer to use the '${ToolNames.AGENT}' tool in order to reduce context usage. You should proactively use the '${ToolNames.AGENT}' tool with specialized agents when the task at hand matches the agent's description. @@ -481,7 +481,7 @@ model: true user: start the server implemented in server.js -model: [tool_call: ${ToolNames.SHELL} for 'node server.js &' with is_background: true because it must run in the background] +model: [tool_call: ${ToolNames.SHELL} for 'node server.js' with is_background: true because it must run in the background] @@ -561,7 +561,7 @@ model: -node server.js & +node server.js true @@ -716,7 +716,7 @@ model: true user: start the server implemented in server.js model: -{"name": "${ToolNames.SHELL}", "arguments": {"command": "node server.js &", "is_background": true}} +{"name": "${ToolNames.SHELL}", "arguments": {"command": "node server.js", "is_background": true}} diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index e3758bad138..615de65fa0a 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -426,6 +426,30 @@ describe('ShellTool', () => { expect(mockShellExecutionService).not.toHaveBeenCalled(); }); + it('rejects wrapped bash commands whose stripped payload ends with bare &', async () => { + expect(() => + shellTool.build({ + command: 'bash -c "node server.js &"', + is_background: true, + }), + ).toThrow( + 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.', + ); + expect(mockShellExecutionService).not.toHaveBeenCalled(); + }); + + it('rejects wrapped sh commands whose stripped payload ends with bare &', async () => { + expect(() => + shellTool.build({ + command: "sh -c 'npm run dev &'", + is_background: true, + }), + ).toThrow( + 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.', + ); + expect(mockShellExecutionService).not.toHaveBeenCalled(); + }); + it('preserves a trailing && (logical AND would be syntactically broken otherwise)', async () => { const invocation = shellTool.build({ command: 'npm run dev &&', diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 18bd00a00d4..d49cca71244 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -935,9 +935,10 @@ export class ShellTool extends BaseDeclarativeTool< if (!params.command.trim()) { return 'Command cannot be empty.'; } + const strippedCommand = stripShellWrapper(params.command); if ( params.is_background && - hasTopLevelTrailingBackgroundOperator(params.command) + hasTopLevelTrailingBackgroundOperator(strippedCommand) ) { return 'Background shell commands must not end with a bare "&". Remove the trailing "&" and rely on is_background: true instead.'; } From c51c0dd15a6c9556edfd88e99d0855a82e66e2cc Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 19:30:26 +0800 Subject: [PATCH 11/12] Guard session switches with background work --- .../cli/src/ui/commands/clearCommand.test.ts | 62 +++++++++++++++ packages/cli/src/ui/commands/clearCommand.ts | 31 ++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 9 +++ .../cli/src/ui/hooks/useResumeCommand.test.ts | 75 ++++++++++++++++++- packages/cli/src/ui/hooks/useResumeCommand.ts | 33 ++++++++ .../core/src/agents/background-tasks.test.ts | 27 ++++++- packages/core/src/agents/background-tasks.ts | 21 +++++- .../services/backgroundShellRegistry.test.ts | 36 +++++++-- .../src/services/backgroundShellRegistry.ts | 26 ++++++- 9 files changed, 309 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 6b84ededc2f..1898368d2f7 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -52,6 +52,14 @@ describe('clearCommand', () => { ({ resetChat: mockResetChat, }) as unknown as GeminiClient, + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), startNewSession: mockStartNewSession, getHookSystem: mockGetHookSystem, getDebugLogger: () => ({ @@ -237,6 +245,14 @@ describe('clearCommand', () => { services: { config: { getHookSystem: mockGetHookSystem, + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), startNewSession: mockStartNewSession, getGeminiClient: vi.fn().mockReturnValue({ resetChat: mockResetChat, @@ -287,5 +303,51 @@ describe('clearCommand', () => { ); expect(mockFireSessionStartEvent).toHaveBeenCalled(); }); + + it('blocks session clearing while background work is still running', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + const blockedContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(true), + reset: vi.fn(), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getHookSystem: mockGetHookSystem, + startNewSession: mockStartNewSession, + getGeminiClient: vi.fn().mockReturnValue({ + resetChat: mockResetChat, + } as unknown as GeminiClient), + getModel: vi.fn().mockReturnValue('test-model'), + getApprovalMode: vi.fn().mockReturnValue('default'), + getToolRegistry: vi.fn().mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + getDebugLogger: vi.fn().mockReturnValue({ warn: vi.fn() }), + }, + }, + session: { + startNewSession: vi.fn(), + }, + }); + + const result = await clearCommand.action(blockedContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + "Stop the current session's running background tasks before starting a new session.", + }); + expect(mockStartNewSession).not.toHaveBeenCalled(); + expect(mockResetChat).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index dbf9a0d4135..655db3b5e05 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -12,9 +12,25 @@ import { SessionEndReason, SessionStartSource, ToolNames, + type Config, type PermissionMode, } from '@qwen-code/qwen-code-core'; +function hasBlockingBackgroundWork(config: Config): boolean { + return ( + config.getBackgroundTaskRegistry().hasUnfinalizedTasks() || + config + .getBackgroundShellRegistry() + .getAll() + .some((entry) => entry.status === 'running') + ); +} + +function resetBackgroundStateForSessionSwitch(config: Config): void { + (config.getBackgroundTaskRegistry() as unknown as { reset(): void }).reset(); + (config.getBackgroundShellRegistry() as unknown as { reset(): void }).reset(); +} + export const clearCommand: SlashCommand = { name: 'clear', altNames: ['reset', 'new'], @@ -27,6 +43,20 @@ export const clearCommand: SlashCommand = { const { config } = context.services; if (config) { + if (hasBlockingBackgroundWork(config)) { + const content = + "Stop the current session's running background tasks before starting a new session."; + context.ui.setDebugMessage(content); + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'error' as const, + content, + }; + } + return; + } + // Fire SessionEnd event (non-blocking to avoid UI lag) config .getHookSystem() @@ -35,6 +65,7 @@ export const clearCommand: SlashCommand = { config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`); }); + resetBackgroundStateForSessionSwitch(config); const newSessionId = config.startNewSession(); // Reset UI telemetry metrics for the new session diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8195b91e079..6d9ce9128ca 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -2216,6 +2216,15 @@ export const useGeminiStream = ( }> >([]); const [notificationTrigger, setNotificationTrigger] = useState(0); + const notificationQueueSessionIdRef = useRef(sessionStates.sessionId); + + useEffect(() => { + if (notificationQueueSessionIdRef.current === sessionStates.sessionId) { + return; + } + notificationQueueSessionIdRef.current = sessionStates.sessionId; + notificationQueueRef.current = []; + }, [sessionStates.sessionId]); // Start the cron scheduler on mount, stop on unmount. // Cron fires enqueue onto the shared notification queue. diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 0bb2efac28e..d4e49024916 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -6,7 +6,10 @@ import { act, renderHook } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; -import { useResumeCommand } from './useResumeCommand.js'; +import { + BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE, + useResumeCommand, +} from './useResumeCommand.js'; const resumeMocks = vi.hoisted(() => { let resolveLoadSession: @@ -153,6 +156,14 @@ describe('useResumeCommand', () => { getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), getBackgroundAgentResumeService: () => ({ buildRecoveredBackgroundAgentsNotice: vi.fn(), @@ -225,6 +236,14 @@ describe('useResumeCommand', () => { getTargetDir: () => '/tmp', getGeminiClient: () => geminiClient, startNewSession: vi.fn(), + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), loadPausedBackgroundAgents: vi .fn() .mockResolvedValue([{ agentId: 'a' }, { agentId: 'b' }]), @@ -261,4 +280,58 @@ describe('useResumeCommand', () => { expect.any(Number), ); }); + + it('blocks resume when the current session still has running background work', async () => { + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + const startNewSession = vi.fn(); + + const config = { + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(true), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getTargetDir: () => '/tmp', + getDebugLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + historyManager, + startNewSession, + }), + ); + + act(() => { + result.current.openResumeDialog(); + }); + + await act(async () => { + await result.current.handleResume('session-blocked'); + }); + + expect(result.current.isResumeDialogOpen).toBe(false); + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.clearItems).not.toHaveBeenCalled(); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + expect(historyManager.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE, + }), + expect.any(Number), + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index ad95e0cb671..9541f305429 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -42,6 +42,24 @@ export interface UseResumeCommandResult { handleResume: (sessionId: string) => Promise; } +const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE = + "Stop the current session's running background tasks before resuming another session."; + +function hasBlockingBackgroundWork(config: Config): boolean { + return ( + config.getBackgroundTaskRegistry().hasUnfinalizedTasks() || + config + .getBackgroundShellRegistry() + .getAll() + .some((entry) => entry.status === 'running') + ); +} + +function resetBackgroundStateForSessionSwitch(config: Config): void { + (config.getBackgroundTaskRegistry() as unknown as { reset(): void }).reset(); + (config.getBackgroundShellRegistry() as unknown as { reset(): void }).reset(); +} + export function useResumeCommand( options?: UseResumeCommandOptions, ): UseResumeCommandResult { @@ -74,6 +92,18 @@ export function useResumeCommand( return; } + if (hasBlockingBackgroundWork(config)) { + closeResumeDialog(); + addItem?.( + { + type: MessageType.ERROR, + text: BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE, + } as Omit, + Date.now(), + ); + return; + } + // Close dialog immediately to prevent input capture during async operations. closeResumeDialog(); @@ -98,6 +128,7 @@ export function useResumeCommand( loadHistory?.(uiHistoryItems); // Update session history core. + resetBackgroundStateForSessionSwitch(config); config.startNewSession(sessionId, sessionData); // Rebuild turn boundary tracking so rewind works within resumed sessions. config @@ -155,3 +186,5 @@ export function useResumeCommand( handleResume, }; } + +export { BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE }; diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index fe9e47f5514..7da05305d98 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -590,7 +590,9 @@ describe('BackgroundTaskRegistry', () => { it('statusChange callback fires on register and every state transition', () => { const seen: Array<{ id: string; status: string }> = []; registry.setStatusChangeCallback((entry) => { - seen.push({ id: entry.agentId, status: entry.status }); + if (entry) { + seen.push({ id: entry.agentId, status: entry.status }); + } }); registry.register({ @@ -821,6 +823,29 @@ describe('BackgroundTaskRegistry', () => { }); }); + describe('session switch helpers', () => { + it('reset clears tracked entries without touching persisted sidecars', () => { + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + registry.register({ + agentId: 'test-2', + description: 'paused agent', + status: 'paused', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.reset(); + + expect(registry.getAll()).toEqual([]); + }); + }); + describe('notification XML', () => { it('includes output-file tag when outputFile is set', () => { const callback = vi.fn(); diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index e8136fd110b..77015700269 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -182,7 +182,7 @@ export type BackgroundRegisterCallback = (entry: BackgroundTaskEntry) => void; * on every tool call a background agent makes. */ export type BackgroundStatusChangeCallback = ( - entry: BackgroundTaskEntry, + entry?: BackgroundTaskEntry, ) => void; /** Fires on `appendActivity` — scoped to detail-view consumers. */ @@ -390,6 +390,23 @@ export class BackgroundTaskRegistry { return false; } + /** + * Drops every in-memory entry without touching sidecar state. + * + * Used only when switching to a different session after the caller has + * already established that no live work from the current session is still + * running. Paused/interrupted entries remain recoverable from disk because + * their sidecars keep the persisted status. + */ + reset(): void { + const firstEntry = this.agents.values().next().value as + | BackgroundTaskEntry + | undefined; + if (!firstEntry) return; + this.agents.clear(); + this.emitStatusChange(firstEntry); + } + /** * Enqueue a message for delivery to a running background agent. * The agent drains this queue between tool rounds. @@ -521,7 +538,7 @@ export class BackgroundTaskRegistry { } } - private emitStatusChange(entry: BackgroundTaskEntry): void { + private emitStatusChange(entry?: BackgroundTaskEntry): void { if (!this.statusChangeCallback) return; try { this.statusChangeCallback(entry); diff --git a/packages/core/src/services/backgroundShellRegistry.test.ts b/packages/core/src/services/backgroundShellRegistry.test.ts index 018ceaa9d6b..086a65e1b3a 100644 --- a/packages/core/src/services/backgroundShellRegistry.test.ts +++ b/packages/core/src/services/backgroundShellRegistry.test.ts @@ -116,7 +116,9 @@ describe('BackgroundShellRegistry', () => { it('fires statusChange callback on register too (mirrors BackgroundTaskRegistry)', () => { const reg = new BackgroundShellRegistry(); const seen: string[] = []; - reg.setStatusChangeCallback((e) => seen.push(e.shellId)); + reg.setStatusChangeCallback((entry) => { + if (entry) seen.push(entry.shellId); + }); reg.register(makeEntry({ shellId: 'a' })); reg.register(makeEntry({ shellId: 'b' })); expect(seen).toEqual(['a', 'b']); @@ -128,9 +130,11 @@ describe('BackgroundShellRegistry', () => { reg.register(makeEntry({ shellId: 'b' })); reg.register(makeEntry({ shellId: 'c' })); const transitions: Array<{ id: string; status: string }> = []; - reg.setStatusChangeCallback((entry) => - transitions.push({ id: entry.shellId, status: entry.status }), - ); + reg.setStatusChangeCallback((entry) => { + if (entry) { + transitions.push({ id: entry.shellId, status: entry.status }); + } + }); reg.complete('a', 0, 1000); reg.fail('b', 'boom', 1100); @@ -146,7 +150,9 @@ describe('BackgroundShellRegistry', () => { it('does not fire statusChange when a transition is a no-op', () => { const reg = new BackgroundShellRegistry(); const transitions: string[] = []; - reg.setStatusChangeCallback((e) => transitions.push(e.shellId)); + reg.setStatusChangeCallback((entry) => { + if (entry) transitions.push(entry.shellId); + }); reg.register(makeEntry({ shellId: 'a' })); reg.complete('a', 0, 1000); transitions.length = 0; @@ -239,6 +245,26 @@ describe('BackgroundShellRegistry', () => { }); }); + describe('session switch helpers', () => { + it('reports whether any shell is still running', () => { + const reg = new BackgroundShellRegistry(); + reg.register(makeEntry({ shellId: 'a' })); + expect(reg.hasRunningEntries()).toBe(true); + reg.complete('a', 0, 1234); + expect(reg.hasRunningEntries()).toBe(false); + }); + + it('reset clears all tracked entries', () => { + const reg = new BackgroundShellRegistry(); + reg.register(makeEntry({ shellId: 'a' })); + reg.register(makeEntry({ shellId: 'b' })); + + reg.reset(); + + expect(reg.getAll()).toEqual([]); + }); + }); + describe('cancel', () => { it('transitions running → cancelled and aborts the signal', () => { const reg = new BackgroundShellRegistry(); diff --git a/packages/core/src/services/backgroundShellRegistry.ts b/packages/core/src/services/backgroundShellRegistry.ts index dab71f17ff7..4776102555d 100644 --- a/packages/core/src/services/backgroundShellRegistry.ts +++ b/packages/core/src/services/backgroundShellRegistry.ts @@ -62,7 +62,7 @@ export type BackgroundShellRegisterCallback = ( * subscribe to both registries. */ export type BackgroundShellStatusChangeCallback = ( - entry: BackgroundShellEntry, + entry?: BackgroundShellEntry, ) => void; export class BackgroundShellRegistry { @@ -117,6 +117,13 @@ export class BackgroundShellRegistry { return [...this.entries.values()]; } + hasRunningEntries(): boolean { + for (const entry of this.entries.values()) { + if (entry.status === 'running') return true; + } + return false; + } + complete(shellId: string, exitCode: number, endTime: number): void { const entry = this.entries.get(shellId); if (!entry || entry.status !== 'running') return; @@ -156,7 +163,7 @@ export class BackgroundShellRegistry { } } - private fireStatusChange(entry: BackgroundShellEntry): void { + private fireStatusChange(entry?: BackgroundShellEntry): void { if (!this.statusChangeCallback) return; try { this.statusChangeCallback(entry); @@ -188,6 +195,21 @@ export class BackgroundShellRegistry { entry.abortController.abort(); } + /** + * Drops every in-memory entry without touching spawned processes. + * + * Callers must only use this after verifying that no running managed shell + * from the current session still exists. + */ + reset(): void { + const firstEntry = this.entries.values().next().value as + | BackgroundShellEntry + | undefined; + if (!firstEntry) return; + this.entries.clear(); + this.fireStatusChange(firstEntry); + } + /** * Cancel every still-running entry. Called on session/Config shutdown so * background shells don't outlive the CLI process and leak orphaned From fbac57d9fd96e0f0231d5108e8a8c81b7dcf456e Mon Sep 17 00:00:00 2001 From: "jinye.djy" Date: Thu, 30 Apr 2026 23:09:51 +0800 Subject: [PATCH 12/12] Preserve trailing user turns during resume --- .../agents/background-agent-resume.test.ts | 18 +++++---- .../src/agents/background-agent-resume.ts | 39 +------------------ packages/core/src/tools/agent/agent.test.ts | 12 +++++- 3 files changed, 23 insertions(+), 46 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index fb3ca459094..801181a9f2c 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -34,7 +34,12 @@ describe('BackgroundAgentResumeService', () => { }); afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); + fs.rmSync(tempDir, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 50, + }); }); function createService() { @@ -1111,7 +1116,7 @@ describe('BackgroundAgentResumeService', () => { expect(readMetaStatus(metaPath)).toBe('cancelled'); }); - it('injects pending trailing user text via initial_messages_override', async () => { + it('preserves pending trailing user text in history and sends continuation as the new turn', async () => { const sessionId = 'session-pending-user'; const agentId = 'agent-pending-user'; const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); @@ -1176,12 +1181,8 @@ describe('BackgroundAgentResumeService', () => { const override = context.get('initial_messages_override') as | Array<{ parts?: Array<{ text?: string }> }> | undefined; - expect(override).toEqual([ - { - role: 'user', - parts: [{ text: 'and another thing' }, { text: '\ncontinue work' }], - }, - ]); + expect(override).toBeUndefined(); + expect(context.get('task_prompt')).toBe('continue work'); }, ); const subagent = { @@ -1209,6 +1210,7 @@ describe('BackgroundAgentResumeService', () => { initialMessages: [ { role: 'user', parts: [{ text: 'original task' }] }, { role: 'model', parts: [{ text: 'working' }] }, + { role: 'user', parts: [{ text: 'and another thing' }] }, ], }, }), diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index ac6f8fbebb5..452d636e185 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -66,7 +66,6 @@ interface TranscriptRecovery { history: Content[]; initialPrompt?: string; lastStableUuid: string | null; - pendingUserMessage?: Content; forkBootstrap?: { history: Content[]; taskPrompt: string; @@ -291,28 +290,9 @@ function recoverTranscript(records: ChatRecord[]): TranscriptRecovery { ? nonSystemStableRecords[0].uuid : null; - const sanitized = [...nonSystemStableRecords]; - const pendingUserRecords: ChatRecord[] = []; - while (sanitized.length > 0) { - const last = sanitized[sanitized.length - 1]!; - if (last.type !== 'user') break; - pendingUserRecords.unshift(last); - sanitized.pop(); - } - - const pendingUserMessage = - pendingUserRecords.length > 0 - ? ({ - role: 'user', - parts: pendingUserRecords.flatMap((record) => - structuredClone(record.message?.parts ?? []), - ), - } as Content) - : undefined; - return { history: coalesceAdjacentUserHistory( - sanitized + nonSystemStableRecords .map((record) => record.message) .filter((message): message is Content => message !== undefined), ), @@ -321,7 +301,6 @@ function recoverTranscript(records: ChatRecord[]): TranscriptRecovery { stableForBranch.length > 0 ? stableForBranch[stableForBranch.length - 1]!.uuid : null, - pendingUserMessage, forkBootstrap: bootstrapRecord?.systemPayload && (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload).kind === @@ -348,7 +327,7 @@ function recoverTranscript(records: ChatRecord[]): TranscriptRecovery { launchPromptRecord!.systemPayload as NotificationRecordPayload ).displayText, runtimeHistory: coalesceAdjacentUserHistory( - sanitized + nonSystemStableRecords .filter((record) => record.uuid !== forkLaunchSeedUuid) .map((record) => record.message) .filter((message): message is Content => message !== undefined), @@ -575,17 +554,6 @@ export class BackgroundAgentResumeService { const continuationPrompt = promptMessages.join('\n\n').trim() || DEFAULT_BACKGROUND_AGENT_CONTINUATION_MESSAGE; - const initialMessagesOverride = recovery.pendingUserMessage - ? [ - { - ...structuredClone(recovery.pendingUserMessage), - parts: [ - ...structuredClone(recovery.pendingUserMessage.parts ?? []), - { text: `\n${continuationPrompt}` }, - ], - }, - ] - : undefined; const writerInitialPrompt = continuationPrompt; if (target.isFork && (!resumeHistory || resumeHistory.length === 0)) { const reason = LEGACY_FORK_RESUME_BLOCKED_REASON; @@ -698,9 +666,6 @@ export class BackgroundAgentResumeService { const hookSystem = this.config.getHookSystem(); const contextState = new ContextState(); contextState.set('task_prompt', continuationPrompt); - if (initialMessagesOverride) { - contextState.set('initial_messages_override', initialMessagesOverride); - } const resolvedMode = approvalModeToPermissionMode(resolvedApprovalMode); await this.applySubagentStartHook(contextState, { agentId: meta.agentId, diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 31b0398e78a..d385fcdfba4 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -55,6 +55,10 @@ type AgentToolWithProtectedMethods = AgentTool & { createInvocation: (params: AgentParams) => AgentToolInvocation; }; +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // Mock dependencies vi.mock('../../subagents/subagent-manager.js'); vi.mock('../../agents/runtime/agent-headless.js'); @@ -1551,12 +1555,18 @@ describe('AgentTool', () => { agentTool as AgentToolWithProtectedMethods ).createInvocation(params); await invocation.execute(); + const expectedTranscriptPrefix = path.join( + '/tmp/qwen-test', + 'subagents', + 'test-session-id', + 'agent-monitor-', + ); await vi.waitFor(() => { expect(mockHookSystem.fireSubagentStopEvent).toHaveBeenCalledWith( expect.stringContaining('monitor-'), 'monitor', expect.stringMatching( - /^\/tmp\/qwen-test\/subagents\/test-session-id\/agent-monitor-.*\.jsonl$/, + new RegExp(`^${escapeRegExp(expectedTranscriptPrefix)}.*\\.jsonl$`), ), 'Monitor done', false,