diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 52fa74f30b8..d7f5344567b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1220,11 +1220,13 @@ export async function loadCliConfig( : settings.tools?.discoveryCommand, toolCallCommand: bareMode ? undefined : settings.tools?.callCommand, mcpServerCommand: bareMode ? undefined : settings.mcp?.serverCommand, - mcpServers: bareMode ? {} : (() => { - const base = settings.mcpServers || {}; - const cliMcpServers = parseMcpConfig(argv.mcpConfig); - return cliMcpServers ? { ...base, ...cliMcpServers } : base; - })(), + mcpServers: bareMode + ? {} + : (() => { + const base = settings.mcpServers || {}; + const cliMcpServers = parseMcpConfig(argv.mcpConfig); + return cliMcpServers ? { ...base, ...cliMcpServers } : base; + })(), allowedMcpServers: allowedMcpServers ? Array.from(allowedMcpServers) : undefined, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 93e2e9cc662..8eaa4e0cbf8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -359,6 +359,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/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/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 { 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 2641b440a9b..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', @@ -58,6 +59,8 @@ interface ProbeHandle { interface Harness { cancel: ReturnType; + resume: ReturnType; + abandon: ReturnType; setEntries: (next: readonly DialogEntry[]) => void; pressKey: (key: { name?: string; sequence?: string }) => void; call: (fn: () => void) => void; @@ -73,6 +76,8 @@ function setup(initial: readonly DialogEntry[]): Harness { }); const cancel = vi.fn(); + const resume = vi.fn(); + const abandon = vi.fn(); // Stub registry that resolves `.get(agentId)` against the current entries // snapshot — the dialog now re-reads agent entries via `.get()` to pick up // live activity/stats mutations the snapshot misses. @@ -88,6 +93,8 @@ function setup(initial: readonly DialogEntry[]): Harness { return match; }, }), + resumeBackgroundAgent: resume, + abandonBackgroundAgent: abandon, } as unknown as Config; const handle: { current: ProbeHandle | null } = { current: null }; @@ -127,6 +134,8 @@ function setup(initial: readonly DialogEntry[]): Harness { return { cancel, + resume, + abandon, setEntries(next) { handlers.length = 0; currentEntries = next; @@ -216,4 +225,69 @@ 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'); + }); + + it('does not resume blocked paused tasks and surfaces the blocked reason', () => { + const blocked = entry({ + agentId: 'a', + status: 'paused', + resumeBlockedReason: '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'); + }); + + 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 5f6828269d6..1d1e4d31035 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -28,15 +28,15 @@ import { ToolNames, type BackgroundTaskEntry, } from '@qwen-code/qwen-code-core'; +import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { + type AgentDialogEntry, type DialogEntry, entryId, } from '../../hooks/useBackgroundTaskView.js'; -// `DialogEntry['status']` widens BackgroundTaskEntry['status'] with the -// shell status union, but they share the same four values -// (running / completed / failed / cancelled), so handlers keyed on the -// agent enum still cover every shell case. +// `DialogEntry['status']` widens the shell status union with the agent-only +// `paused` state, so dialog handlers can switch on a single combined enum. type EntryStatus = DialogEntry['status']; // Tool-name → display-name lookup (`run_shell_command` → `Shell`). @@ -54,10 +54,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', @@ -73,6 +73,12 @@ function terminalStatusPresentation( status: EntryStatus, ): StatusPresentation | null { switch (status) { + case 'paused': + return { + icon: '\u23F8', + color: theme.status.warning, + labelColor: theme.status.warningDim, + }; case 'completed': return { icon: '\u2714', @@ -244,7 +250,7 @@ const DetailBody: React.FC<{ ); const AgentDetailBody: React.FC<{ - entry: BackgroundTaskEntry; + entry: AgentDialogEntry; maxHeight: number; maxWidth: number; }> = ({ entry, maxHeight, maxWidth }) => { @@ -267,7 +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 hasError = entry.status === 'failed' && Boolean(entry.error); + const blockedReason = entry.resumeBlockedReason; + const hasError = Boolean(entry.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. @@ -351,6 +359,22 @@ const AgentDetailBody: React.FC<{ )} + {hasBlockedReason && ( + + + + + Resume blocked + + + + + {blockedReason} + + + + )} + {hasError && ( @@ -469,6 +493,7 @@ export const BackgroundTasksDialog: React.FC = ({ enterDetail, exitDetail, cancelSelected, + resumeSelected, } = useBackgroundTaskViewActions(); const config = useConfig(); @@ -609,6 +634,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; @@ -634,6 +663,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; @@ -644,15 +677,28 @@ export const BackgroundTasksDialog: React.FC = ({ if (!dialogOpen) return null; + const selectedEntryAllowsResume = + selectedEntry?.kind === 'agent' && + selectedEntry.status === 'paused' && + !selectedEntry.resumeBlockedReason; + // 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 (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 (selectedEntryAllowsResume) hints.push('r resume'); + if (selectedEntry?.kind === 'agent' && selectedEntry.status === 'paused') { + hints.push('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 e702cf50d9a..49d2b7889a8 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.test.tsx @@ -82,6 +82,12 @@ describe('getPillLabel', () => { ).toBe('1 local agent'); }); + it('uses paused form when only paused entries remain', () => { + expect(getPillLabel([agentEntry({ agentId: 'a', status: 'paused' })])).toBe( + '1 local agent paused', + ); + }); + it('uses generic done form when all entries are terminal', () => { expect( getPillLabel([agentEntry({ agentId: 'a', status: 'completed' })]), diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx index 6cf88de6cd7..540b0662025 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksPill.tsx @@ -21,10 +21,9 @@ const KIND_NAMES = { } as const; /** - * Pill label: counts running entries grouped by kind while any are - * running ("1 shell, 2 local agents"), and once everything has terminated - * switches to a "done" form so the pill still invites reopening the - * dialog to inspect final state ("3 done"). + * Pill label: prefer live running counts, then paused resumable agent counts; + * once everything is terminal, switch to a generic "done" form so the pill + * still invites reopening the dialog to inspect final state. */ export function getPillLabel(entries: readonly DialogEntry[]): string { if (entries.length === 0) return ''; @@ -33,6 +32,15 @@ export function getPillLabel(entries: readonly DialogEntry[]): string { if (running.length > 0) { return groupAndFormat(running); } + const pausedAgents = entries.filter( + (e): e is Extract => + e.kind === 'agent' && e.status === 'paused', + ); + if (pausedAgents.length > 0) { + return pausedAgents.length === 1 + ? '1 local agent paused' + : `${pausedAgents.length} local agents paused`; + } // All terminal — collapse into a single tally; per-kind detail isn't // useful at this point and would clutter the footer. return entries.length === 1 ? '1 task done' : `${entries.length} tasks done`; diff --git a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx index f508543e2d2..be3456f26f9 100644 --- a/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx +++ b/packages/cli/src/ui/contexts/BackgroundTaskViewContext.tsx @@ -56,8 +56,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; } @@ -89,6 +91,7 @@ const DEFAULT_ACTIONS: BackgroundTaskViewActions = { enterDetail: noop, exitDetail: noop, cancelSelected: noop, + resumeSelected: async () => {}, setPillFocused: noop, }; @@ -170,6 +173,10 @@ export function BackgroundTaskViewProvider({ if (!config) return; const target = entries[selectedIndex]; if (!target) return; + if (target.kind === 'agent' && target.status === 'paused') { + config.abandonBackgroundAgent(target.agentId); + return; + } // Both registries' cancel paths are no-ops on non-running entries, so // no pre-check here. Shell cancel goes through requestCancel — it // triggers the AbortController only and lets the spawn's settle path @@ -182,6 +189,20 @@ export function BackgroundTaskViewProvider({ } }, [config, entries, selectedIndex]); + const resumeSelected = useCallback(async () => { + if (!config) return; + const target = entries[selectedIndex]; + if ( + !target || + target.kind !== 'agent' || + target.status !== 'paused' || + target.resumeBlockedReason + ) { + return; + } + await config.resumeBackgroundAgent(target.agentId); + }, [config, entries, selectedIndex]); + const state: BackgroundTaskViewState = useMemo( () => ({ entries, @@ -202,6 +223,7 @@ export function BackgroundTaskViewProvider({ enterDetail, exitDetail, cancelSelected, + resumeSelected, setPillFocused, }), [ @@ -212,6 +234,7 @@ export function BackgroundTaskViewProvider({ enterDetail, exitDetail, cancelSelected, + resumeSelected, setPillFocused, ], ); 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/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 a346e0ad9d9..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: @@ -111,7 +114,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 +142,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 +156,18 @@ 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(), + }), getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), getDebugLogger: () => ({ warn: vi.fn(), @@ -194,4 +217,121 @@ 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(), + 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' }]), + 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), + ); + }); + + 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 f9ca2df233a..9541f305429 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; @@ -38,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 { @@ -63,13 +85,25 @@ 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) { 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(); @@ -94,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 @@ -101,6 +136,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 +169,7 @@ export function useResumeCommand( closeResumeDialog, config, hasHistoryManager, + addItem, clearItems, loadHistory, startNewSession, @@ -137,3 +186,5 @@ export function useResumeCommand( handleResume, }; } + +export { BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE }; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 82149d2ac5b..988cc7e5b5f 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/src/index.ts"], + "@qwen-code/qwen-code-core/*": ["../core/src/*"] + } }, "include": [ "index.ts", diff --git a/packages/core/src/agents/agent-transcript.test.ts b/packages/core/src/agents/agent-transcript.test.ts index ccf17d8c1fb..89f3262531e 100644 --- a/packages/core/src/agents/agent-transcript.test.ts +++ b/packages/core/src/agents/agent-transcript.test.ts @@ -13,11 +13,14 @@ import { getAgentJsonlPath, getAgentMetaPath, attachJsonlTranscriptWriter, + readAgentMeta, + readLastTranscriptRecordUuidSync, writeAgentMeta, type AgentMeta, } 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', () => { @@ -116,6 +119,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 +168,13 @@ describe('agent-transcript', () => { function makeWriter( jsonlPath: string, - extra: { initialUserPrompt?: string } = {}, + extra: { + initialUserPrompt?: string; + bootstrapHistory?: Content[]; + bootstrapSystemInstruction?: string | Content; + bootstrapTools?: Array; + launchTaskPrompt?: string; + } = {}, ) { const emitter = new AgentEventEmitter(); const { cleanup } = attachJsonlTranscriptWriter(emitter, jsonlPath, { @@ -182,6 +217,75 @@ 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 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); @@ -404,5 +508,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..59154d36da0 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -29,8 +29,13 @@ 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'; +import type { FunctionDeclaration, Content } from '@google/genai'; const debugLogger = createDebugLogger('AGENT_TRANSCRIPT'); @@ -94,6 +99,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 +130,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 +206,36 @@ 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?: 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 + * 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 +259,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 +368,54 @@ export function attachJsonlTranscriptWriter( }); }; + const recordSystem = ( + subtype: NonNullable, + payload: ChatRecord['systemPayload'], + ) => { + append({ + ...baseFields('system'), + subtype, + systemPayload: payload, + }); + }; + const onExternalMessage = (event: AgentExternalMessageEvent) => { recordUserMessage(event.text); }; + const hasBootstrapPayload = + options.bootstrapHistory !== undefined || + options.bootstrapSystemInstruction !== undefined || + options.bootstrapTools !== undefined; + + if (hasBootstrapPayload) { + 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); + } + 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..801181a9f2c --- /dev/null +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -0,0 +1,1224 @@ +/** + * @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, + maxRetries: 5, + retryDelay: 50, + }); + }); + + 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', + isTrustedFolder: () => true, + 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', + 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'; + 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('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('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'; + 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'); + }); + 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 () => { + 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' }] }, + ], + systemInstruction: { + role: 'system', + parts: [{ text: 'persisted system instruction' }], + }, + tools: [{ name: 'Bash' }, { name: 'Read' }], + }, + }), + 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({ + renderedSystemPrompt: { + role: 'system', + parts: [{ text: 'persisted system instruction' }], + }, + 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(createArgs?.[5]).toEqual({ + tools: [{ name: 'Bash' }, { name: 'Read' }], + }); + 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)?.resumeBlockedReason).toContain( + 'bootstrap transcript is missing', + ); + expect(registry.get(agentId)?.error).toBeUndefined(); + 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('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('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); + 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).toBeUndefined(); + expect(context.get('task_prompt')).toBe('continue 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' }] }, + { role: 'user', parts: [{ text: 'and another thing' }] }, + ], + }, + }), + ); + }); +}); + +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 new file mode 100644 index 00000000000..452d636e185 --- /dev/null +++ b/packages/core/src/agents/background-agent-resume.ts @@ -0,0 +1,989 @@ +/** + * @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.'; + +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'; + +interface TranscriptRecovery { + history: Content[]; + initialPrompt?: string; + lastStableUuid: string | null; + forkBootstrap?: { + history: Content[]; + taskPrompt: string; + runtimeHistory: Content[]; + systemInstruction?: string | Content; + tools?: Array; + }; +} + +interface ResolvedResumeTarget { + agentName: string; + isFork: boolean; + subagentConfig?: SubagentConfig; + unavailableReason?: string; +} + +interface ResumeOperation { + continuationMessages: string[]; + promise: Promise; +} + +interface RestorePausedEntryOptions { + error?: string; + resumeBlockedReason?: string; +} + +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 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, +): 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 persistBackgroundCancellation( + metaPath: string, + persistedStatus: 'running' | 'cancelled', +): void { + patchAgentMeta(metaPath, { + status: persistedStatus, + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); +} + +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; + + return { + history: coalesceAdjacentUserHistory( + nonSystemStableRecords + .map((record) => record.message) + .filter((message): message is Content => message !== undefined), + ), + initialPrompt: initialPrompt || undefined, + lastStableUuid: + stableForBranch.length > 0 + ? stableForBranch[stableForBranch.length - 1]!.uuid + : null, + forkBootstrap: + bootstrapRecord?.systemPayload && + (bootstrapRecord.systemPayload as AgentBootstrapRecordPayload).kind === + 'fork' && + typeof ( + launchPromptRecord?.systemPayload as + | NotificationRecordPayload + | undefined + )?.displayText === 'string' + ? { + history: structuredClone( + (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, + runtimeHistory: coalesceAdjacentUserHistory( + nonSystemStableRecords + .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 resumeBlockedReason = + target.unavailableReason || + (target.isFork && !recovery.forkBootstrap + ? LEGACY_FORK_RESUME_BLOCKED_REASON + : target.isFork && + (!recovery.forkBootstrap?.systemInstruction || + !recovery.forkBootstrap?.tools) + ? LEGACY_FORK_CAPABILITIES_BLOCKED_REASON + : undefined); + + 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 === resumeBlockedReason ? undefined : meta.lastError, + resumeBlockedReason, + }; + 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(); + + registry.register({ + ...existing, + status: 'running', + abortController: bgAbortController, + endTime: undefined, + result: undefined, + error: undefined, + resumeBlockedReason: 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: undefined, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); + return undefined; + } + + 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() + ? 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 writerInitialPrompt = continuationPrompt; + if (target.isFork && (!resumeHistory || resumeHistory.length === 0)) { + const reason = LEGACY_FORK_RESUME_BLOCKED_REASON; + patchAgentMeta(metaPath, { + lastError: undefined, + lastUpdatedAt: new Date().toISOString(), + }); + this.restorePausedEntry(agentId, { resumeBlockedReason: reason }); + return undefined; + } + if (target.isFork && !recovery.forkBootstrap) { + const reason = LEGACY_FORK_RESUME_BLOCKED_REASON; + patchAgentMeta(metaPath, { + lastError: undefined, + lastUpdatedAt: new Date().toISOString(), + }); + 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 + ? await this.createResumedForkSubagent( + bgConfig as Config, + bgEventEmitter, + resumeHistory ?? [], + recovery.forkBootstrap!, + ) + : 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, + resumeBlockedReason: 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); + 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, + transcriptPath: outputFile, + 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); + persistBackgroundCancellation( + metaPath, + registry.get(meta.agentId)?.persistedCancellationStatus ?? + 'cancelled', + ); + } 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), + ); + persistBackgroundCancellation( + metaPath, + registry.get(meta.agentId)?.persistedCancellationStatus ?? + 'cancelled', + ); + } 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, { error: 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, + options: RestorePausedEntryOptions = {}, + ): 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: options.error, + resumeBlockedReason: options.resumeBlockedReason, + stats: undefined, + recentActivities: [], + pendingMessages: [...(latest.pendingMessages ?? [])], + notified: false, + }; + registry.register(pausedEntry); + return pausedEntry; + } + + private async createResumedForkSubagent( + agentConfig: Config, + eventEmitter: AgentEventEmitter, + initialMessages: Content[], + bootstrap: NonNullable, + ): Promise { + const promptConfig: PromptConfig = { + renderedSystemPrompt: structuredClone(bootstrap.systemInstruction!), + initialMessages, + }; + const toolConfig: ToolConfig = { + tools: structuredClone(bootstrap.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; + transcriptPath: string; + resolvedMode: PermissionMode; + signal?: AbortSignal; + }, + ): Promise { + const { agentId, agentType, transcriptPath, resolvedMode, signal } = opts; + const hookSystem = this.config.getHookSystem(); + if (!hookSystem) return; + 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..7da05305d98 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 { @@ -228,6 +257,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', @@ -285,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 @@ -502,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({ @@ -733,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 8e30e182806..77015700269 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'); @@ -73,6 +74,7 @@ function escapeXml(text: string): string { export type BackgroundTaskStatus = | 'running' + | 'paused' | 'completed' | 'failed' | 'cancelled'; @@ -107,6 +109,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; @@ -127,6 +134,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[]; /** @@ -136,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 { @@ -160,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. */ @@ -243,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); @@ -259,6 +290,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,11 +384,29 @@ 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; } + /** + * 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. @@ -395,7 +460,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); @@ -473,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/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 f4b2c42fc33..0c391f43349 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; @@ -2509,6 +2511,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/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/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 diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 21c94114d68..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, @@ -92,7 +93,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 +138,8 @@ export interface ChatRecord { | AtCommandRecordPayload | CustomTitleRecordPayload | NotificationRecordPayload - | RewindRecordPayload; + | RewindRecordPayload + | AgentBootstrapRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ agentId?: string; @@ -151,6 +155,27 @@ 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[]; + /** + * 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; +} + /** * 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.test.ts b/packages/core/src/tools/agent/agent.test.ts index ac0c55c2ea5..d385fcdfba4 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 = { @@ -54,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'); @@ -1531,6 +1536,46 @@ 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(); + 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( + new RegExp(`^${escapeRegExp(expectedTranscriptPrefix)}.*\\.jsonl$`), + ), + 'Monitor done', + false, + PermissionMode.AutoEdit, + expect.any(AbortSignal), + ); + }); + }); + it('should run in foreground when neither flag is set', async () => { const fgSubagent: SubagentConfig = { ...bgSubagent, @@ -1666,6 +1711,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 ec2ed6e3f27..9d8a31345d4 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -61,10 +61,22 @@ import { getAgentJsonlPath, getAgentMetaPath, attachJsonlTranscriptWriter, + patchAgentMeta, writeAgentMeta, } from '../../agents/agent-transcript.js'; import { getGitBranch } from '../../utils/gitUtils.js'; +function persistBackgroundCancellation( + metaPath: string, + persistedStatus: 'running' | 'cancelled', +): void { + patchAgentMeta(metaPath, { + status: persistedStatus, + lastUpdatedAt: new Date().toISOString(), + lastError: undefined, + }); +} + export interface AgentParams { description: string; prompt: string; @@ -640,7 +652,10 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter: AgentEventEmitter = this.eventEmitter, ): Promise<{ subagent: AgentHeadless; + initialMessages?: Content[]; taskPrompt: string; + promptConfig: PromptConfig; + toolConfig: ToolConfig; }> { const geminiClient = this.config.getGeminiClient(); const rawHistory = geminiClient ? geminiClient.getHistory(true) : []; @@ -740,7 +755,7 @@ class AgentToolInvocation extends BaseToolInvocation { eventEmitter, ); - return { subagent, taskPrompt }; + return { subagent, initialMessages, taskPrompt, promptConfig, toolConfig }; } // Runs the SubagentStop hook after execution. On a blocking decision, feeds the @@ -751,15 +766,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; @@ -768,7 +785,7 @@ class AgentToolInvocation extends BaseToolInvocation { const stopHookOutput = await hookSystem.fireSubagentStopEvent( agentId, agentType, - transcriptPath, + effectiveTranscriptPath, subagent.getFinalText(), stopHookActive, resolvedMode, @@ -1056,18 +1073,27 @@ 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; + let bgPromptConfig: PromptConfig | undefined; + let bgToolConfig: ToolConfig | undefined; if (isFork) { const fork = await this.createForkSubagent( bgConfig as Config, bgEventEmitter, ); bgSubagent = fork.subagent; + bgInitialMessages = fork.initialMessages; + bgTaskPrompt = fork.taskPrompt; + bgPromptConfig = fork.promptConfig; + bgToolConfig = fork.toolConfig; } else { bgSubagent = await this.subagentManager.createAgentHeadless( subagentConfig, bgConfig as Config, { eventEmitter: bgEventEmitter }, ); + bgTaskPrompt = this.params.prompt; } const registry = this.config.getBackgroundTaskRegistry(); @@ -1100,6 +1126,13 @@ 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, + bootstrapSystemInstruction: isFork + ? (bgPromptConfig?.renderedSystemPrompt ?? + bgPromptConfig?.systemPrompt) + : undefined, + bootstrapTools: isFork ? bgToolConfig?.tools : undefined, + launchTaskPrompt: isFork ? bgTaskPrompt : undefined, }, ); writeAgentMeta(metaPath, { @@ -1112,8 +1145,13 @@ 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, }); - registry.register({ agentId: hookOpts.agentId, description: this.params.description, @@ -1124,6 +1162,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 @@ -1192,6 +1231,7 @@ class AgentToolInvocation extends BaseToolInvocation { await this.runSubagentStopHookLoop(bgSubagent, { agentId: hookOpts.agentId, agentType: hookOpts.agentType, + transcriptPath: jsonlPath, resolvedMode, signal: bgAbortController.signal, }); @@ -1208,18 +1248,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, ); + persistBackgroundCancellation( + metaPath, + registry.get(hookOpts.agentId)?.persistedCancellationStatus ?? + 'cancelled', + ); } 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 +1291,18 @@ class AgentToolInvocation extends BaseToolInvocation { errorMsg, getCompletionStats(), ); + persistBackgroundCancellation( + metaPath, + registry.get(hookOpts.agentId)?.persistedCancellationStatus ?? + 'cancelled', + ); } 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/shell.test.ts b/packages/core/src/tools/shell.test.ts index 206f67d9944..615de65fa0a 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -414,15 +414,51 @@ describe('ShellTool', () => { expect(registry.complete).not.toHaveBeenCalled(); }); - it('strips trailing & from the spawned command (managed path handles backgrounding)', async () => { + 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('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: 'node server.js &', + command: 'npm run dev &&', is_background: true, }); await invocation.execute(mockAbortSignal); expect(mockShellExecutionService).toHaveBeenCalledWith( - 'node server.js', - '/test/dir', + 'npm run dev &&', + expect.any(String), expect.any(Function), expect.any(AbortSignal), false, @@ -431,14 +467,14 @@ describe('ShellTool', () => { ); }); - it('does not strip a trailing && (logical AND would be syntactically broken)', async () => { + it('preserves an escaped trailing \\& (literal &)', async () => { const invocation = shellTool.build({ - command: 'npm run dev &&', + command: 'echo foo \\&', is_background: true, }); await invocation.execute(mockAbortSignal); expect(mockShellExecutionService).toHaveBeenCalledWith( - 'npm run dev &&', + 'echo foo \\&', expect.any(String), expect.any(Function), expect.any(AbortSignal), @@ -448,14 +484,48 @@ describe('ShellTool', () => { ); }); - it('does not strip an escaped trailing \\& (literal &)', async () => { + it('preserves quoted trailing ampersands', async () => { const invocation = shellTool.build({ - command: 'echo foo \\&', + command: `printf '&'`, is_background: true, }); await invocation.execute(mockAbortSignal); expect(mockShellExecutionService).toHaveBeenCalledWith( - 'echo foo \\&', + `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), diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 625dfe4c6df..d49cca71244 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -48,23 +48,216 @@ 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; +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; @@ -434,25 +627,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` @@ -760,6 +935,13 @@ export class ShellTool extends BaseDeclarativeTool< if (!params.command.trim()) { return 'Command cannot be empty.'; } + const strippedCommand = stripShellWrapper(params.command); + if ( + params.is_background && + hasTopLevelTrailingBackgroundOperator(strippedCommand) + ) { + 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.'; } diff --git a/packages/core/src/tools/task-stop.test.ts b/packages/core/src/tools/task-stop.test.ts index acd0ca14052..77d853c5faa 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 { BackgroundShellRegistry } from '../services/backgroundShellRegistry.js'; @@ -16,12 +16,15 @@ describe('TaskStopTool', () => { let shellRegistry: BackgroundShellRegistry; let config: Config; let tool: TaskStopTool; + let abandonBackgroundAgent: ReturnType; beforeEach(() => { registry = new BackgroundTaskRegistry(); + abandonBackgroundAgent = vi.fn(); shellRegistry = new BackgroundShellRegistry(); config = { getBackgroundTaskRegistry: () => registry, + abandonBackgroundAgent, getBackgroundShellRegistry: () => shellRegistry, } as unknown as Config; tool = new TaskStopTool(config); @@ -96,6 +99,26 @@ describe('TaskStopTool', () => { 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 agent'); + }); + describe('background shell support', () => { it('cancels a running background shell', async () => { const ac = new AbortController(); @@ -117,7 +140,9 @@ describe('TaskStopTool', () => { expect(result.error).toBeUndefined(); expect(result.llmContent).toContain('background shell "bg_a1b2c3d4"'); expect(result.llmContent).toContain('npm run dev'); - expect(result.llmContent).toContain('/tmp/bg-out/shell-bg_a1b2c3d4.output'); + expect(result.llmContent).toContain( + '/tmp/bg-out/shell-bg_a1b2c3d4.output', + ); // task_stop only requests cancellation — the entry stays `running` // until the spawn handler observes the abort and settles the entry // with the real exit moment. Without this guarantee, /tasks would diff --git a/packages/core/src/tools/task-stop.ts b/packages/core/src/tools/task-stop.ts index c67ed2153a8..57f8cfb02a7 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'; @@ -48,6 +48,29 @@ class TaskStopInvocation extends BaseToolInvocation< const agentRegistry = this.config.getBackgroundTaskRegistry(); const agentEntry = agentRegistry.get(taskId); if (agentEntry) { + if (agentEntry.status === 'paused') { + const abandoned = this.config.abandonBackgroundAgent(taskId); + if (!abandoned) { + return { + llmContent: + `Error: Background agent "${taskId}" could not be cancelled ` + + `from paused state.`, + returnDisplay: 'Task could not be cancelled.', + error: { + message: `Task could not be cancelled: ${taskId}`, + type: ToolErrorType.TASK_STOP_NOT_RUNNING, + }, + }; + } + + const desc = agentEntry.description; + return { + llmContent: + `Cancelled paused background agent "${taskId}".\n` + + `Description: ${desc}`, + returnDisplay: `Cancelled: ${desc}`, + }; + } if (agentEntry.status !== 'running') { return notRunningError('agent', taskId, agentEntry.status); } @@ -127,7 +150,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 agents and shells are cancelled; paused recovered agents are abandoned without resuming them.', Kind.Other, { type: 'object',