diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 3fc0f0df7..a6c9761e0 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -167,7 +167,7 @@ describe('runNonInteractive', () => { setNotificationCallback: vi.fn(), setRegisterCallback: vi.fn(), getRunning: vi.fn().mockReturnValue([]), - hasUnfinalizedAgents: vi.fn().mockReturnValue(false), + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), abortAll: vi.fn(), }), } as unknown as Config; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 01473dfb3..f13c79f0c 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -714,11 +714,11 @@ export async function runNonInteractive( await handleCancellationError(config); } await drainLocalQueue(); - // Wait for every agent's terminal notification, not just the + // Wait for every task's terminal notification, not just the // running ones: cancel() marks status 'cancelled' synchronously // but the notification is emitted later by the natural handler, // and SDK consumers need every task_started paired with one. - if (!registry.hasUnfinalizedAgents() && localQueue.length === 0) + if (!registry.hasUnfinalizedTasks() && localQueue.length === 0) break; await new Promise((r) => setTimeout(r, 100)); } diff --git a/packages/core/src/agents/agent-transcript.ts b/packages/core/src/agents/agent-transcript.ts index 016a0bf27..c2d73f1bf 100644 --- a/packages/core/src/agents/agent-transcript.ts +++ b/packages/core/src/agents/agent-transcript.ts @@ -41,6 +41,11 @@ function sanitizeFilenameComponent(value: string): string { /** * Returns the directory holding all subagent transcripts for a given session. * Layout: `/subagents//`. + * + * TODO: this path is part of the model-facing contract via `` in + * the task-notification XML. When a second background task kind lands (e.g. a + * shell pool), migrate to `/tasks//-.jsonl` + * so the namespace generalizes. Update `read-file.ts` auto-allow accordingly. */ export function getSubagentSessionDir( projectDir: string, diff --git a/packages/core/src/agents/background-tasks.test.ts b/packages/core/src/agents/background-tasks.test.ts index 5ff207c14..a767c2404 100644 --- a/packages/core/src/agents/background-tasks.test.ts +++ b/packages/core/src/agents/background-tasks.test.ts @@ -120,14 +120,14 @@ describe('BackgroundTaskRegistry', () => { expect(callback).not.toHaveBeenCalled(); // Pathological tool case: bgBody never emits. After the grace period - // the fallback fires so hasUnfinalizedAgents() stops reporting true + // the fallback fires so hasUnfinalizedTasks() stops reporting true // and the headless wait loop can exit. vi.runAllTimers(); expect(callback).toHaveBeenCalledOnce(); const [, modelText] = callback.mock.calls[0] as [string, string]; expect(modelText).toContain('cancelled'); - expect(registry.hasUnfinalizedAgents()).toBe(false); + expect(registry.hasUnfinalizedTasks()).toBe(false); } finally { vi.useRealTimers(); } @@ -182,6 +182,34 @@ describe('BackgroundTaskRegistry', () => { expect(modelText).toContain('cancelled'); }); + it('complete() after the cancellation has already been notified is a no-op', () => { + // Once finalizeCancelled has emitted the terminal notification, a + // late-arriving complete() must not double-fire — the SDK contract + // is one notification per task_started. + const callback = vi.fn(); + registry.setNotificationCallback(callback); + + registry.register({ + agentId: 'test-1', + description: 'test agent', + status: 'running', + startTime: Date.now(), + abortController: new AbortController(), + }); + + registry.cancel('test-1'); + registry.finalizeCancelled('test-1', 'partial'); + expect(callback).toHaveBeenCalledOnce(); + callback.mockClear(); + + registry.complete('test-1', 'late result'); + + expect(callback).not.toHaveBeenCalled(); + // Status stays cancelled — the notified terminal state wins. + expect(registry.get('test-1')!.status).toBe('cancelled'); + expect(registry.get('test-1')!.result).toBe('partial'); + }); + it('does not cancel a non-running agent', () => { const abortController = new AbortController(); @@ -257,7 +285,7 @@ describe('BackgroundTaskRegistry', () => { expect(callback).toHaveBeenCalledTimes(2); }); - it('hasUnfinalizedAgents reports cancelled-but-not-notified entries', () => { + 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 // terminal task-notification — otherwise the matching notification @@ -269,17 +297,17 @@ describe('BackgroundTaskRegistry', () => { startTime: Date.now(), abortController: new AbortController(), }); - expect(registry.hasUnfinalizedAgents()).toBe(true); + expect(registry.hasUnfinalizedTasks()).toBe(true); registry.cancel('test-1'); expect(registry.get('test-1')!.status).toBe('cancelled'); - expect(registry.hasUnfinalizedAgents()).toBe(true); + expect(registry.hasUnfinalizedTasks()).toBe(true); registry.finalizeCancelled('test-1', ''); - expect(registry.hasUnfinalizedAgents()).toBe(false); + expect(registry.hasUnfinalizedTasks()).toBe(false); }); - it('hasUnfinalizedAgents clears once every entry has been notified', () => { + it('hasUnfinalizedTasks clears once every entry has been notified', () => { registry.register({ agentId: 'a', description: 'agent a', @@ -295,11 +323,11 @@ describe('BackgroundTaskRegistry', () => { abortController: new AbortController(), }); - expect(registry.hasUnfinalizedAgents()).toBe(true); + expect(registry.hasUnfinalizedTasks()).toBe(true); registry.complete('a', 'done'); - expect(registry.hasUnfinalizedAgents()).toBe(true); + expect(registry.hasUnfinalizedTasks()).toBe(true); registry.fail('b', 'boom'); - expect(registry.hasUnfinalizedAgents()).toBe(false); + expect(registry.hasUnfinalizedTasks()).toBe(false); }); it('complete after cancellation surfaces the real result', () => { diff --git a/packages/core/src/agents/background-tasks.ts b/packages/core/src/agents/background-tasks.ts index 881eb8481..1184614bd 100644 --- a/packages/core/src/agents/background-tasks.ts +++ b/packages/core/src/agents/background-tasks.ts @@ -221,14 +221,14 @@ export class BackgroundTaskRegistry { } /** - * True if any registered agent has not yet emitted its terminal + * True if any registered task has not yet emitted its terminal * task-notification. Covers `running` (still executing) and * `cancelled`-but-not-finalized (cancel requested, but the natural * handler hasn't fired finalizeCancelled() yet). Headless callers * must keep their event loop alive while this returns true, so every * task_started is paired with a matching task_notification. */ - hasUnfinalizedAgents(): boolean { + hasUnfinalizedTasks(): boolean { for (const entry of this.agents.values()) { if (!entry.notified) return true; } diff --git a/packages/core/src/tools/send-message.test.ts b/packages/core/src/tools/send-message.test.ts index 4f00ae9c1..6cc0e1c3f 100644 --- a/packages/core/src/tools/send-message.test.ts +++ b/packages/core/src/tools/send-message.test.ts @@ -23,7 +23,7 @@ describe('SendMessageTool', () => { tool = new SendMessageTool(config); }); - it('queues a message for a running agent', async () => { + it('queues a message for a running task', async () => { registry.register({ agentId: 'agent-1', description: 'test agent', @@ -33,7 +33,7 @@ describe('SendMessageTool', () => { }); const result = await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'do more work' }, + { task_id: 'agent-1', message: 'do more work' }, new AbortController().signal, ); @@ -52,11 +52,11 @@ describe('SendMessageTool', () => { }); await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'first' }, + { task_id: 'agent-1', message: 'first' }, new AbortController().signal, ); await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'second' }, + { task_id: 'agent-1', message: 'second' }, new AbortController().signal, ); @@ -66,17 +66,17 @@ describe('SendMessageTool', () => { ]); }); - it('returns error for non-existent agent', async () => { + it('returns error for non-existent task', async () => { const result = await tool.validateBuildAndExecute( - { to: 'nope', message: 'hello' }, + { task_id: 'nope', message: 'hello' }, new AbortController().signal, ); - expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_AGENT_NOT_FOUND); - expect(result.llmContent).toContain('No background agent found'); + expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_FOUND); + expect(result.llmContent).toContain('No background task found'); }); - it('returns error for non-running agent', async () => { + it('returns error for non-running task', async () => { registry.register({ agentId: 'agent-1', description: 'test agent', @@ -87,17 +87,15 @@ describe('SendMessageTool', () => { registry.complete('agent-1', 'done'); const result = await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'hello' }, + { task_id: 'agent-1', message: 'hello' }, new AbortController().signal, ); - expect(result.error?.type).toBe( - ToolErrorType.SEND_MESSAGE_AGENT_NOT_RUNNING, - ); + expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_RUNNING); expect(result.llmContent).toContain('not running'); }); - it('rejects messages for a cancelled agent', async () => { + it('rejects messages for a cancelled task', async () => { // Once task_stop fires, the reasoning loop is winding down — there is // no next tool-round boundary to drain into, so the message would be // silently dropped. Reject instead of accepting a message that will @@ -112,17 +110,15 @@ describe('SendMessageTool', () => { registry.cancel('agent-1'); const result = await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'too late' }, + { task_id: 'agent-1', message: 'too late' }, new AbortController().signal, ); - expect(result.error?.type).toBe( - ToolErrorType.SEND_MESSAGE_AGENT_NOT_RUNNING, - ); + expect(result.error?.type).toBe(ToolErrorType.SEND_MESSAGE_NOT_RUNNING); expect(registry.get('agent-1')!.pendingMessages).toEqual([]); }); - it('includes agent description in success display', async () => { + it('includes task description in success display', async () => { registry.register({ agentId: 'agent-1', description: 'Search for auth code', @@ -132,7 +128,7 @@ describe('SendMessageTool', () => { }); const result = await tool.validateBuildAndExecute( - { to: 'agent-1', message: 'focus on login' }, + { task_id: 'agent-1', message: 'focus on login' }, new AbortController().signal, ); diff --git a/packages/core/src/tools/send-message.ts b/packages/core/src/tools/send-message.ts index a56f97ef9..85dfdb93d 100644 --- a/packages/core/src/tools/send-message.ts +++ b/packages/core/src/tools/send-message.ts @@ -6,7 +6,7 @@ /** * @fileoverview SendMessage tool — lets the model send a text message to - * a running background agent. The message is injected into the agent's + * a running background task. The message is injected into the task's * reasoning loop at the next tool-round boundary. */ @@ -22,9 +22,9 @@ import { } from './tools.js'; export interface SendMessageParams { - /** The ID of the background agent to send the message to. */ - to: string; - /** The text message to deliver to the agent. */ + /** The ID of the background task to send the message to. */ + task_id: string; + /** The text message to deliver to the task. */ message: string; } @@ -40,39 +40,39 @@ class SendMessageInvocation extends BaseToolInvocation< } getDescription(): string { - return `Send message to agent ${this.params.to}`; + return `Send message to task ${this.params.task_id}`; } async execute(_signal: AbortSignal): Promise { const registry = this.config.getBackgroundTaskRegistry(); - const entry = registry.get(this.params.to); + const entry = registry.get(this.params.task_id); if (!entry) { return { - llmContent: `Error: No background agent found with ID "${this.params.to}".`, - returnDisplay: 'Agent not found.', + llmContent: `Error: No background task found with ID "${this.params.task_id}".`, + returnDisplay: 'Task not found.', error: { - message: `Agent not found: ${this.params.to}`, - type: ToolErrorType.SEND_MESSAGE_AGENT_NOT_FOUND, + message: `Task not found: ${this.params.task_id}`, + type: ToolErrorType.SEND_MESSAGE_NOT_FOUND, }, }; } if (entry.status !== 'running') { return { - llmContent: `Error: Background agent "${this.params.to}" is not running (status: ${entry.status}). Cannot send messages to stopped agents.`, - returnDisplay: `Agent not running (${entry.status}).`, + llmContent: `Error: Background task "${this.params.task_id}" is not running (status: ${entry.status}). Cannot send messages to stopped tasks.`, + returnDisplay: `Task not running (${entry.status}).`, error: { - message: `Agent is ${entry.status}: ${this.params.to}`, - type: ToolErrorType.SEND_MESSAGE_AGENT_NOT_RUNNING, + message: `Task is ${entry.status}: ${this.params.task_id}`, + type: ToolErrorType.SEND_MESSAGE_NOT_RUNNING, }, }; } - registry.queueMessage(this.params.to, this.params.message); + registry.queueMessage(this.params.task_id, this.params.message); return { - llmContent: `Message queued for delivery to background agent "${this.params.to}". The agent will receive it at the next tool-round boundary.`, + llmContent: `Message queued for delivery to background task "${this.params.task_id}". The task will receive it at the next tool-round boundary.`, returnDisplay: `Message queued for ${entry.description}`, }; } @@ -88,22 +88,22 @@ export class SendMessageTool extends BaseDeclarativeTool< super( SendMessageTool.Name, ToolDisplayNames.SEND_MESSAGE, - 'Send a text message to a running background agent. The message is delivered at the next tool-round boundary. Use this to provide additional instructions or context to a background agent.', + '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.', Kind.Other, { type: 'object', properties: { - to: { + task_id: { type: 'string', description: - 'The ID of the running background agent (from the launch response).', + 'The ID of the running background task (from the launch response).', }, message: { type: 'string', - description: 'The text message to send to the agent.', + description: 'The text message to send to the task.', }, }, - required: ['to', 'message'], + required: ['task_id', 'message'], additionalProperties: false, }, ); diff --git a/packages/core/src/tools/task-stop.test.ts b/packages/core/src/tools/task-stop.test.ts index 592a586f0..d5df835a3 100644 --- a/packages/core/src/tools/task-stop.test.ts +++ b/packages/core/src/tools/task-stop.test.ts @@ -45,17 +45,17 @@ describe('TaskStopTool', () => { expect(ac.signal.aborted).toBe(true); }); - it('returns error for non-existent agent', async () => { + it('returns error for non-existent task', async () => { const result = await tool.validateBuildAndExecute( { task_id: 'nope' }, new AbortController().signal, ); - expect(result.error?.type).toBe(ToolErrorType.TASK_STOP_AGENT_NOT_FOUND); - expect(result.llmContent).toContain('No background agent found'); + expect(result.error?.type).toBe(ToolErrorType.TASK_STOP_NOT_FOUND); + expect(result.llmContent).toContain('No background task found'); }); - it('returns error for non-running agent', async () => { + it('returns error for non-running task', async () => { registry.register({ agentId: 'agent-1', description: 'test agent', @@ -70,7 +70,7 @@ describe('TaskStopTool', () => { new AbortController().signal, ); - expect(result.error?.type).toBe(ToolErrorType.TASK_STOP_AGENT_NOT_RUNNING); + expect(result.error?.type).toBe(ToolErrorType.TASK_STOP_NOT_RUNNING); expect(result.llmContent).toContain('not running'); }); diff --git a/packages/core/src/tools/task-stop.ts b/packages/core/src/tools/task-stop.ts index 86190b692..655b70450 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 agent. + * @fileoverview TaskStop tool — lets the model cancel a running background task. */ import type { Config } from '../config/config.js'; @@ -20,7 +20,7 @@ import { } from './tools.js'; export interface TaskStopParams { - /** The ID of the background agent to stop. */ + /** The ID of the background task to stop. */ task_id: string; } @@ -36,7 +36,7 @@ class TaskStopInvocation extends BaseToolInvocation< } getDescription(): string { - return `Stop background agent ${this.params.task_id}`; + return `Stop background task ${this.params.task_id}`; } async execute(_signal: AbortSignal): Promise { @@ -45,35 +45,35 @@ class TaskStopInvocation extends BaseToolInvocation< if (!entry) { return { - llmContent: `Error: No background agent found with ID "${this.params.task_id}".`, - returnDisplay: 'Agent not found.', + llmContent: `Error: No background task found with ID "${this.params.task_id}".`, + returnDisplay: 'Task not found.', error: { - message: `Agent not found: ${this.params.task_id}`, - type: ToolErrorType.TASK_STOP_AGENT_NOT_FOUND, + message: `Task not found: ${this.params.task_id}`, + type: ToolErrorType.TASK_STOP_NOT_FOUND, }, }; } if (entry.status !== 'running') { return { - llmContent: `Error: Background agent "${this.params.task_id}" is not running (status: ${entry.status}).`, - returnDisplay: `Agent not running (${entry.status}).`, + llmContent: `Error: Background task "${this.params.task_id}" is not running (status: ${entry.status}).`, + returnDisplay: `Task not running (${entry.status}).`, error: { - message: `Agent is ${entry.status}: ${this.params.task_id}`, - type: ToolErrorType.TASK_STOP_AGENT_NOT_RUNNING, + message: `Task is ${entry.status}: ${this.params.task_id}`, + type: ToolErrorType.TASK_STOP_NOT_RUNNING, }, }; } registry.cancel(this.params.task_id); - // The terminal task-notification is emitted by the agent's own handler + // The terminal task-notification is emitted by the task's own handler // (via registry.complete/fail) rather than cancel(), so the parent model - // still receives the agent's real partial/final result — not just a bare + // still receives the task's real partial/final result — not just a bare // "cancelled" message — once the reasoning loop unwinds. const desc = entry.description; return { - llmContent: `Cancellation requested for background agent "${this.params.task_id}". A final task-notification carrying the agent's last result will follow.\nDescription: ${desc}`, + llmContent: `Cancellation requested for background task "${this.params.task_id}". A final task-notification carrying the task's last result will follow.\nDescription: ${desc}`, returnDisplay: `Cancelled: ${desc}`, }; } @@ -89,7 +89,7 @@ export class TaskStopTool extends BaseDeclarativeTool< super( TaskStopTool.Name, ToolDisplayNames.TASK_STOP, - 'Cancel a running background agent by its ID. The agent ID is returned when you launch a background agent.', + 'Cancel a running background task by its ID. The task ID is returned when the task is launched.', Kind.Other, { type: 'object', @@ -97,7 +97,7 @@ export class TaskStopTool extends BaseDeclarativeTool< task_id: { type: 'string', description: - 'The ID of the background agent to stop (from the launch response or notification).', + 'The ID of the background task to stop (from the launch response or notification).', }, }, required: ['task_id'], diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 1f2202a42..6a29472c6 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -68,10 +68,10 @@ export enum ToolErrorType { OUTPUT_TRUNCATED = 'output_truncated', // TaskStop-specific Errors - TASK_STOP_AGENT_NOT_FOUND = 'task_stop_agent_not_found', - TASK_STOP_AGENT_NOT_RUNNING = 'task_stop_agent_not_running', + TASK_STOP_NOT_FOUND = 'task_stop_not_found', + TASK_STOP_NOT_RUNNING = 'task_stop_not_running', // SendMessage-specific Errors - SEND_MESSAGE_AGENT_NOT_FOUND = 'send_message_agent_not_found', - SEND_MESSAGE_AGENT_NOT_RUNNING = 'send_message_agent_not_running', + SEND_MESSAGE_NOT_FOUND = 'send_message_not_found', + SEND_MESSAGE_NOT_RUNNING = 'send_message_not_running', }