From 4f5bd9897a37f81a25c7f06489af5f900843c497 Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:47:41 +0800 Subject: [PATCH 1/8] feat(core): restore background agent roster --- ...6-07-22-background-agent-roster-restore.md | 69 +++++++ .../cli/src/acp-integration/acpAgent.test.ts | 10 +- packages/cli/src/acp-integration/acpAgent.ts | 11 + .../src/acp-integration/session/Session.ts | 18 +- .../session/Session.worktree.test.ts | 50 +++++ packages/cli/src/nonInteractiveCli.test.ts | 2 + packages/cli/src/nonInteractiveCli.ts | 28 ++- packages/cli/src/ui/AppContainer.test.tsx | 46 +++++ packages/cli/src/ui/AppContainer.tsx | 29 ++- .../cli/src/ui/hooks/useBranchCommand.test.ts | 70 +++++++ packages/cli/src/ui/hooks/useBranchCommand.ts | 19 ++ .../cli/src/ui/hooks/useResumeCommand.test.ts | 3 + packages/cli/src/ui/hooks/useResumeCommand.ts | 21 +- packages/core/src/agents/agent-transcript.ts | 18 ++ .../agents/background-agent-resume.test.ts | 138 ++++++++++++- .../src/agents/background-agent-resume.ts | 191 ++++++++++++++++-- .../core/src/agents/runtime/agent-core.ts | 2 + packages/core/src/config/config.test.ts | 10 + packages/core/src/config/config.ts | 39 +++- packages/core/src/tools/agent/agent.test.ts | 3 +- packages/core/src/tools/agent/agent.ts | 8 +- packages/core/src/tools/list-agents.test.ts | 91 +++++++++ packages/core/src/tools/list-agents.ts | 95 +++++++++ packages/core/src/tools/send-message.test.ts | 26 +++ packages/core/src/tools/send-message.ts | 13 +- packages/core/src/tools/tool-names.ts | 2 + 26 files changed, 956 insertions(+), 56 deletions(-) create mode 100644 docs/design/2026-07-22-background-agent-roster-restore.md create mode 100644 packages/core/src/tools/list-agents.test.ts create mode 100644 packages/core/src/tools/list-agents.ts diff --git a/docs/design/2026-07-22-background-agent-roster-restore.md b/docs/design/2026-07-22-background-agent-roster-restore.md new file mode 100644 index 00000000000..974ccb9354e --- /dev/null +++ b/docs/design/2026-07-22-background-agent-roster-restore.md @@ -0,0 +1,69 @@ +# Background Agent Roster Restore + +## Context + +Background-agent sidecars and JSONL transcripts persist logical identity and +history, while `BackgroundTaskRegistry` indexes the current session's +addressable tasks. The resume loader currently restores only sidecars left in +`running` state. Completed agents therefore disappear from the registry after +their parent session is restored, even though their transcripts remain +available. The model also has no tool for querying the registry. + +## Goals + +- Restore recent completed background agents with their original task IDs. +- Add a model-callable `list_agents` tool for on-demand discovery. +- Keep `send_message(task_id)` as the continuation operation. +- Give the model one short, one-shot reminder after restoration. +- Apply the same restoration behavior to TUI, headless, and ACP entry points. + +## Non-goals + +- Persisting a live JavaScript runtime across process teardown. +- Replacing the Agent Teams `task_list` tool. +- Restoring failed or cancelled agents. +- Reconstructing temporary worktree isolation. + +## Design + +The session-directory scan accepts both `running` and `completed` sidecars. +Running entries become paused, preserving the existing interrupted-work +behavior. Completed entries remain completed, are marked already notified, and +retain the transcript and metadata paths needed by `send_message` revival. + +New sidecars persist whether the original launch was backgrounded. Completed +entries are restored only when this marker is explicitly true, so foreground +and legacy unmarked completed sidecars are not exposed as reusable background +agents. Legacy running sidecars retain the existing recovery behavior. + +The loader verifies the sidecar filename and parent-session owner before +registration. A retained row with a missing transcript, mismatched transcript +identity, incompatible isolation, or conflicting working directory remains +visible but is marked non-continuable. Worktree-isolated rows are treated the +same way because their temporary ownership context cannot be reconstructed +safely. Only the newest retained completed entries are restored; running +entries are not subject to that limit. + +`list_agents` reads the live registry and returns background agents with a +stable `task_id`, description, type, status, continuation capability, and any +blocking reason. It does not scan disk. The tool is caller-owned and excluded +from subagents and teammates. + +After restoration, the next ordinary top-level user prompt receives a single +system reminder to call `list_agents` and then `send_message`. Slash commands +and interrupted-turn continuations do not consume this reminder. Bare mode +does not receive it. + +Session switches clear the in-memory registry before loading a new roster. +Failed resume rollback clears partially restored entries before restoring the +old session, and branching is blocked while background work is still active. + +## Validation + +- Running and completed sidecars restore with stable IDs and correct states. +- Foreground and wrong-owner sidecars are excluded. +- Unsafe retained state is visible but cannot be continued. +- Restored completed entries do not emit duplicate completion notifications. +- `send_message` can revive a compatible restored completed entry. +- TUI, headless, and ACP restore the roster and deliver the reminder once. +- New, clear, branch, and failed resume paths do not leak a prior roster. diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c4a527d56f7..45eb17d7d29 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -11496,6 +11496,8 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { getSessionRuntimeBaseDir: vi .fn() .mockReturnValue('/tmp/qwen-runtime-test'), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), assertCanStartTurn: vi.fn().mockResolvedValue(undefined), getSessionService: vi.fn(), // load path reads back the persisted conversation here and feeds @@ -12015,7 +12017,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { it('loadSession returns LoadSessionResponse and replays history on the session', async () => { const messages = [{ role: 'user', parts: [{ text: 'hi' }] }]; - bindRestoreMocks({ + const innerConfig = bindRestoreMocks({ sessionExists: true, resumedConversation: { messages, @@ -12044,6 +12046,12 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => { const recording = lastSessionMock?.getConfig().getChatRecordingService(); expect(recording?.rebuildTurnBoundaries).toHaveBeenCalledWith(messages); + expect(innerConfig.loadPausedBackgroundAgents).toHaveBeenCalledWith( + 'persisted-1', + ); + expect( + innerConfig.consumePendingRecoveredAgentsNotice, + ).toHaveBeenCalledOnce(); mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 88467d607ac..ad5f284ba2d 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -3969,6 +3969,7 @@ class QwenAgent implements Agent { } await this.#restoreWorktreeOnResume(config, session); + await this.#restoreBackgroundAgentsOnResume(config, session); this.#restoreGoalOnResume(config, session); const modesData = this.buildModesData(config); @@ -4056,6 +4057,7 @@ class QwenAgent implements Agent { } await this.#restoreWorktreeOnResume(config, session); + await this.#restoreBackgroundAgentsOnResume(config, session); this.#restoreGoalOnResume(config, session); const modesData = this.buildModesData(config); @@ -4096,6 +4098,15 @@ class QwenAgent implements Agent { } } + async #restoreBackgroundAgentsOnResume( + config: Config, + session: Session, + ): Promise { + await config.loadPausedBackgroundAgents(config.getSessionId()); + session.pendingRecoveredAgentsNotice = + config.consumePendingRecoveredAgentsNotice(); + } + /** * Re-registers the `/goal` Stop hook when a resumed transcript ends on an * unsatisfied goal — the daemon counterpart of the TUI's resume restore. diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index ef401013976..76754c69d47 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -1197,6 +1197,9 @@ export class Session implements SessionContext { */ pendingWorktreeNotice: string | null = null; + /** One-shot model notice for background agents restored with the session. */ + pendingRecoveredAgentsNotice: string | null = null; + // Implement SessionContext interface readonly sessionId: string; @@ -2397,6 +2400,7 @@ export class Session implements SessionContext { (block) => block.type === 'text', ); const inputText = firstTextBlock?.text || ''; + const isSlashInput = !isContinue && isSlashCommand(inputText); let parts: Part[] | null; let fullTurnModelOverride: string | undefined; @@ -2412,7 +2416,7 @@ export class Session implements SessionContext { // Non-null here: the `none` case returned early above, and both // interruption branches assign a concrete part list. parts = continuationParts!; - } else if (isSlashCommand(inputText)) { + } else if (isSlashInput) { // Handle slash command in ACP mode using capability-based filtering const slashCommandResult = await handleSlashCommand( inputText, @@ -2557,6 +2561,18 @@ export class Session implements SessionContext { this.pendingWorktreeNotice = null; } + if ( + this.pendingRecoveredAgentsNotice && + !isContinue && + !isSlashInput + ) { + const noticePart = { + text: `\n${this.pendingRecoveredAgentsNotice}\n\n\n`, + }; + parts = insertAfterFunctionResponses(parts, [noticePart]); + this.pendingRecoveredAgentsNotice = null; + } + let nextMessage: Content | null = { role: 'user', parts }; let turnCount = 0; const toolLoopState = createDaemonToolLoopState(); diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index ad1d39f0c87..99a5df3aa59 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -26,6 +26,7 @@ import type { PromptRequest, } from '@agentclientprotocol/sdk'; import type { LoadedSettings } from '../../config/settings.js'; +import { handleSlashCommand } from '../../nonInteractiveCliCommands.js'; // Stub the non-interactive CLI commands that Session.ts imports transitively. vi.mock('../../nonInteractiveCliCommands.js', () => ({ @@ -67,6 +68,7 @@ describe('Session.pendingWorktreeNotice', () => { beforeEach(() => { capturedMessages = []; + vi.mocked(handleSlashCommand).mockReset(); mockChat = { sendMessageStream: vi @@ -253,6 +255,54 @@ describe('Session.pendingWorktreeNotice', () => { expect(session.pendingWorktreeNotice).toBeNull(); }); + it('injects a recovered-agents notice into the next prompt once', async () => { + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = + '2 background agents were restored. Use list_agents to inspect them.'; + session.pendingRecoveredAgentsNotice = notice; + + await session.prompt(makePromptRequest('first prompt')); + await session.prompt(makePromptRequest('second prompt')); + + const firstParts = capturedMessages[0] as Array<{ text?: string }>; + expect(firstParts.some((part) => part.text?.includes(notice))).toBe(true); + const secondParts = capturedMessages[1] as Array<{ text?: string }>; + expect(secondParts.some((part) => part.text?.includes(notice))).toBe(false); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + + it('does not consume a recovered-agents notice for a slash command', async () => { + vi.mocked(handleSlashCommand).mockResolvedValueOnce({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }); + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = 'Recovered agents are available.'; + session.pendingRecoveredAgentsNotice = notice; + + await session.prompt(makePromptRequest('/testcommand')); + await session.prompt(makePromptRequest('ordinary prompt')); + + expect(capturedMessages[0]).toEqual([{ text: 'Prompt from command' }]); + expect(capturedMessages[1]).toEqual( + expect.arrayContaining([ + { text: expect.stringContaining(notice) as string }, + { text: 'ordinary prompt' }, + ]), + ); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + // VP4b: sanity — no notice set, prompt works normally, no worktree reminder injected it('VP4b: no notice set — prompt proceeds normally without worktree system-reminder', async () => { const session = new Session( diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index bc0635f85c3..8fddef7ea4c 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -334,6 +334,8 @@ describe('runNonInteractive', () => { // --worktree flag, so return null to short-circuit injection // and let the resume-restore branch run. consumePendingStartupWorktreeNotice: vi.fn().mockReturnValue(null), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index acf56dfa201..3dc9c320227 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -78,6 +78,8 @@ import { cleanupReviewWorktreeLeases } from './services/review-worktree-lease.js const debugLogger = createDebugLogger('NON_INTERACTIVE_CLI'); +const restoredBackgroundAgentSessions = new WeakMap>(); + /** * Maximum wait, in milliseconds, for in-flight background tasks to emit * their terminal `task_notification` after `abortAll()` on the @@ -650,6 +652,17 @@ export async function runNonInteractive( ); adapter.emitMessage(systemMessage); + const resumedSessionData = config.getResumedSessionData(); + if (resumedSessionData) { + const restoredSessions = + restoredBackgroundAgentSessions.get(config) ?? new Set(); + if (!restoredSessions.has(sessionId)) { + await config.loadPausedBackgroundAgents(sessionId); + restoredSessions.add(sessionId); + restoredBackgroundAgentSessions.set(config, restoredSessions); + } + } + let initialPartList: PartListUnion | null = extractPartsFromUserMessage( options.userMessage, ); @@ -832,10 +845,7 @@ export async function runNonInteractive( adapter.emitSystemMessage('worktree_started', { notice: startupNotice, }); - } else if ( - !options.continueInterrupted && - config.getResumedSessionData() - ) { + } else if (!options.continueInterrupted && resumedSessionData) { try { const sessionPath = config .getSessionService() @@ -859,6 +869,16 @@ export async function runNonInteractive( } } + const recoveredAgentsNotice = + resumedSessionData && + !options.continueInterrupted && + !isSlashCommand(input) + ? config.consumePendingRecoveredAgentsNotice() + : null; + if (recoveredAgentsNotice) { + initialPartList = withReminder(initialPartList, recoveredAgentsNotice); + } + const initialParts = normalizePartList(initialPartList); let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b1076ca504e..59bb01b2306 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1145,6 +1145,52 @@ describe('AppContainer State Management', () => { expect(mockQueueMessage).not.toHaveBeenCalled(); }); + it('injects a recovered-agent reminder into the next ordinary prompt once', () => { + const mockQueueMessage = vi.fn(); + vi.spyOn(mockConfig, 'consumePendingRecoveredAgentsNotice') + .mockReturnValueOnce('Use list_agents to inspect restored agents.') + .mockReturnValue(null); + mockedUseGeminiStream.mockReturnValue({ + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + }); + mockedUseMessageQueue.mockReturnValue({ + messageQueue: [], + addMessage: mockQueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextSegment: vi.fn().mockReturnValue(null), + }); + + render( + , + ); + + capturedUIActions.handleFinalSubmit('continue the review'); + capturedUIActions.handleFinalSubmit('one more check'); + + expect(mockQueueMessage).toHaveBeenNthCalledWith( + 1, + '\nUse list_agents to inspect restored agents.\n' + + '\n\ncontinue the review', + ); + expect(mockQueueMessage).toHaveBeenNthCalledWith(2, 'one more check'); + }); + it.each(['exit', 'quit', ':q', ':q!', ':wq', ':wq!'])( 'routes bare "%s" to /quit instead of sending as a message', (command) => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2c8be76ca15..8cf9e4fe0d0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2174,6 +2174,25 @@ export const AppContainer = (props: AppContainerProps) => { // The user's raw text, captured before any `` prefix is // prepended below (so keyword detection sees only what the user typed). const userPromptText = submittedValue; + // Quit must bypass reminders and the message queue so it can stop an + // active stream without consuming one-shot session state. + if ( + ['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes( + userPromptText.trim(), + ) + ) { + void handleSlashCommand('/quit'); + return; + } + const recoveredAgentsNotice = + !isSlashCommand(userPromptText) && !isBtwCommand(userPromptText) + ? config.consumePendingRecoveredAgentsNotice() + : null; + if (recoveredAgentsNotice) { + submittedValue = + `\n${recoveredAgentsNotice}\n\n\n` + + submittedValue; + } // Phase C: one-shot worktree restore reminder. Set during --resume // when the persisted sidecar names a live worktree. We only inject // on top-level user prompts (not btw-during-response, not slash @@ -2222,16 +2241,6 @@ export const AppContainer = (props: AppContainerProps) => { return; } - // Quit must bypass the message queue so it can stop an active stream. - if ( - ['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes( - submittedValue.trim(), - ) - ) { - void handleSlashCommand('/quit'); - return; - } - // Check if speculation has results for this submission const spec = speculationRef.current; if ( diff --git a/packages/cli/src/ui/hooks/useBranchCommand.test.ts b/packages/cli/src/ui/hooks/useBranchCommand.test.ts index fd1230931d1..1da1960e4d9 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.test.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.test.ts @@ -98,9 +98,79 @@ describe('useBranchCommand', () => { getGeminiClient: () => ({ initialize: vi.fn() }), startNewSession: startNewSessionConfig, getDebugLogger: () => ({ warn: vi.fn() }), + getBackgroundTaskRegistry: () => ({ + hasRunningTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getWorkflowRunRegistry: () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), }; }); + it('blocks branching while background work is running', async () => { + config.getBackgroundTaskRegistry = () => ({ + hasRunningTasks: vi.fn().mockReturnValue(true), + reset: vi.fn(), + }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('blocked'); + }); + + expect(forkSession).not.toHaveBeenCalled(); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + text: expect.stringContaining('running background tasks'), + }), + expect.any(Number), + ); + }); + + it('clears the prior session roster after a successful branch', async () => { + const backgroundReset = vi.fn(); + const monitorReset = vi.fn(); + const shellReset = vi.fn(); + const workflowReset = vi.fn(); + config.getBackgroundTaskRegistry = () => ({ + hasRunningTasks: vi.fn().mockReturnValue(false), + reset: backgroundReset, + }); + config.getMonitorRegistry = () => ({ + getRunning: vi.fn().mockReturnValue([]), + reset: monitorReset, + }); + config.getBackgroundShellRegistry = () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: shellReset, + }); + config.getWorkflowRunRegistry = () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: workflowReset, + }); + + const { result } = renderHook(() => useBranchCommand(makeOptions())); + await act(async () => { + await result.current.handleBranch('ready'); + }); + + expect(backgroundReset).toHaveBeenCalledOnce(); + expect(monitorReset).toHaveBeenCalledOnce(); + expect(shellReset).toHaveBeenCalledOnce(); + expect(workflowReset).toHaveBeenCalledOnce(); + }); + it('persists and reloads the title before switching core or UI', async () => { // The parent snapshot must come AFTER finalize(): finalize() appends a // trailing custom_title record to the parent JSONL, advancing the diff --git a/packages/cli/src/ui/hooks/useBranchCommand.ts b/packages/cli/src/ui/hooks/useBranchCommand.ts index b153834a7aa..f89f131b576 100644 --- a/packages/cli/src/ui/hooks/useBranchCommand.ts +++ b/packages/cli/src/ui/hooks/useBranchCommand.ts @@ -21,6 +21,13 @@ import { restoreGoalFromHistory } from '../utils/restoreGoal.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; +import { + hasBlockingBackgroundWork, + resetBackgroundStateForSessionSwitch, +} from '../utils/backgroundWorkUtils.js'; + +const BACKGROUND_WORK_BRANCH_BLOCKED_MESSAGE = + "Stop the current session's running background tasks before branching the conversation."; /** * Derives a short one-line title from the first *real* user message in the @@ -93,6 +100,17 @@ export function useBranchCommand( async (name?: string) => { if (!config) return; + if (hasBlockingBackgroundWork(config)) { + historyManager.addItem( + { + type: 'error', + text: t(BACKGROUND_WORK_BRANCH_BLOCKED_MESSAGE), + }, + Date.now(), + ); + return; + } + const oldSessionId = config.getSessionId(); const newSessionId = randomUUID(); const sessionService = config.getSessionService(); @@ -191,6 +209,7 @@ export function useBranchCommand( startNewSession(newSessionId); historyManager.clearItems(); historyManager.loadHistory(uiHistoryItems); + resetBackgroundStateForSessionSwitch(config); uiSwapped = true; // 9. Re-arm /goal under the fork's new sessionId. The branched JSONL diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ec40d8bf73f..8e2e076ecda 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -592,6 +592,9 @@ describe('useResumeCommand', () => { }), expect.any(Number), ); + expect(historyManager.loadHistory.mock.invocationCallOrder[0]).toBeLessThan( + historyManager.addItem.mock.invocationCallOrder[0]!, + ); }); it('blocks resume when the current session still has running background work', async () => { diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 9482f8776d0..56cd8732e9f 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -107,6 +107,7 @@ export function useResumeCommand( const oldSessionId = config.getSessionId(); let coreSwapped = false; let uiSwapped = false; + let recoveredBackgroundAgentsNotice: string | null = null; try { const cwd = config.getTargetDir(); @@ -179,13 +180,9 @@ export function useResumeCommand( const recovered = await config.loadPausedBackgroundAgents(sessionId); if (recovered.length > 0) { - const recoveredMessage: HistoryItemWithoutId = { - type: MessageType.INFO, - text: config - .getBackgroundAgentResumeService() - .buildRecoveredBackgroundAgentsNotice(recovered.length), - }; - addItem(recoveredMessage, Date.now()); + recoveredBackgroundAgentsNotice = config + .getBackgroundAgentResumeService() + .buildRecoveredBackgroundAgentsNotice(recovered.length); } // 2. Swap UI. Once this commits, rolling core back is unsafe — @@ -195,6 +192,15 @@ export function useResumeCommand( setSessionName?.(customTitle ?? null); clearItems(); loadHistory(uiHistoryItems); + if (recoveredBackgroundAgentsNotice) { + addItem( + { + type: MessageType.INFO, + text: recoveredBackgroundAgentsNotice, + }, + Date.now(), + ); + } uiSwapped = true; // SessionStart hook is handled during chat initialization so its @@ -209,6 +215,7 @@ export function useResumeCommand( // recorder would keep writing new user messages into the // orphaned session JSONL while UI still shows the old session. try { + resetBackgroundStateForSessionSwitch(config); config.startNewSession(oldSessionId, undefined); } catch (rollbackErr) { config diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 77506c9ede6..c1e6c822960 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -110,6 +110,14 @@ export interface AgentMeta { * `running` as resumable work that was interrupted by process exit. */ status?: 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'; + /** + * Whether the original launch ran asynchronously. Completed entries are + * restored only when this is explicitly true so legacy foreground sidecars + * are never exposed as reusable background agents. + */ + isBackgrounded?: boolean; + /** Whether the original launch used temporary worktree isolation. */ + isolation?: 'worktree'; /** ISO 8601 timestamp of the latest lifecycle transition. */ lastUpdatedAt?: string; /** Resolved approval mode used when the agent was launched. */ @@ -191,6 +199,16 @@ export function writeAgentMeta(metaPath: string, meta: AgentMeta): void { fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8'); } catch (error) { debugLogger.warn(`Failed to write agent meta sidecar ${metaPath}:`, error); + return; + } + try { + const now = new Date(); + fs.utimesSync(path.dirname(metaPath), now, now); + } catch (error) { + debugLogger.warn( + `Failed to refresh agent session directory for ${metaPath}:`, + error, + ); } } diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index be71aaaa20c..dd2ef032c78 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -130,7 +130,7 @@ describe('BackgroundAgentResumeService', () => { }; } - it('loads only interrupted running background agents as paused entries', async () => { + it('restores interrupted and completed background agents without notifying again', async () => { const sessionId = 'session-1'; const runningAgentId = 'agent-running'; const completedAgentId = 'agent-completed'; @@ -154,6 +154,7 @@ describe('BackgroundAgentResumeService', () => { parentAgentId: null, createdAt: '2026-04-20T00:00:00.000Z', status: 'running', + isBackgrounded: true, subagentName: 'researcher', resolvedApprovalMode: 'auto-edit', }); @@ -165,6 +166,8 @@ describe('BackgroundAgentResumeService', () => { parentAgentId: null, createdAt: '2026-04-20T00:00:00.000Z', status: 'completed', + isBackgrounded: true, + lastUpdatedAt: '2026-04-20T00:00:02.000Z', subagentName: 'researcher', resolvedApprovalMode: 'auto-edit', }); @@ -196,14 +199,24 @@ describe('BackgroundAgentResumeService', () => { ); fs.writeFileSync( getAgentJsonlPath(tempDir, sessionId, completedAgentId), - '', + JSON.stringify({ + uuid: 'c1', + parentUuid: null, + sessionId, + agentId: completedAgentId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Already done' }] }, + }) + '\n', 'utf8', ); const { service, subagentManager } = createService(); + const onNotification = vi.fn(); + registry.setNotificationCallback(onNotification); const recovered = await service.loadPausedBackgroundAgents(sessionId); - expect(recovered).toHaveLength(1); + expect(recovered).toHaveLength(2); expect(recovered[0]).toMatchObject({ agentId: runningAgentId, status: 'paused', @@ -213,12 +226,127 @@ describe('BackgroundAgentResumeService', () => { metaPath: runningMetaPath, outputFile: getAgentJsonlPath(tempDir, sessionId, runningAgentId), }); + expect(recovered[1]).toMatchObject({ + agentId: completedAgentId, + status: 'completed', + notified: true, + description: 'Already done', + outputFile: getAgentJsonlPath(tempDir, sessionId, completedAgentId), + }); expect(registry.get(runningAgentId)?.status).toBe('paused'); - expect(registry.get(completedAgentId)).toBeUndefined(); - expect(subagentManager.loadSubagent).toHaveBeenCalledTimes(1); + expect(registry.get(completedAgentId)?.status).toBe('completed'); + expect(onNotification).not.toHaveBeenCalled(); + expect(subagentManager.loadSubagent).toHaveBeenCalledTimes(2); expect(subagentManager.loadSubagent).toHaveBeenCalledWith('researcher'); }); + it('excludes foreground, legacy completed, and wrong-owner sidecars', async () => { + const sessionId = 'session-owned'; + const cases = [ + { + agentId: 'foreground', + isBackgrounded: false, + parentSessionId: sessionId, + }, + { agentId: 'legacy-completed', parentSessionId: sessionId }, + { + agentId: 'wrong-owner', + isBackgrounded: true, + parentSessionId: 'other', + }, + ]; + + for (const item of cases) { + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, item.agentId), { + agentId: item.agentId, + agentType: 'researcher', + description: item.agentId, + parentSessionId: item.parentSessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: item.isBackgrounded, + subagentName: 'researcher', + }); + } + + const { service, subagentManager } = createService(); + expect(await service.loadPausedBackgroundAgents(sessionId)).toEqual([]); + expect(subagentManager.loadSubagent).not.toHaveBeenCalled(); + }); + + it('keeps damaged and unsafe retained entries visible but non-continuable', async () => { + const sessionId = 'session-unsafe'; + const missingId = 'missing-transcript'; + const wrongCwdId = 'wrong-cwd'; + const worktreeId = 'worktree-agent'; + for (const agentId of [missingId, wrongCwdId, worktreeId]) { + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, agentId), { + agentId, + agentType: 'researcher', + description: agentId, + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: true, + ...(agentId === worktreeId ? { isolation: 'worktree' as const } : {}), + subagentName: 'researcher', + }); + } + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, wrongCwdId), + JSON.stringify({ + uuid: 'u1', + parentUuid: null, + sessionId, + agentId: wrongCwdId, + cwd: path.join(tempDir, 'another-workspace'), + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Unsafe cwd' }] }, + }) + '\n', + 'utf8', + ); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, worktreeId), + JSON.stringify({ + uuid: 'w1', + parentUuid: null, + sessionId, + agentId: worktreeId, + cwd: tempDir, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Worktree task' }] }, + }) + '\n', + 'utf8', + ); + + const { service } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + expect(recovered).toHaveLength(3); + expect(registry.get(missingId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task transcript is missing or unreadable.', + }); + expect(registry.get(wrongCwdId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task working directory does not match the restored session.', + }); + expect(registry.get(worktreeId)).toMatchObject({ + status: 'completed', + resumeBlockedReason: + 'Background task worktree isolation cannot be reconstructed after session restore.', + }); + expect( + await service.reviveCompletedBackgroundAgent(missingId, 'continue'), + ).toBeUndefined(); + }); + it('preserves model on recovered paused agents for per-model caps', async () => { const sessionId = 'session-model'; const agentId = 'agent-model'; diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 0cf813baa0b..0af1efd602c 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -18,11 +18,14 @@ import { import { AgentTerminateMode } from './runtime/agent-types.js'; import { AgentHeadless, ContextState } from './runtime/agent-headless.js'; import { + getAgentJsonlPath, + getAgentMetaPath, getSubagentSessionDir, normalizeResumedAgentDepth, readAgentMeta, patchAgentMeta, attachJsonlTranscriptWriter, + type AgentMeta, } from './agent-transcript.js'; import type { ChatRecord } from '../services/chatRecordingService.js'; import { buildOrderedUuidChain } from '../utils/conversation-chain.js'; @@ -43,10 +46,11 @@ import { FORK_SUBAGENT_TYPE, runInForkContext, } from '../tools/agent/fork-subagent.js'; -import type { - AgentCompletionStats, - AgentTask, - AgentTaskRegistration, +import { + MAX_RETAINED_TERMINAL_AGENTS, + type AgentCompletionStats, + type AgentTask, + type AgentTaskRegistration, } from './background-tasks.js'; import type { SubagentConfig } from '../subagents/types.js'; import { BUBBLE_APPROVAL_MODE } from '../subagents/types.js'; @@ -69,6 +73,14 @@ 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.'; +const MISSING_TRANSCRIPT_BLOCKED_REASON = + 'Background task transcript is missing or unreadable.'; +const TRANSCRIPT_IDENTITY_BLOCKED_REASON = + 'Background task transcript does not match its retained identity.'; +const WORKING_DIRECTORY_BLOCKED_REASON = + 'Background task working directory does not match the restored session.'; +const WORKTREE_ISOLATION_BLOCKED_REASON = + 'Background task worktree isolation cannot be reconstructed after session restore.'; type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo'; @@ -366,8 +378,27 @@ function getCompletionStats( 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.`; + ? 'Restored 1 background agent from this session. Open Background tasks to inspect it.' + : `Restored ${count} background agents from this session. Open Background tasks to inspect them.`; +} + +function buildRecoveredModelNotice(count: number): string { + return ( + `${count} background agent${count === 1 ? ' was' : 's were'} restored ` + + `from this session. Use list_agents to inspect ${count === 1 ? 'it' : 'them'} ` + + 'and send_message with a task_id to continue one.' + ); +} + +interface RecoverableAgentSidecar { + fileName: string; + metaPath: string; + meta: AgentMeta; +} + +function recoveryTimestamp(meta: AgentMeta): number { + const parsed = Date.parse(meta.lastUpdatedAt ?? meta.createdAt); + return Number.isFinite(parsed) ? parsed : 0; } export class BackgroundAgentResumeService { @@ -390,29 +421,107 @@ export class BackgroundAgentResumeService { throw error; } - const registry = this.config.getBackgroundTaskRegistry(); - const recovered: AgentTask[] = []; - + const sidecars: RecoverableAgentSidecar[] = []; 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 ( + !meta || + typeof meta.agentId !== 'string' || + meta.agentId.length === 0 || + meta.parentSessionId !== sessionId || + path.basename( + getAgentMetaPath(projectDir, sessionId, meta.agentId), + ) !== fileName || + (meta.status !== 'running' && meta.status !== 'completed') || + (meta.isBackgrounded !== undefined && + typeof meta.isBackgrounded !== 'boolean') || + meta.isBackgrounded === false || + (meta.status === 'completed' && meta.isBackgrounded !== true) + ) { + continue; + } + sidecars.push({ fileName, metaPath, meta }); + } catch (error) { + debugLogger.warn( + `[BackgroundAgentResume] Failed to read background agent metadata from ${metaPath}:`, + error, + ); + } + } + + sidecars.sort((a, b) => { + if (a.meta.status !== b.meta.status) { + return a.meta.status === 'running' ? -1 : 1; + } + if (a.meta.status === 'completed') { + return recoveryTimestamp(b.meta) - recoveryTimestamp(a.meta); + } + return a.fileName.localeCompare(b.fileName); + }); + + const registry = this.config.getBackgroundTaskRegistry(); + const recovered: AgentTask[] = []; + let completedCount = registry + .getAll() + .filter((entry) => entry.notified === true).length; + + for (const { metaPath, meta } of sidecars) { + if ( + meta.status === 'completed' && + completedCount >= MAX_RETAINED_TERMINAL_AGENTS + ) { + continue; + } + try { if (registry.get(meta.agentId)) continue; const subagentName = meta.subagentName ?? meta.agentType; - if (!subagentName) continue; + if (typeof subagentName !== 'string' || !subagentName) continue; const target = await this.resolveResumeTarget(subagentName); - const outputFile = path.join( - dir, - fileName.slice(0, -META_FILE_SUFFIX.length) + '.jsonl', + const outputFile = getAgentJsonlPath( + projectDir, + sessionId, + meta.agentId, ); const records = await jsonl.read(outputFile); const recovery = recoverTranscript(records); const parsedStartTime = Date.parse(meta.createdAt); + const parsedEndTime = Date.parse(meta.lastUpdatedAt ?? meta.createdAt); + const projectRoot = path.resolve(this.config.getProjectRoot()); + let retainedStateBlockedReason: string | undefined; + if (records.length === 0) { + retainedStateBlockedReason = MISSING_TRANSCRIPT_BLOCKED_REASON; + } else if ( + records.some( + (record) => + record.sessionId !== sessionId || + (record.agentId !== undefined && record.agentId !== meta.agentId), + ) + ) { + retainedStateBlockedReason = TRANSCRIPT_IDENTITY_BLOCKED_REASON; + } else if ( + meta.isolation !== undefined && + meta.isolation !== 'worktree' + ) { + retainedStateBlockedReason = + 'Background task isolation metadata is incompatible.'; + } else if (meta.isolation === 'worktree') { + retainedStateBlockedReason = WORKTREE_ISOLATION_BLOCKED_REASON; + } else if ( + records.some( + (record) => + typeof record.cwd === 'string' && + path.resolve(record.cwd) !== projectRoot, + ) + ) { + retainedStateBlockedReason = WORKING_DIRECTORY_BLOCKED_REASON; + } const resumeBlockedReason = + retainedStateBlockedReason || target.unavailableReason || (target.isFork && !recovery.forkBootstrap ? LEGACY_FORK_RESUME_BLOCKED_REASON @@ -427,10 +536,17 @@ export class BackgroundAgentResumeService { description: meta.description, subagentType: target.agentName, isBackgrounded: true, - status: 'paused', + status: meta.status === 'running' ? 'paused' : 'completed', startTime: Number.isFinite(parsedStartTime) ? parsedStartTime : Date.now(), + ...(meta.status === 'completed' + ? { + endTime: Number.isFinite(parsedEndTime) + ? parsedEndTime + : Date.now(), + } + : {}), abortController: new AbortController(), prompt: recovery.initialPrompt, outputFile, @@ -444,9 +560,19 @@ export class BackgroundAgentResumeService { // UI falls back to its generic orphan annotation. parentAgentId: meta.parentAgentId, depth: meta.depth, - model: meta.model, + model: meta.model ?? meta.persistedCliFlags?.model, }; - const entry = registry.register(registration); + if (meta.status === 'completed') { + (registration as AgentTask).notified = true; + (registration as AgentTask).outputOffset = 0; + } + const entry = registry.register(registration, { + suppressRegisterCallback: meta.status === 'completed', + preserveNotificationState: meta.status === 'completed', + }); + if (meta.status === 'completed') { + completedCount += 1; + } recovered.push(entry); } catch (error) { debugLogger.warn( @@ -498,8 +624,8 @@ export class BackgroundAgentResumeService { * * Returns `undefined` (and logs why) when the agent can't be revived: not an * in-registry, finished background agent with a persisted transcript, or the - * background-agent concurrency cap is full. Cross-session / evicted completed - * agents are out of scope (see QwenLM/qwen-code#5540). + * background-agent concurrency cap is full. Completed agents restored from + * the same parent session use this path after process restart. */ async reviveCompletedBackgroundAgent( agentId: string, @@ -518,7 +644,8 @@ export class BackgroundAgentResumeService { !entry.isBackgrounded || entry.status !== 'completed' || !entry.metaPath || - !entry.outputFile + !entry.outputFile || + entry.resumeBlockedReason ) { debugLogger.warn( `[BackgroundAgentResume] Cannot revive "${agentId}": not a completed ` + @@ -597,7 +724,22 @@ export class BackgroundAgentResumeService { this.restorePausedEntry(agentId, { suppressRegisterCallback: true }); const revived = await this.resumeBackgroundAgent(agentId, initialMessage); if (!revived) { - this.restoreCompletedEntry(completedEntry); + const failedEntry = registry.get(agentId); + this.restoreCompletedEntry({ + ...completedEntry, + resumeBlockedReason: + failedEntry?.resumeBlockedReason ?? + completedEntry.resumeBlockedReason, + pendingMessages: [ + ...(failedEntry?.pendingMessages ?? completedEntry.pendingMessages), + ], + recentActivities: [ + ...(failedEntry?.recentActivities ?? completedEntry.recentActivities), + ], + pendingApprovals: [ + ...(failedEntry?.pendingApprovals ?? completedEntry.pendingApprovals), + ], + }); } return revived; } @@ -611,6 +753,9 @@ export class BackgroundAgentResumeService { if (!existing || existing.status !== 'paused') { return existing; } + if (existing.resumeBlockedReason) { + return undefined; + } const metaPath = existing.metaPath; const outputFile = existing.outputFile; @@ -1087,6 +1232,10 @@ export class BackgroundAgentResumeService { return buildRecoveredNotice(count); } + buildRecoveredBackgroundAgentsModelNotice(count: number): string { + return buildRecoveredModelNotice(count); + } + private async resolveResumeTarget( subagentName: string, ): Promise { diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 7df30758df9..651b9d51f74 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -142,6 +142,7 @@ export const EXCLUDED_TOOLS_FOR_SUBAGENTS: ReadonlySet = new Set([ ToolNames.CRON_CREATE, ToolNames.CRON_LIST, ToolNames.CRON_DELETE, + ToolNames.LIST_AGENTS, ToolNames.TASK_STOP, ToolNames.SEND_MESSAGE, ToolNames.TEAM_CREATE, @@ -175,6 +176,7 @@ const EXCLUDED_TOOLS_FOR_TEAMMATES: ReadonlySet = new Set([ ToolNames.CRON_CREATE, ToolNames.CRON_LIST, ToolNames.CRON_DELETE, + ToolNames.LIST_AGENTS, ToolNames.TASK_STOP, ToolNames.TEAM_CREATE, ToolNames.TEAM_DELETE, diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1bcc0458d92..4ecb84751d1 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6745,6 +6745,16 @@ describe('setApprovalMode with folder trust', () => { vi.clearAllMocks(); }); + it('registers the background-agent roster tool', async () => { + const config = new Config(baseParams); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerFactory as Mock).mock.calls; + expect(calls.some((call) => call[0] === ToolNames.LIST_AGENTS)).toBe( + true, + ); + }); + it('should register grep tool when useRipgrep is true and it is available', async () => { (canUseRipgrep as Mock).mockResolvedValue(true); const config = new Config({ ...baseParams, useRipgrep: true }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 09af8d59962..20ceb7469ef 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1605,6 +1605,7 @@ export class Config { * process exit (which dies with the process — no leak). */ private pendingStartupWorktreeNotice: string | null = null; + private pendingRecoveredAgentsNotice: string | null = null; private debugLogger: DebugLogger; private toolRegistry!: ToolRegistry; /** @@ -3407,6 +3408,7 @@ export class Config { process.env['QWEN_CODE_SESSION_ID'] = this.sessionId; } this.sessionData = sessionData; + this.pendingRecoveredAgentsNotice = null; setDebugLogSession(this); this.debugLogger = createDebugLogger(); this.chatRecordingService = this.chatRecordingEnabled @@ -6482,9 +6484,36 @@ export class Config { async loadPausedBackgroundAgents( sessionId: string = this.getSessionId(), ): Promise> { - return this.getBackgroundAgentResumeService().loadPausedBackgroundAgents( - sessionId, - ); + if (sessionId !== this.getSessionId()) { + this.debugLogger.warn( + `Refusing to restore background agents for non-current session ${sessionId}.`, + ); + return []; + } + const service = this.getBackgroundAgentResumeService(); + let recovered: ReadonlyArray< + import('../agents/background-tasks.js').AgentTask + >; + try { + recovered = await service.loadPausedBackgroundAgents(sessionId); + } catch (error) { + this.debugLogger.warn( + `Background agent restore failed for session ${sessionId}; continuing without restored agents.`, + error, + ); + return []; + } + if (recovered.length > 0 && !this.getBareMode()) { + this.pendingRecoveredAgentsNotice = + service.buildRecoveredBackgroundAgentsModelNotice(recovered.length); + } + return recovered; + } + + consumePendingRecoveredAgentsNotice(): string | null { + const notice = this.pendingRecoveredAgentsNotice; + this.pendingRecoveredAgentsNotice = null; + return notice; } async resumeBackgroundAgent( @@ -6773,6 +6802,10 @@ export class Config { const { AgentTool } = await import('../tools/agent/agent.js'); return new AgentTool(this); }); + await registerLazy(ToolNames.LIST_AGENTS, async () => { + const { ListAgentsTool } = await import('../tools/list-agents.js'); + return new ListAgentsTool(this); + }); await registerLazy(ToolNames.TASK_STOP, async () => { const { TaskStopTool } = await import('../tools/task-stop.js'); return new TaskStopTool(this); diff --git a/packages/core/src/tools/agent/agent.test.ts b/packages/core/src/tools/agent/agent.test.ts index 8873efcd802..2474f5daecc 100644 --- a/packages/core/src/tools/agent/agent.test.ts +++ b/packages/core/src/tools/agent/agent.test.ts @@ -368,8 +368,9 @@ describe('AgentTool', () => { 'Reuse an existing background agent for related follow-up work', ); expect(tool.description).toContain( - 'send_message with the `agentId` from its launch result as its `task_id`', + 'list_agents to inspect the current roster', ); + expect(tool.description).toContain('send_message with its `task_id`'); expect(tool.description).toContain('next tool-round boundary'); expect(tool.description).toContain( 'paused agents resume with it as their first continuation instruction', diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index 00aec2d42f6..e9110ef82b4 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -894,7 +894,7 @@ Usage notes: - Run agents concurrently only when their tasks are independent. For code changes, give concurrent agents disjoint write scopes; launch them in a single message with multiple tool uses. - A background agent reports its result through a completion notification in a later turn. A foreground agent returns its result inline. Agent results are not visible to the user, so relay the relevant outcome in your response. - While background agents run, continue meaningful non-overlapping work. Wait for an agent only when its result blocks the next required step. -- Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.SEND_MESSAGE} with the \`agentId\` from its launch result as its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents are revived from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent. +- Reuse an existing background agent for related follow-up work instead of launching a duplicate: call ${ToolNames.LIST_AGENTS} to inspect the current roster, then call ${ToolNames.SEND_MESSAGE} with its \`task_id\`. Running agents receive the message at the next tool-round boundary; paused agents resume with it as their first continuation instruction; completed agents are revived from their retained transcript. If the task is no longer retained or cannot be resumed or revived, launch a new agent. - Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need. - Regular subagents and named teammates start without parent conversation history. Only fork agents accept \`fork_turns\`; omit it for the full conversation or use a positive integer string such as \`"3"\` for a bounded recent window. - Treat the agent's output as evidence, not as automatically correct. Verify factual claims, review code changes, and run relevant checks before integrating or relaying the result. @@ -3031,6 +3031,8 @@ class AgentToolInvocation extends BaseToolInvocation { parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), status: 'running', + isBackgrounded: true, + isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, persistedCliFlags: capturePersistedCliFlags( @@ -3346,7 +3348,7 @@ class AgentToolInvocation extends BaseToolInvocation { return { llmContent: `Background agent launched successfully.\n` + - `agentId: ${hookOpts.agentId} (internal ID — do not mention to the user. Use ${ToolNames.SEND_MESSAGE} to continue this agent, or ${ToolNames.TASK_STOP} to cancel.)\n` + + `task_id: ${hookOpts.agentId} (internal ID — do not mention to the user. Use ${ToolNames.SEND_MESSAGE} to continue this agent, or ${ToolNames.TASK_STOP} to cancel.)\n` + `The agent is working in the background. You will be notified automatically when it completes.\n` + `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\n` + `output_file: ${jsonlPath}\n` + @@ -3602,6 +3604,8 @@ class AgentToolInvocation extends BaseToolInvocation { parentAgentId: getCurrentAgentId(), createdAt: new Date().toISOString(), status: 'running', + isBackgrounded: false, + isolation: this.params.isolation, lastUpdatedAt: new Date().toISOString(), resolvedApprovalMode, persistedCliFlags: capturePersistedCliFlags( diff --git a/packages/core/src/tools/list-agents.test.ts b/packages/core/src/tools/list-agents.test.ts new file mode 100644 index 00000000000..7ca39d0e21c --- /dev/null +++ b/packages/core/src/tools/list-agents.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Config } from '../config/config.js'; +import { BackgroundTaskRegistry } from '../agents/background-tasks.js'; +import { ListAgentsTool } from './list-agents.js'; + +describe('ListAgentsTool', () => { + let registry: BackgroundTaskRegistry; + let tool: ListAgentsTool; + + beforeEach(() => { + registry = new BackgroundTaskRegistry(); + tool = new ListAgentsTool({ + getBackgroundTaskRegistry: () => registry, + } as unknown as Config); + }); + + it('reports an empty roster', async () => { + const result = await tool.validateBuildAndExecute( + {}, + new AbortController().signal, + ); + + expect(tool.name).toBe('list_agents'); + expect(result.llmContent).toBe( + 'No background agents are available in this session.', + ); + }); + + it('lists only background agents with stable continuation fields', async () => { + registry.register({ + agentId: 'agent-running', + subagentType: 'explore', + description: 'Inspect runtime', + isBackgrounded: true, + status: 'running', + startTime: 1, + abortController: new AbortController(), + outputFile: '/tmp/agent-running.jsonl', + }); + registry.register({ + agentId: 'agent-foreground', + description: 'Inline work', + isBackgrounded: false, + status: 'running', + startTime: 2, + abortController: new AbortController(), + outputFile: '/tmp/agent-foreground.jsonl', + }); + registry.register({ + agentId: 'agent-blocked', + description: 'Unsafe restore', + isBackgrounded: true, + status: 'completed', + startTime: 3, + endTime: 4, + abortController: new AbortController(), + outputFile: '/tmp/agent-blocked.jsonl', + resumeBlockedReason: 'Transcript does not match.', + }); + + const result = await tool.validateBuildAndExecute( + {}, + new AbortController().signal, + ); + + expect(JSON.parse(String(result.llmContent))).toEqual({ + agents: [ + { + task_id: 'agent-running', + subagent_type: 'explore', + description: 'Inspect runtime', + status: 'running', + can_message: true, + }, + { + task_id: 'agent-blocked', + description: 'Unsafe restore', + status: 'completed', + can_message: false, + resume_blocked_reason: 'Transcript does not match.', + }, + ], + }); + }); +}); diff --git a/packages/core/src/tools/list-agents.ts b/packages/core/src/tools/list-agents.ts new file mode 100644 index 00000000000..0613a2a456d --- /dev/null +++ b/packages/core/src/tools/list-agents.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + type ToolInvocation, + type ToolResult, +} from './tools.js'; + +export type ListAgentsParams = Record; + +class ListAgentsInvocation extends BaseToolInvocation< + ListAgentsParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: ListAgentsParams, + ) { + super(params); + } + + getDescription(): string { + return 'List background agents'; + } + + async execute(): Promise { + const agents = this.config + .getBackgroundTaskRegistry() + .getAll() + .filter((entry) => entry.isBackgrounded) + .map((entry) => ({ + task_id: entry.agentId, + ...(entry.subagentType ? { subagent_type: entry.subagentType } : {}), + description: entry.description, + status: entry.status, + can_message: + !entry.resumeBlockedReason && + (entry.status === 'running' || + entry.status === 'paused' || + entry.status === 'completed'), + ...(entry.resumeBlockedReason + ? { resume_blocked_reason: entry.resumeBlockedReason } + : {}), + })); + + if (agents.length === 0) { + const message = 'No background agents are available in this session.'; + return { llmContent: message, returnDisplay: message }; + } + + return { + llmContent: JSON.stringify({ agents }), + returnDisplay: `Listed ${agents.length} background agent${ + agents.length === 1 ? '' : 's' + }.`, + }; + } +} + +export class ListAgentsTool extends BaseDeclarativeTool< + ListAgentsParams, + ToolResult +> { + static readonly Name = ToolNames.LIST_AGENTS; + + constructor(private readonly config: Config) { + super( + ListAgentsTool.Name, + ToolDisplayNames.LIST_AGENTS, + 'List addressable background agents in the current session, including ' + + 'agents restored from a prior session run. Use the returned task_id ' + + 'with send_message to continue a running, paused, or completed agent.', + Kind.Read, + { + type: 'object', + properties: {}, + additionalProperties: false, + }, + ); + } + + protected createInvocation( + params: ListAgentsParams, + ): ToolInvocation { + return new ListAgentsInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/send-message.test.ts b/packages/core/src/tools/send-message.test.ts index 822676beaa2..f77f2e2afac 100644 --- a/packages/core/src/tools/send-message.test.ts +++ b/packages/core/src/tools/send-message.test.ts @@ -390,6 +390,32 @@ describe('SendMessageTool — background-task mode', () => { expect(result.llmContent).toContain('could not be revived'); }); + it('reports the retained-state reason without attempting continuation', async () => { + registry.register({ + agentId: 'agent-1', + description: 'unsafe restored agent', + status: 'completed', + startTime: Date.now(), + abortController: new AbortController(), + isBackgrounded: true, + outputFile: '/tmp/test.jsonl', + metaPath: '/tmp/test.meta.json', + resumeBlockedReason: 'Background task transcript is missing.', + }); + + const result = await tool.validateBuildAndExecute( + { task_id: 'agent-1', message: 'try again' }, + new AbortController().signal, + ); + + expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_RUNNING); + expect(result.llmContent).toContain( + 'Background task transcript is missing.', + ); + expect(reviveCompletedBackgroundAgent).not.toHaveBeenCalled(); + expect(resumeBackgroundAgent).not.toHaveBeenCalled(); + }); + 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 37ad925e4ab..68176229f15 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -108,6 +108,17 @@ class SendMessageInvocation extends BaseToolInvocation< }; } + if (entry.resumeBlockedReason) { + return { + llmContent: `Error: Background task "${this.params.task_id}" cannot be continued: ${entry.resumeBlockedReason}`, + returnDisplay: 'Task cannot be continued.', + error: { + message: `Task cannot be continued: ${this.params.task_id}`, + type: ToolErrorType.SEND_MESSAGE_NOT_RUNNING, + }, + }; + } + if (entry.status === 'paused') { const resumed = await this.config.resumeBackgroundAgent( this.params.task_id, @@ -256,7 +267,7 @@ export class SendMessageTool extends BaseDeclarativeTool< ToolDisplayNames.SEND_MESSAGE, 'Send a message to a teammate (use "to") or to a running background task (use "task_id"). ' + 'For teams, set "to" to a bare teammate name (no @) or "*" to broadcast. ' + - 'For background tasks, set "task_id" to the id from the launch response, a recovered paused task, or a completed task to revive. ' + + 'For background tasks, set "task_id" to the id from the launch response or list_agents. ' + 'Running tasks receive it at the next tool-round boundary; paused recovered tasks are resumed with the message as their first continuation instruction; a completed task is revived from its transcript and continued with your message. ' + 'Your text output is NOT visible to other agents — use this tool to communicate.', Kind.Other, diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 0329560aec1..21668ed38aa 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -40,6 +40,7 @@ export const ToolNames = { CRON_DELETE: 'cron_delete', LOOP_WAKEUP: 'loop_wakeup', CREATE_SUB_SESSION: 'create_sub_session', + LIST_AGENTS: 'list_agents', TASK_STOP: 'task_stop', TASK_CREATE: 'task_create', TASK_UPDATE: 'task_update', @@ -94,6 +95,7 @@ export const ToolDisplayNames = { CRON_DELETE: 'CronDelete', LOOP_WAKEUP: 'LoopWakeup', CREATE_SUB_SESSION: 'CreateSubSession', + LIST_AGENTS: 'ListAgents', TASK_STOP: 'TaskStop', TASK_CREATE: 'TaskCreate', TASK_UPDATE: 'TaskUpdate', From 7b9efe50b057e13b000e16585b4d654115610748 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:26:05 +0000 Subject: [PATCH 2/8] fix(web-shell): add list_agents to TOOL_DISPLAY_NAMES The new list_agents core wire tool was added to core's ToolNames but not to the web-shell TOOL_DISPLAY_NAMES map, causing toolFormatting.drift.test.ts to fail (expected ['list_agents'] to deeply equal []). Add the missing 'ListAgents' display-name entry so the browser panel shows a friendly name instead of the raw wire name and the drift guard passes. --- packages/web-shell/client/components/messages/toolFormatting.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index 49b1fb2ade4..c38cc6408f1 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -34,6 +34,7 @@ export const TOOL_DISPLAY_NAMES: Record = { loop_wakeup: 'LoopWakeup', create_sub_session: 'CreateSubSession', task_stop: 'TaskStop', + list_agents: 'ListAgents', send_message: 'SendMessage', structured_output: 'StructuredOutput', monitor: 'Monitor', From b4393dcc9a2b9774b5c5d81b2ed123062785ec4e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:26:14 +0000 Subject: [PATCH 3/8] fix(cli): reload old-session background agents on failed resume rollback When /resume fails after core has swapped but before the UI swap, the catch block rolls core back to the old session via startNewSession(oldSessionId). However the forward path already called resetBackgroundStateForSessionSwitch, which cleared the old session's in-memory background agents. The rollback did not reload them, so list_agents returned empty for the old session (whose sidecars are still on disk) until the next process start or successful resume. Reload the old session's paused background agents after rolling core back, so the restored roster matches on-disk state. Placed after startNewSession so the loadPausedBackgroundAgents current-session guard is satisfied; best-effort via .catch so it never blocks the rollback path. --- packages/cli/src/ui/hooks/useResumeCommand.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 56cd8732e9f..208cf0a8d53 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -217,6 +217,18 @@ export function useResumeCommand( try { resetBackgroundStateForSessionSwitch(config); config.startNewSession(oldSessionId, undefined); + // The forward path cleared the old session's in-memory + // background agents (resetBackgroundStateForSessionSwitch above, + // ~L158) before swapping core. After rolling core back to the old + // session, reload them so `list_agents` reflects the old session's + // still-on-disk sidecars again; otherwise the user lands back on + // the old session with an empty roster until the next process + // start or successful /resume. Best-effort — the guard inside + // loadPausedBackgroundAgents requires the session to already be + // current, which the startNewSession above satisfies. + await config + .loadPausedBackgroundAgents(oldSessionId) + .catch(() => {}); } catch (rollbackErr) { config .getDebugLogger() From ba4bf5560a08747c5f3aea8d9b095a22b7e65e84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:19:09 +0000 Subject: [PATCH 4/8] fix(web-shell): add zh translation for list_agents tool name The toolFormatting test 'has a zh translation for every tool in the display-name map' failed with expected ['list_agents'] to deeply equal [] because list_agents was added to TOOL_DISPLAY_NAMES without a matching toolName.list_agents zh-CN entry. Add the translation to restore parity. --- packages/web-shell/client/i18n.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index d71388007ff..b0260406588 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2255,6 +2255,7 @@ const ZH: Messages = { 'toolName.team_create': '创建团队', 'toolName.team_delete': '删除团队', 'toolName.send_message': '发送消息', + 'toolName.list_agents': '列出 Agent', 'toolName.structured_output': '结构化输出', 'toolName.monitor': '监控', 'toolName.notebook_edit': '编辑 Notebook', From 7752b4f8d4ea41b795805da97ba3d4ee6a17a8a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 20:23:43 +0000 Subject: [PATCH 5/8] fix(cli): resolve CI failures for background-agent roster restore - Add toolDisplayName.ListAgents translations (en, zh, zh-TW, ca) so the new list_agents tool has a zh entry; fixes i18n/index.test.ts. - Add loadPausedBackgroundAgents and consumePendingRecoveredAgentsNotice to the acpAgent worktree test config mock, which loadSession now calls via #restoreBackgroundAgentsOnResume; fixes acpAgent.worktree.test.ts. --- packages/cli/src/acp-integration/acpAgent.worktree.test.ts | 2 ++ packages/cli/src/i18n/locales/ca.js | 1 + packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh-TW.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + 5 files changed, 6 insertions(+) diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 61b7e0e0d1b..cf90c1e0ec8 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -331,6 +331,8 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => { getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), getResumedSessionData: vi.fn().mockReturnValue(undefined), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue(undefined), + consumePendingRecoveredAgentsNotice: vi.fn().mockReturnValue(null), getSessionService: vi.fn().mockReturnValue(mockSessionService), getWorkspaceContext: vi.fn().mockReturnValue({ getDirectories: vi.fn().mockReturnValue([]), diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 9ca3bd1dd55..d20408653cf 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2381,6 +2381,7 @@ export default { 'toolDisplayName.CronDelete': 'Suprimeix tasca programada', 'toolDisplayName.LoopWakeup': 'Desperta el bucle', 'toolDisplayName.CreateSubSession': 'Crea subsessió', + 'toolDisplayName.ListAgents': "Llista d'agents", 'toolDisplayName.TaskCreate': 'Crea tasca', 'toolDisplayName.TaskUpdate': 'Actualitza tasca', 'toolDisplayName.TaskList': 'Llista tasques', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 14675a5571d..b8de4211b25 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -202,6 +202,7 @@ export default { 'toolDisplayName.CronDelete': 'toolDisplayName.CronDelete', 'toolDisplayName.LoopWakeup': 'toolDisplayName.LoopWakeup', 'toolDisplayName.CreateSubSession': 'toolDisplayName.CreateSubSession', + 'toolDisplayName.ListAgents': 'toolDisplayName.ListAgents', 'toolDisplayName.TaskCreate': 'toolDisplayName.TaskCreate', 'toolDisplayName.TaskUpdate': 'toolDisplayName.TaskUpdate', 'toolDisplayName.TaskList': 'toolDisplayName.TaskList', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index d306d30fc5b..833ba86d9d0 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -193,6 +193,7 @@ export default { 'toolDisplayName.CronDelete': '刪除定時任務', 'toolDisplayName.LoopWakeup': '循環喚醒', 'toolDisplayName.CreateSubSession': '建立子會話', + 'toolDisplayName.ListAgents': '列出 Agent', 'toolDisplayName.TaskCreate': '建立任務', 'toolDisplayName.TaskUpdate': '更新任務', 'toolDisplayName.TaskList': '任務列表', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 7753bbd85e2..eda2de7c9cd 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -194,6 +194,7 @@ export default { 'toolDisplayName.CronDelete': '删除定时任务', 'toolDisplayName.LoopWakeup': '循环唤醒', 'toolDisplayName.CreateSubSession': '创建子会话', + 'toolDisplayName.ListAgents': '列出 Agent', 'toolDisplayName.TaskCreate': '创建任务', 'toolDisplayName.TaskUpdate': '更新任务', 'toolDisplayName.TaskList': '任务列表', From 473f2e2dad39e9692cb4305b5aef43b32bb889f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 22:18:23 +0000 Subject: [PATCH 6/8] refactor(core): extract incompatible-isolation blocked reason to a const Move the incompatible-isolation blocked-reason string out of an inline literal into a module-level INCOMPATIBLE_ISOLATION_BLOCKED_REASON const, matching its four sibling reasons so the text is discoverable by constant-name grep and edited alongside the others. --- packages/core/src/agents/background-agent-resume.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 0af1efd602c..0775a031e1f 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -81,6 +81,8 @@ const WORKING_DIRECTORY_BLOCKED_REASON = 'Background task working directory does not match the restored session.'; const WORKTREE_ISOLATION_BLOCKED_REASON = 'Background task worktree isolation cannot be reconstructed after session restore.'; +const INCOMPATIBLE_ISOLATION_BLOCKED_REASON = + 'Background task isolation metadata is incompatible.'; type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'auto' | 'yolo'; @@ -506,8 +508,7 @@ export class BackgroundAgentResumeService { meta.isolation !== undefined && meta.isolation !== 'worktree' ) { - retainedStateBlockedReason = - 'Background task isolation metadata is incompatible.'; + retainedStateBlockedReason = INCOMPATIBLE_ISOLATION_BLOCKED_REASON; } else if (meta.isolation === 'worktree') { retainedStateBlockedReason = WORKTREE_ISOLATION_BLOCKED_REASON; } else if ( From ddeb0e2e66b7505d86da53fbabfa65013bf93d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 00:26:23 +0000 Subject: [PATCH 7/8] fix(core): preserve retained activity state on failed agent revive Address review feedback on the background-agent roster restore: - On a failed completed-agent revive, restore UI state with a non-empty guard instead of `??`. Because `restorePausedEntry` resets the paused entry's `recentActivities` to `[]`, the previous `failedEntry?.field ?? completedEntry.field` kept that empty array and dropped the pre-revive snapshot (the UI Progress section rendered empty). Applied consistently to pendingMessages, recentActivities, and pendingApprovals. Add regression coverage for previously untested paths: - failed revive preserves pre-revive recentActivities - terminal-agent cap admits only the newest MAX_RETAINED_TERMINAL_AGENTS completed sidecars on restore - /resume rollback reloads the old session's background agents - headless resume prepends the recovered-agents notice to the prompt --- packages/cli/src/nonInteractiveCli.test.ts | 39 ++++++ .../cli/src/ui/hooks/useResumeCommand.test.ts | 8 ++ .../agents/background-agent-resume.test.ts | 126 +++++++++++++++++- .../src/agents/background-agent-resume.ts | 30 +++-- 4 files changed, 193 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 8fddef7ea4c..fe2fc22e766 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -458,6 +458,45 @@ describe('runNonInteractive', () => { expect(mockShutdownTelemetry).toHaveBeenCalled(); }); + it('prepends the recovered background agents notice on a resumed headless prompt', async () => { + setupMetricsMock(); + // A resumed session with a one-shot recovered-agents notice pending. + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({} as never); + vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( + 'Restored 2 background agents from the previous session.', + ); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Continue the work', + 'prompt-resume-notice', + ); + + expect(mockConfig.consumePendingRecoveredAgentsNotice).toHaveBeenCalled(); + const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + // The notice is prepended as a system-reminder ahead of the user prompt. + expect(request).toEqual([ + { + text: expect.stringContaining( + 'Restored 2 background agents from the previous session.', + ), + }, + { text: 'Continue the work' }, + ]); + expect((request as Array<{ text: string }>)[0].text).toContain( + SYSTEM_REMINDER_OPEN, + ); + }); + it('does not let headless YOLO bypass explicit teammate approval', async () => { setupMetricsMock(); const teamEvents = new EventEmitter(); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 8e2e076ecda..2e2483acc2f 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -815,5 +815,13 @@ describe('useResumeCommand', () => { }), expect.any(Number), ); + // The rollback reloads the old session's still-on-disk background agents + // so `list_agents` is not left empty after core is restored. The forward + // path never reached its own load (initialize threw first), so this call + // is the rollback reload, scoped to the old session. + expect(config.loadPausedBackgroundAgents).toHaveBeenCalledTimes(1); + expect(config.loadPausedBackgroundAgents).toHaveBeenCalledWith( + 'old-session-id', + ); }); }); diff --git a/packages/core/src/agents/background-agent-resume.test.ts b/packages/core/src/agents/background-agent-resume.test.ts index dd2ef032c78..d198000b891 100644 --- a/packages/core/src/agents/background-agent-resume.test.ts +++ b/packages/core/src/agents/background-agent-resume.test.ts @@ -9,7 +9,10 @@ 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 { + BackgroundTaskRegistry, + MAX_RETAINED_TERMINAL_AGENTS, +} from './background-tasks.js'; import { BackgroundAgentResumeService } from './background-agent-resume.js'; import { getAgentJsonlPath, @@ -2962,6 +2965,127 @@ describe('BackgroundAgentResumeService', () => { expect(registry.get(agentId)?.status).toBe('completed'); expect(subagentManager.createAgentHeadless).not.toHaveBeenCalled(); }); + + it('preserves pre-revive activity state when a completed revive fails', async () => { + const sessionId = 'session-revive-rollback-state'; + const agentId = 'agent-revive-rollback-state'; + const metaPath = getAgentMetaPath(tempDir, sessionId, agentId); + const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId); + + writeAgentMeta(metaPath, { + agentId, + agentType: 'researcher', + description: 'Finished research', + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + 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: 'Finished research' }] }, + }) + '\n', + 'utf8', + ); + + registry.register({ + agentId, + description: 'Finished research', + subagentType: 'researcher', + isBackgrounded: true, + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + outputFile, + metaPath, + }); + registry.complete(agentId, 'All done'); + // Populate the pre-revive UI state that must survive a failed revive. + const activities = [ + { name: 'Read', description: 'read src/index.ts', at: 1 }, + { name: 'Bash', description: 'npm test', at: 2 }, + ]; + registry.get(agentId)!.recentActivities = activities; + + const { service, subagentManager } = createService(); + // Force the revive to fail after the entry has been transitioned to paused + // (which resets `recentActivities` to []), exercising the rollback path. + subagentManager.createAgentHeadless.mockRejectedValue( + new Error('setup failed'), + ); + + await expect( + service.reviveCompletedBackgroundAgent(agentId, 'keep going'), + ).resolves.toBeUndefined(); + + const restored = registry.get(agentId); + expect(restored?.status).toBe('completed'); + // Regression guard: a `??` fallback would keep the paused entry's empty + // `recentActivities`, silently dropping the retained activities. The + // completed snapshot must be restored instead. + expect(restored?.recentActivities).toEqual(activities); + }); + + it('does not restore more completed agents than the terminal-agent cap', async () => { + const sessionId = 'session-terminal-cap'; + const extra = 3; + const total = MAX_RETAINED_TERMINAL_AGENTS + extra; + const ids: string[] = []; + for (let i = 0; i < total; i++) { + const agentId = `cap-agent-${String(i).padStart(3, '0')}`; + ids.push(agentId); + // Higher index → newer recovery timestamp, so the newest `cap` entries + // (by `lastUpdatedAt`) are the ones that must survive the cap. + const lastUpdatedAt = `2026-04-20T00:00:${String(i).padStart(2, '0')}.000Z`; + writeAgentMeta(getAgentMetaPath(tempDir, sessionId, agentId), { + agentId, + agentType: 'researcher', + description: agentId, + parentSessionId: sessionId, + parentAgentId: null, + createdAt: '2026-04-20T00:00:00.000Z', + status: 'completed', + isBackgrounded: true, + lastUpdatedAt, + subagentName: 'researcher', + }); + fs.writeFileSync( + getAgentJsonlPath(tempDir, sessionId, agentId), + JSON.stringify({ + uuid: `u-${agentId}`, + parentUuid: null, + sessionId, + timestamp: '2026-04-20T00:00:00.000Z', + type: 'user', + message: { role: 'user', parts: [{ text: agentId }] }, + }) + '\n', + 'utf8', + ); + } + + const { service } = createService(); + const recovered = await service.loadPausedBackgroundAgents(sessionId); + + // Only the cap's worth of completed agents are admitted... + expect(recovered).toHaveLength(MAX_RETAINED_TERMINAL_AGENTS); + expect(registry.getAll()).toHaveLength(MAX_RETAINED_TERMINAL_AGENTS); + // ...and they are the most recent by recovery timestamp; the oldest + // `extra` sidecars are dropped rather than admitted over the cap. + for (const id of ids.slice(extra)) { + expect(registry.get(id)?.status).toBe('completed'); + } + for (const id of ids.slice(0, extra)) { + expect(registry.get(id)).toBeUndefined(); + } + }); }); function readMetaStatus(metaPath: string): string | undefined { diff --git a/packages/core/src/agents/background-agent-resume.ts b/packages/core/src/agents/background-agent-resume.ts index 0775a031e1f..e455290042c 100644 --- a/packages/core/src/agents/background-agent-resume.ts +++ b/packages/core/src/agents/background-agent-resume.ts @@ -726,20 +726,32 @@ export class BackgroundAgentResumeService { const revived = await this.resumeBackgroundAgent(agentId, initialMessage); if (!revived) { const failedEntry = registry.get(agentId); + // `??` only falls back on null/undefined, so a failed revive that left + // the entry with an *empty* array would clobber the pre-revive snapshot + // (e.g. the UI Progress section would render empty instead of the + // retained activities). Prefer the failed entry's state only when it + // actually carries items; otherwise fall back to the completed snapshot. + const preferNonEmpty = ( + next: readonly T[] | undefined, + fallback: readonly T[], + ): T[] => (next && next.length > 0 ? [...next] : [...fallback]); this.restoreCompletedEntry({ ...completedEntry, resumeBlockedReason: failedEntry?.resumeBlockedReason ?? completedEntry.resumeBlockedReason, - pendingMessages: [ - ...(failedEntry?.pendingMessages ?? completedEntry.pendingMessages), - ], - recentActivities: [ - ...(failedEntry?.recentActivities ?? completedEntry.recentActivities), - ], - pendingApprovals: [ - ...(failedEntry?.pendingApprovals ?? completedEntry.pendingApprovals), - ], + pendingMessages: preferNonEmpty( + failedEntry?.pendingMessages, + completedEntry.pendingMessages, + ), + recentActivities: preferNonEmpty( + failedEntry?.recentActivities, + completedEntry.recentActivities, + ), + pendingApprovals: preferNonEmpty( + failedEntry?.pendingApprovals, + completedEntry.pendingApprovals, + ), }); } return revived; From 0821d92ecceba06998a37a60cf06985589fec7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 09:20:00 +0000 Subject: [PATCH 8/8] test(cli): cover interrupted-turn continuation not consuming recovered-agents notice Add ACP and headless regression tests asserting an interrupted-turn continuation does not consume the one-shot recovered-agents notice (the !isContinue / !continueInterrupted guards), so it is delivered on the user's next ordinary prompt. Mirrors the existing slash-command coverage. --- .../session/Session.worktree.test.ts | 45 +++++++++++++++++ packages/cli/src/nonInteractiveCli.test.ts | 48 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/packages/cli/src/acp-integration/session/Session.worktree.test.ts b/packages/cli/src/acp-integration/session/Session.worktree.test.ts index 99a5df3aa59..8da4005902d 100644 --- a/packages/cli/src/acp-integration/session/Session.worktree.test.ts +++ b/packages/cli/src/acp-integration/session/Session.worktree.test.ts @@ -303,6 +303,51 @@ describe('Session.pendingWorktreeNotice', () => { expect(session.pendingRecoveredAgentsNotice).toBeNull(); }); + it('does not consume a recovered-agents notice on an interrupted-turn continuation', async () => { + const session = new Session( + SESSION_ID, + mockConfig, + mockClient, + mockSettings, + ); + const notice = 'Recovered agents are available.'; + session.pendingRecoveredAgentsNotice = notice; + + // A daemon continuation (`qwen.daemon.continueLastTurn`) closing a dangling + // tool call re-sends synthesized functionResponse parts. The one-shot + // recovered-agents notice must survive it (the `!isContinue` guard) so it + // is delivered on the user's next ordinary prompt instead. + vi.mocked(mockChat.getHistory).mockReturnValue([ + { + role: 'model', + parts: [ + { functionCall: { id: 'call-1', name: 'read_file', args: {} } }, + ], + }, + ] as never); + await session.prompt({ + ...makePromptRequest(''), + _meta: { 'qwen.daemon.continueLastTurn': true }, + } as PromptRequest); + + // The continuation send leads with the synthesized functionResponse and + // carries no recovered-agents notice; the notice is still pending. + const continuationParts = capturedMessages[0] as Array<{ text?: string }>; + expect(continuationParts.some((part) => part.text?.includes(notice))).toBe( + false, + ); + expect(session.pendingRecoveredAgentsNotice).toBe(notice); + + // The next ordinary prompt consumes it exactly once. + vi.mocked(mockChat.getHistory).mockReturnValue([]); + await session.prompt(makePromptRequest('ordinary prompt')); + const ordinaryParts = capturedMessages[1] as Array<{ text?: string }>; + expect(ordinaryParts.some((part) => part.text?.includes(notice))).toBe( + true, + ); + expect(session.pendingRecoveredAgentsNotice).toBeNull(); + }); + // VP4b: sanity — no notice set, prompt works normally, no worktree reminder injected it('VP4b: no notice set — prompt proceeds normally without worktree system-reminder', async () => { const session = new Session( diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index fe2fc22e766..01b68e1d656 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -497,6 +497,54 @@ describe('runNonInteractive', () => { ); }); + it('does not consume the recovered-agents notice on an interrupted-turn continuation', async () => { + setupMetricsMock(); + // A resumed session with a one-shot recovered-agents notice pending, but + // this run is an interrupted-turn continuation. The `!continueInterrupted` + // guard must leave the notice for the user's next ordinary prompt. + vi.mocked(mockConfig.getResumedSessionData).mockReturnValue({} as never); + vi.mocked(mockConfig.consumePendingRecoveredAgentsNotice).mockReturnValue( + 'Restored 2 background agents from the previous session.', + ); + mockGeminiClient.getChat = vi.fn(() => ({ + getDebugResponses: mockGetDebugResponses, + getHistory: vi.fn().mockReturnValue([ + { + role: 'model', + parts: [{ functionCall: { id: 'call-1', name: 'shell' } }], + }, + ]), + })); + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + await runNonInteractive(mockConfig, mockSettings, '', 'prompt-c-notice', { + continueInterrupted: true, + }); + + // The notice is not consumed, and the continuation send carries only the + // synthesized functionResponse — no recovered-agents system-reminder. + expect( + mockConfig.consumePendingRecoveredAgentsNotice, + ).not.toHaveBeenCalled(); + const [request] = mockGeminiClient.sendMessageStream.mock.calls[0]!; + expect(request).toEqual([ + { + functionResponse: { + id: 'call-1', + name: 'shell', + response: { error: expect.stringContaining('not recorded') }, + }, + }, + ]); + }); + it('does not let headless YOLO bypass explicit teammate approval', async () => { setupMetricsMock(); const teamEvents = new EventEmitter();