fix: align v2 task observable behavior

This commit is contained in:
_Kerman 2026-07-08 20:02:21 +08:00
parent 157704b82a
commit f6aa8a8e7b
5 changed files with 69 additions and 40 deletions

View file

@ -68,7 +68,7 @@ type AgentTaskNotification = Record<string, unknown> & {
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<TaskOrigin, 'taskId' | 'status' | 'notificationId'>;
function isTaskOrigin(origin: unknown): origin is TaskNotificationOrigin {
if (typeof origin !== 'object' || origin === null) return false;
const value = origin as Record<string, unknown>;
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');

View file

@ -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 () => {

View file

@ -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<void> {
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();

View file

@ -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('<output-file');
expect(text).not.toContain('final subagent summary');
@ -461,7 +461,7 @@ describe('AgentTaskService — notification delivery', () => {
});
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('<output-file');
expect(text).toContain(persistence.taskOutputFile('agent-done0000'));
@ -547,7 +547,7 @@ describe('AgentTaskService — notification delivery', () => {
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('<output-file');
expect(text).toContain(persistence.taskOutputFile('bash-done0000'));
@ -654,7 +654,7 @@ describe('AgentTaskService — notification delivery', () => {
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,
},
}));

View file

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