refactor(core): generalize task_stop and send_message framing to "task"

Today every BackgroundTaskRegistry entry is a subagent, but the
control-plane tools were named and described as agent-only. Generalize
so future task kinds (e.g. backgrounded shells, monitors) can share
the same registry without a model-facing rename.

- task_stop / send_message: descriptions, error messages, and ToolError
  enum values drop the "agent" framing in favor of "task".
- send_message: parameter to -> task_id, matching task_stop for a
  uniform control-plane contract.
- BackgroundTaskRegistry.hasUnfinalizedAgents -> hasUnfinalizedTasks.
- agent-transcript: add a TODO at getSubagentSessionDir flagging that
  <projectDir>/subagents/ is part of the model-facing contract via
  <output-file>; future kinds should migrate to <projectDir>/tasks/.
- Add a test for complete()-after-finalizeCancelled no-op to pin the
  one-notification-per-task SDK contract through the post-notified
  re-entry path.
This commit is contained in:
tanzhenxin 2026-04-27 09:58:52 +08:00
parent cb513119d1
commit 57c89e425f
10 changed files with 110 additions and 81 deletions

View file

@ -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;

View file

@ -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));
}

View file

@ -41,6 +41,11 @@ function sanitizeFilenameComponent(value: string): string {
/**
* Returns the directory holding all subagent transcripts for a given session.
* Layout: `<projectDir>/subagents/<sessionId>/`.
*
* TODO: this path is part of the model-facing contract via `<output-file>` in
* the task-notification XML. When a second background task kind lands (e.g. a
* shell pool), migrate to `<projectDir>/tasks/<sessionId>/<kind>-<id>.jsonl`
* so the namespace generalizes. Update `read-file.ts` auto-allow accordingly.
*/
export function getSubagentSessionDir(
projectDir: string,

View file

@ -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('<status>cancelled</status>');
expect(registry.hasUnfinalizedAgents()).toBe(false);
expect(registry.hasUnfinalizedTasks()).toBe(false);
} finally {
vi.useRealTimers();
}
@ -182,6 +182,34 @@ describe('BackgroundTaskRegistry', () => {
expect(modelText).toContain('<status>cancelled</status>');
});
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', () => {

View file

@ -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;
}

View file

@ -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,
);

View file

@ -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<ToolResult> {
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,
},
);

View file

@ -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');
});

View file

@ -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<ToolResult> {
@ -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'],

View file

@ -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',
}