diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 19f930cbe..5da334fb5 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -68,7 +68,7 @@ type AgentTaskNotification = Record & { readonly id: string; readonly category: 'task'; readonly type: string; - readonly source_kind: 'task'; + readonly source_kind: 'background_task'; readonly source_id: string; readonly agent_id?: string | undefined; readonly title: string; @@ -658,7 +658,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { if (maxRunningTasks === undefined) return; if (!detached) return; if (this.activeTaskCount() < maxRunningTasks) return; - throw new Error('Too many detached tasks are already running.'); + throw new Error('Too many background tasks are already running.'); } private taskConfig(): AgentTaskConfig | undefined { @@ -815,16 +815,16 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private recordTaskStarted(info: AgentTaskInfo): void { this.wire.dispatch(taskStarted({ info })); - this.telemetry.track('task_created', { + this.telemetry.track('background_task_created', { kind: info.kind === 'process' ? 'bash' : info.kind, }); } private recordTaskTerminated(info: AgentTaskInfo): void { this.wire.dispatch(taskTerminated({ info })); - this.telemetry.track('task_completed', { + this.telemetry.track('background_task_completed', { kind: info.kind, - duration: info.endedAt !== null ? info.endedAt - info.startedAt : null, + duration_ms: info.endedAt !== null ? info.endedAt - info.startedAt : null, status: info.status, }); } @@ -886,10 +886,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { id: origin.notificationId, category: 'task', type: `task.${info.status}`, - source_kind: 'task', + source_kind: 'background_task', source_id: info.taskId, agent_id: info.kind === 'agent' ? info.agentId : undefined, - title: `Task ${info.kind} ${info.status}`, + title: `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', body: buildAgentTaskNotificationBody(info), children: agentTaskNotificationChildren(output), @@ -922,7 +922,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { ); } - private markDeliveredNotification(origin: TaskOrigin): void { + private markDeliveredNotification(origin: TaskNotificationOrigin): void { this.deliveredNotificationKeys.add(notificationKey(origin)); } @@ -1042,22 +1042,24 @@ function newerRestoredTask( return loaded; } -function isTaskOrigin(origin: unknown): origin is TaskOrigin { +type TaskNotificationOrigin = Pick; + +function isTaskOrigin(origin: unknown): origin is TaskNotificationOrigin { if (typeof origin !== 'object' || origin === null) return false; const value = origin as Record; return ( - value['kind'] === 'task' && + (value['kind'] === 'background_task' || value['kind'] === 'task') && typeof value['taskId'] === 'string' && typeof value['status'] === 'string' && typeof value['notificationId'] === 'string' ); } -function notificationKey(origin: TaskOrigin): string { +function notificationKey(origin: TaskNotificationOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } -function taskOriginsFromRecord(record: WireRecord): readonly TaskOrigin[] { +function taskOriginsFromRecord(record: WireRecord): readonly TaskNotificationOrigin[] { const raw = record as { readonly type: string; readonly message?: unknown; @@ -1072,7 +1074,7 @@ function taskOriginsFromRecord(record: WireRecord): readonly TaskOrigin[] { return []; } -function taskOriginFromMessage(message: unknown): readonly TaskOrigin[] { +function taskOriginFromMessage(message: unknown): readonly TaskNotificationOrigin[] { if (typeof message !== 'object' || message === null) return []; const origin = (message as { readonly origin?: unknown }).origin; return isTaskOrigin(origin) ? [origin] : []; @@ -1094,7 +1096,7 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { const recovery = [ '', `To recover or continue this subagent, call Agent(resume="${agentId}", prompt="Pick up where you left off; redo the last tool call if its result was never observed.").`, - `Use agent_id ("${agentId}"), NOT source_id / task_id ("${info.taskId}") because the two look alike but only agent_id is accepted by the resume parameter.`, + `Use agent_id ("${agentId}"), NOT source_id / task_id ("${info.taskId}") — the two look alike but only agent_id is accepted by the resume parameter.`, 'Add run_in_background=true to keep it backgrounded, or omit it to take the result inline in the current turn.', 'The subagent retains its full prior context across the restart, but any in-flight tool call lost its result and may need to be redone.', ].join('\n'); diff --git a/packages/agent-core-v2/test/task/manager.test.ts b/packages/agent-core-v2/test/task/manager.test.ts index 11e43adf4..003f1e01c 100644 --- a/packages/agent-core-v2/test/task/manager.test.ts +++ b/packages/agent-core-v2/test/task/manager.test.ts @@ -523,7 +523,7 @@ describe('AgentTaskService', () => { expect(() => { manager.registerTask(agentTask(new Promise(() => {}), 'second background')); - }).toThrow('Too many detached tasks are already running.'); + }).toThrow('Too many background tasks are already running.'); }); it('does not count foreground tasks detached later against the detached task limit', () => { @@ -539,7 +539,7 @@ describe('AgentTaskService', () => { expect(() => { manager.registerTask(agentTask(new Promise(() => {}), 'second background')); - }).toThrow('Too many detached tasks are already running.'); + }).toThrow('Too many background tasks are already running.'); }); it('lists active tasks by default', () => { @@ -603,10 +603,10 @@ describe('AgentTaskService', () => { expect(() => { registerProcess(manager, pendingProcess().proc, 'sleep 60', 'second task'); - }).toThrow('Too many detached tasks are already running.'); + }).toThrow('Too many background tasks are already running.'); expect(() => { manager.registerTask(agentTask(new Promise(() => {}), 'agent task')); - }).toThrow('Too many detached tasks are already running.'); + }).toThrow('Too many background tasks are already running.'); }); it('captures process output', async () => { diff --git a/packages/agent-core-v2/test/task/output-access.test.ts b/packages/agent-core-v2/test/task/output-access.test.ts index 96f816cc1..b9bd9aedf 100644 --- a/packages/agent-core-v2/test/task/output-access.test.ts +++ b/packages/agent-core-v2/test/task/output-access.test.ts @@ -6,6 +6,7 @@ import { join } from 'pathe'; import type { IProcess } from '#/session/process/processRunner'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentTaskService } from '#/agent/task/task'; +import { TERMINAL_STATUSES } from '#/agent/task/types'; import { ProcessTask } from '#/os/backends/node-local/tools/process-task'; import { createAgentTaskPersistence, type TaskServiceTestManager } from './stubs'; import { taskServices, createTestAgent, homeDirServices, type TestAgentContext } from '../harness'; @@ -49,6 +50,31 @@ async function waitForOutput( throw new Error(`Timed out waiting for output: ${expected}`); } +async function waitForTaskNotifications( + ctx: TestAgentContext, + manager: TaskServiceTestManager, +): Promise { + const tasks = manager.list(false).filter( + (task) => + TERMINAL_STATUSES.has(task.status) && + task.detached !== false && + task.terminalNotificationSuppressed !== true, + ); + if (tasks.length === 0) return; + + await vi.waitFor(() => { + const origins = ctx.context.get().map((message) => message.origin); + for (const task of tasks) { + expect(origins).toContainEqual({ + kind: 'task', + taskId: task.taskId, + status: task.status, + notificationId: `task:${task.taskId}:${task.status}`, + }); + } + }); +} + function immediateProcess(exitCode: number, stdoutText = ''): IProcess { return { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, @@ -78,6 +104,7 @@ describe('AgentTaskService — readOutput / getOutputSnapshot', () => { afterEach(async () => { try { + await waitForTaskNotifications(ctx, manager); await ctx.expectResumeMatches(); } finally { await ctx.dispose(); diff --git a/packages/agent-core-v2/test/task/rpc-events.test.ts b/packages/agent-core-v2/test/task/rpc-events.test.ts index c8ee3127a..25ad89050 100644 --- a/packages/agent-core-v2/test/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/task/rpc-events.test.ts @@ -286,7 +286,7 @@ describe('AgentTaskService — event emission', () => { status: 'running', }), }); - expect(agent.telemetry.track).toHaveBeenCalledWith('task_created', { + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { kind: 'bash', }); }); @@ -305,7 +305,7 @@ describe('AgentTaskService — event emission', () => { status: 'running', }), }); - expect(agent.telemetry.track).toHaveBeenCalledWith('task_created', { + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { kind: 'agent', }); }); @@ -325,10 +325,10 @@ describe('AgentTaskService — event emission', () => { }), }); expect(agent.telemetry.track).toHaveBeenCalledWith( - 'task_completed', + 'background_task_completed', expect.objectContaining({ kind: 'process', - duration: expect.any(Number), + duration_ms: expect.any(Number), status: 'completed', }), ); @@ -349,11 +349,11 @@ describe('AgentTaskService — event emission', () => { await timedOut; expect(agent.telemetry.track).toHaveBeenCalledWith( - 'task_completed', + 'background_task_completed', expect.objectContaining({ kind: 'process', status: 'failed' }), ); expect(agent.telemetry.track).toHaveBeenCalledWith( - 'task_completed', + 'background_task_completed', expect.objectContaining({ kind: 'agent', status: 'timed_out' }), ); }); @@ -436,7 +436,7 @@ describe('AgentTaskService — notification delivery', () => { }); const content = (message as { content: Array<{ text: string }> }).content; const text = content[0]!.text; - expect(text).toContain('Task agent completed'); + expect(text).toContain('Background agent completed'); expect(text).toContain('agent task completed.'); expect(text).toContain(' { }); const content = (message as { content: Array<{ text: string }> }).content; const text = content[0]!.text; - expect(text).toContain('Task process completed'); + expect(text).toContain('Background process completed'); expect(text).toContain('shell task completed.'); }); @@ -484,7 +484,7 @@ describe('AgentTaskService — notification delivery', () => { }); const content = (message as { content: Array<{ text: string }> }).content; expect(content[0]!.text).toContain( - 'Task process killed', + 'Background process killed', ); }); @@ -513,7 +513,7 @@ describe('AgentTaskService — notification delivery', () => { notificationId: 'task:agent-done0000:completed', }); const text = message.content[0]!.text; - expect(text).toContain('Task agent completed'); + expect(text).toContain('Background agent completed'); expect(text).not.toContain('restored subagent summary'); expect(text).toContain(' { notificationId: 'task:bash-done0000:completed', }); const text = message.content[0]!.text; - expect(text).toContain('Task process completed'); + expect(text).toContain('Background process completed'); expect(text).not.toContain('restored shell output'); expect(text).toContain(' { notificationId: 'task:agent-run00000:lost', }); expect(message.content[0]!.text).toContain( - 'Task agent lost', + 'Background agent lost', ); } finally { await cleanupSessionDir(sessionDir, fixture); @@ -684,10 +684,10 @@ describe('AgentTaskService — notification delivery', () => { inputData: { sink: 'context', notificationType: 'task.completed', - title: 'Task agent completed', + title: 'Background agent completed', body: 'inspect repository completed.', severity: 'info', - sourceKind: 'task', + sourceKind: 'background_task', sourceId: taskId, }, })); @@ -733,10 +733,10 @@ describe('AgentTaskService — notification delivery', () => { inputData: { sink: 'context', notificationType: 'task.completed', - title: 'Task process completed', + title: 'Background process completed', body: 'done completed.', severity: 'info', - sourceKind: 'task', + sourceKind: 'background_task', sourceId: taskId, }, })); diff --git a/packages/agent-core-v2/test/task/service.test.ts b/packages/agent-core-v2/test/task/service.test.ts index 333b7580a..e9aafbbdd 100644 --- a/packages/agent-core-v2/test/task/service.test.ts +++ b/packages/agent-core-v2/test/task/service.test.ts @@ -127,7 +127,7 @@ describe('Agent task notification XML', () => { id: 'n_"1&2', category: 'task', type: 'task.done', - source_kind: 'task', + source_kind: 'background_task', source_id: 'bg&1', title: 'Task finished', severity: 'info', @@ -158,12 +158,12 @@ describe('Agent task notification XML', () => { id: 'n_lost1', category: 'task', type: 'task.lost', - source_kind: 'task', + source_kind: 'background_task', source_id: 'agent-w7gq3wwj', agent_id: 'agent-0', - title: 'Task agent lost', + title: 'Background agent lost', severity: 'warning', - body: 'Task agent 1 lost.', + body: 'Background agent 1 lost.', }); expect(text).toContain('source_id="agent-w7gq3wwj"'); @@ -175,9 +175,9 @@ describe('Agent task notification XML', () => { id: 'n_bash', category: 'task', type: 'task.completed', - source_kind: 'task', + source_kind: 'background_task', source_id: 'bash-abcdef00', - title: 'Task completed', + title: 'Background task completed', severity: 'info', body: 'echo done completed.', });