fix(agent-core-v2): emit subagent.spawned after task registration (#3005)

* fix(agent-core-v2): emit subagent.spawned after task registration

The spawned signal previously fired at launch, before the run's task
registration, so clients learned the agent id with no task id to bind
cancel/status actions to; a failed registration also left a spawned row
behind for a run that never registered. Emit it only after registerTask
succeeds and carry the task id on the event.

* fix(agent-core-v2): keep spawned ahead of started for Agent-tool runs

The TUI drops subagent.started until spawned has established the row,
and a failed registration must not leave a started row behind with no
terminal event. Defer the mirrored started dispatch so the Agent tool
can emit it itself after registration and spawned.

* fix(agent-core-v2): void the deferred started dispatch

* fix(kap-server): key Agent-tool transcript rows by the registered task id

Transcript-protocol clients suppress the raw task.*/subagent.* session
events, so they only saw a subagent row keyed by agent id that cannot
address /tasks/{id}, plus a second row once task.started landed. Key the
spawned row by the task id it now carries, fold task.started and the
subagent lifecycle back into it, and keep the agent-id path for spawns
without a registration (swarm/session-init/tower). Statement-level
ordering notes move to the file headers per package convention.

* test(agent-core-v2): split the spawned/started ordering contract into its own test

* fix(kap-server): keep subagent result details across task termination and drop stale task mappings on taskless respawns

* fix(kap-server): recover the agent-to-task association from a backfilled task.started

* fix(kap-server): seed pre-attach Agent task mappings on the transcript binding

* fix(kap-server): seed the full in-flight task row on transcript bind, not only its id

* docs(agent-core-v2): name the state-domain event dispatcher in the Agent tool header

* style(kap-server): drop comments in transcript services per the no-comments lint rule
This commit is contained in:
qer 2026-08-19 14:58:55 +08:00 committed by GitHub
parent 01eeacb59b
commit be8e017597
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 308 additions and 16 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Emit subagent.spawned after the run's task registration so the signal carries the task id clients bind cancel/status actions to.

View file

@ -51,7 +51,8 @@ import type { Runtime } from '#/runtime/runtime';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun';
import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { ISessionSubagentService } from '#/session/subagent/subagent';
import {
buildSubagentModelDescriptions,
@ -310,15 +311,6 @@ export class SubagentTool implements ISubagentTool {
});
}
const runInBackground = args.run_in_background === true;
emitAgentRunSpawned(requester, agentId, {
profileName,
parentToolCallId: toolCallId,
description: args.description,
runInBackground,
model: displayModel,
});
const run = await this.subagents.run(
agentId,
{ kind: 'prompt', prompt: promptText },
@ -328,6 +320,7 @@ export class SubagentTool implements ISubagentTool {
profileName,
prompt: promptText,
signal: controller.signal,
deferStarted: true,
cancel: (reason) => {
controller.abort(reason);
},
@ -451,6 +444,21 @@ export class SubagentTool implements ISubagentTool {
};
}
const requester = this.lifecycle.get(this.callerAgentId);
if (requester !== undefined) {
emitAgentRunSpawned(requester, handle.agentId, {
profileName: handle.profileName,
parentToolCallId: toolCallId,
description: args.description,
runInBackground,
model: handle.model,
taskId,
});
void requester.accessor
.get(IEventDispatcher)
?.dispatch(new SubagentStarted({ subagentId: handle.agentId }));
}
if (runInBackground) {
return {
output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground),

View file

@ -25,6 +25,7 @@ export interface SubagentSpawnedPayload {
readonly runInBackground: boolean;
readonly model?: string;
readonly thinkingEffort?: string;
readonly taskId?: string;
}
export class SubagentSpawned extends Event2<SubagentSpawnedPayload> {
@ -75,6 +76,7 @@ export interface AgentRunSpawnedMeta {
readonly swarmIndex?: number;
readonly runInBackground?: boolean;
readonly model?: string;
readonly taskId?: string;
}
export interface MirrorAgentRunOptions {
@ -83,6 +85,7 @@ export interface MirrorAgentRunOptions {
readonly suppressRateLimitFailureEvent?: boolean;
readonly signal: AbortSignal;
readonly cancel?: (reason?: unknown) => void;
readonly deferStarted?: boolean;
}
export function emitAgentRunSpawned(
@ -107,6 +110,7 @@ export function emitAgentRunSpawned(
runInBackground: meta.runInBackground ?? false,
model: meta.model,
thinkingEffort: childProfile?.getEffectiveThinkingLevel(),
taskId: meta.taskId,
}),
);
childProfile?.republishStatus();
@ -127,7 +131,9 @@ export async function mirrorAgentRun(
const dispatcher = requester.accessor.get(IEventDispatcher);
const subagents = requester.accessor.get(ISessionSubagentService);
const agentLifecycle = requester.accessor.get(IAgentLifecycleService);
void dispatcher?.dispatch(new SubagentStarted({ subagentId: run.agentId }));
if (options.deferStarted !== true) {
void dispatcher?.dispatch(new SubagentStarted({ subagentId: run.agentId }));
}
if (options.prompt !== undefined) {
const cancelAndRethrow = (reason: unknown): never => {
options.cancel?.(reason);

View file

@ -1778,6 +1778,36 @@ describe('Agent tool execution contract', () => {
completion.resolve({ summary: 'finished later' });
});
it('emits spawned with the registered task id ahead of started', async () => {
const completion = deferred<{ readonly summary: string }>();
const lifecycle = createAgentLifecycleStub({
createAgentIds: ['agent-child'],
runCompletion: () => completion.promise,
});
const context = createAgentToolContext(lifecycle);
const result = await executeAgentTool(context, {
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
});
if (typeof result.output !== 'string') throw new TypeError('expected string output');
const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1];
expect(taskId).toBeDefined();
expect(lifecycle.publishedEvents).toContainEqual(
expect.objectContaining({
type: 'subagent.spawned',
subagentId: 'agent-child',
taskId,
}),
);
const eventOrder = lifecycle.publishedEvents.map((event) => event.type);
expect(eventOrder.indexOf('subagent.spawned')).toBeGreaterThanOrEqual(0);
expect(eventOrder.indexOf('subagent.started')).toBeGreaterThan(eventOrder.indexOf('subagent.spawned'));
completion.resolve({ summary: 'finished later' });
});
it('rejects background subagents when background execution is disabled', async () => {
const lifecycle = createAgentLifecycleStub();
const context = createAgentToolContext(lifecycle);
@ -1878,6 +1908,11 @@ describe('Agent tool execution contract', () => {
output: 'Too many background tasks are already running.',
});
expect(lifecycle.create).toHaveBeenCalledTimes(2);
expect(
lifecycle.publishedEvents.filter(
(event) => (event as { subagentId?: string }).subagentId === 'agent-second',
),
).toEqual([]);
completions[0]?.resolve({ summary: 'finished later' });
});

View file

@ -845,6 +845,7 @@ export const subagentSpawnedEventSchema = z.object({
runInBackground: z.boolean(),
model: z.string().optional(),
thinkingEffort: z.string().optional(),
taskId: z.string().optional(),
}) satisfies z.ZodType<SubagentSpawnedPayload>;
export const subagentStartedEventSchema = z.object({

View file

@ -1,6 +1,7 @@
import {
IAgentLifecycleService,
IAgentActivityView,
IAgentTaskService,
IEventBus,
ISessionMetadata,
ISessionInteractionService,
@ -103,6 +104,25 @@ export function bindSessionTranscript(
},
turn: (turnId) => store.getAgent(agentId)?.getTurn(turnId),
});
for (const agent of agents.list()) {
if (agent.id !== agentId) continue;
const tasks = agent.accessor.get(IAgentTaskService)?.list() ?? [];
for (const info of tasks) {
if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) {
applyOps(
agentId,
projector.seedSubagentTask({
taskId: info.taskId,
agentId: info.agentId,
description: info.description,
status: info.status,
detached: info.detached ?? false,
startedAt: info.startedAt,
}),
);
}
}
}
projectors.set(agentId, projector);
}
return projector;

View file

@ -211,6 +211,40 @@ export class AgentTranscriptProjector {
private readonly tasks = new Map<string, TranscriptTask>();
/** shell `commandId` → transcript `taskId` (`shell.output` is keyed by command id only). */
private readonly shellTasks = new Map<string, string>();
/** subagent agent id registered task id, for Agent-tool runs whose spawned
carried the registration (`taskId`): the task row keys by the task id so
`/tasks/{id}` actions resolve, and lifecycle events fold back to it. */
private readonly subagentTaskIds = new Map<string, string>();
/** Pre-seed the association and the row for a task registered before
attach: a foreground Agent run emits no `task.started` at all, so
without this a late-bound projector never learns the mapping, shows no
cancellable row, and lets the terminal event invent foreground-wrong
defaults. Only in-flight tasks seed (a terminal one has no lifecycle
left to fold). */
seedSubagentTask(info: {
readonly taskId: string;
readonly agentId: string;
readonly description: string;
readonly status: string;
readonly detached: boolean;
readonly startedAt: number;
}): TranscriptOperation[] {
if (info.status !== 'running') return [];
this.subagentTaskIds.set(info.agentId, info.taskId);
const task = this.upsertTask(info.taskId, (prev) => ({
taskId: info.taskId,
kind: 'subagent',
state: 'running',
detached: info.detached,
description: info.description,
agentId: info.agentId,
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt),
endedAt: prev?.endedAt,
}));
return [{ op: 'task.upsert', task }];
}
/** interaction id → the pending entity as last emitted (resolve spreads it). */
private readonly interactions = new Map<string, TranscriptInteraction>();
/** promptId → the prompt queue entity as last emitted (`prompt.upsert` replaces). */
@ -871,9 +905,16 @@ export class AgentTranscriptProjector {
outputTail: prev?.outputTail ?? '',
startedAt: prev?.startedAt ?? epochMsToIso(info.startedAt),
endedAt: info.endedAt === null ? prev?.endedAt : epochMsToIso(info.endedAt),
resultSummary: prev?.resultSummary,
usage: prev?.usage,
error: prev?.error,
stateReason: prev?.stateReason,
}));
const ops: TranscriptOperation[] = [{ op: 'task.upsert', task }];
if (event.type === 'task.started') {
if (info.kind === 'agent' && typeof info.agentId === 'string' && info.agentId.length > 0) {
this.subagentTaskIds.set(info.agentId, info.taskId);
}
ops.push({
op: 'taskref.upsert',
item: { kind: 'taskref', refId: `ref-${info.taskId}`, taskId: info.taskId, at: nowIso() },
@ -1004,9 +1045,16 @@ export class AgentTranscriptProjector {
description?: string;
swarmIndex?: number;
runInBackground: boolean;
taskId?: string;
}): TranscriptOperation[] {
const task = this.upsertTask(event.subagentId, (prev) => ({
taskId: event.subagentId,
const taskKey = event.taskId ?? event.subagentId;
if (event.taskId !== undefined) {
this.subagentTaskIds.set(event.subagentId, event.taskId);
} else {
this.subagentTaskIds.delete(event.subagentId);
}
const task = this.upsertTask(taskKey, (prev) => ({
taskId: taskKey,
kind: 'subagent',
state: 'running',
detached: event.runInBackground,
@ -1048,8 +1096,9 @@ export class AgentTranscriptProjector {
: event.type === 'subagent.failed'
? 'failed'
: 'running';
const task = this.upsertTask(event.subagentId, (prev) => ({
taskId: event.subagentId,
const taskKey = this.subagentTaskIds.get(event.subagentId) ?? event.subagentId;
const task = this.upsertTask(taskKey, (prev) => ({
taskId: taskKey,
kind: 'subagent',
state,
detached: prev?.detached ?? true,

View file

@ -5,6 +5,7 @@ import { join } from 'node:path';
import {
IAgentLifecycleService,
IAgentLoopService,
IAgentTaskService,
IEventBus,
ISessionIndex,
ISessionInteractionService,
@ -970,6 +971,124 @@ describe('AgentTranscriptProjector', () => {
});
});
it('keys an Agent-tool subagent row by its registered task id and folds the lifecycle', () => {
const projector = new AgentTranscriptProjector('main');
const tx = new AgentTranscript('main');
const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event));
feed(
ev({
type: 'subagent.spawned',
subagentId: 'agent-1',
subagentName: 'explore',
parentToolCallId: 'call-1',
description: 'Inspect files',
runInBackground: true,
taskId: 'task-9',
}),
);
feed(
ev({
type: 'task.started',
info: {
taskId: 'task-9',
kind: 'agent',
description: 'Inspect files',
status: 'running',
detached: true,
agentId: 'agent-1',
startedAt: 1_700_000_000_000,
endedAt: null,
},
}),
);
feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }));
feed(
ev({
type: 'task.terminated',
info: {
taskId: 'task-9',
kind: 'agent',
description: 'Inspect files',
status: 'completed',
detached: true,
agentId: 'agent-1',
startedAt: 1_700_000_000_000,
endedAt: 1_700_000_001_000,
},
}),
);
expect(tx.getTask('task-9')).toMatchObject({
kind: 'subagent',
state: 'completed',
agentId: 'agent-1',
description: 'Inspect files',
detached: true,
resultSummary: 'done',
});
expect(tx.getTask('agent-1')).toBeUndefined();
});
it('drops the stale task mapping when a child respawns without a task id', () => {
const projector = new AgentTranscriptProjector('main');
const tx = new AgentTranscript('main');
const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event));
feed(
ev({
type: 'subagent.spawned',
subagentId: 'agent-1',
subagentName: 'explore',
parentToolCallId: 'call-1',
description: 'Inspect files',
runInBackground: true,
taskId: 'task-9',
}),
);
feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }));
feed(
ev({
type: 'subagent.spawned',
subagentId: 'agent-1',
subagentName: 'worker',
parentToolCallId: 'call-2',
description: 'scan again',
runInBackground: false,
}),
);
feed(ev({ type: 'subagent.started', subagentId: 'agent-1' }));
expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' });
expect(tx.getTask('agent-1')).toMatchObject({ kind: 'subagent', state: 'running' });
});
it('recovers the agent → task association from a backfilled task.started', () => {
const projector = new AgentTranscriptProjector('main');
const tx = new AgentTranscript('main');
const feed = (event: ProjectorBusEvent): void => void tx.apply(projector.map(event));
feed(
ev({
type: 'task.started',
info: {
taskId: 'task-9',
kind: 'agent',
description: 'Inspect files',
status: 'running',
detached: true,
agentId: 'agent-1',
startedAt: 1_700_000_000_000,
endedAt: null,
},
}),
);
feed(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }));
expect(tx.getTask('task-9')).toMatchObject({ state: 'completed', resultSummary: 'done' });
expect(tx.getTask('agent-1')).toBeUndefined();
});
it('projects goal updates into meta.goal plus an inline marker', () => {
const projector = new AgentTranscriptProjector('main');
const tx = new AgentTranscript('main');
@ -1811,7 +1930,7 @@ describe('bindSessionTranscript', () => {
this.disposeHandlers.add(cb);
return { dispose: () => this.disposeHandlers.delete(cb) };
}
add(id: string, opts?: { loopStatus?: unknown }): FakeAgentHandle {
add(id: string, opts?: { loopStatus?: unknown; tasks?: readonly unknown[] }): FakeAgentHandle {
const bus = new FakeBus();
const handle: FakeAgentHandle = {
id,
@ -1822,6 +1941,9 @@ describe('bindSessionTranscript', () => {
if (token === IAgentLoopService) {
return { status: () => opts?.loopStatus ?? { state: 'idle' } };
}
if (token === IAgentTaskService) {
return { list: () => opts?.tasks ?? [] };
}
return undefined;
},
},
@ -1911,6 +2033,46 @@ describe('bindSessionTranscript', () => {
binding.dispose();
});
it('seeds pre-attach Agent task mappings so a late-bound projector folds the lifecycle', () => {
const agents = new FakeAgents();
agents.add('main', {
tasks: [
{
taskId: 'task-9',
kind: 'agent',
agentId: 'agent-1',
status: 'running',
description: 'Inspect',
detached: false,
startedAt: 1_700_000_000_000,
},
],
});
const store = new TranscriptStore('s1');
const binding = bindSessionTranscript(
store,
fakeSession(new SessionInteractionService(new TestSessionStateService()), agents),
);
expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({
kind: 'subagent',
state: 'running',
detached: false,
description: 'Inspect',
agentId: 'agent-1',
});
agents.get('main')!.bus.emit(ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }));
expect(store.getAgent('main')?.getTask('task-9')).toMatchObject({
state: 'completed',
resultSummary: 'done',
detached: false,
});
expect(store.getAgent('main')?.getTask('agent-1')).toBeUndefined();
binding.dispose();
});
const SHOT_PNG_UPLOAD = {
type: 'file',
file_id: 'file_1',

View file

@ -864,6 +864,11 @@ export interface SubagentSpawnedEvent {
/** The child's effective thinking effort at spawn (same vocabulary as
* `agent.status.updated`). Optional for cross-version tolerance. */
readonly thinkingEffort?: string;
/** Background-task id the run registered under in the caller's task store.
* Emitted after task registration, so cancel/status actions can bind to
* the task store without waiting for `task.started`. Optional for
* cross-version tolerance (older producers never send it). */
readonly taskId?: string;
}
export interface SubagentStartedEvent {
@ -1800,6 +1805,7 @@ export const subagentSpawnedEventSchema = z.object({
runInBackground: z.boolean(),
model: z.string().optional(),
thinkingEffort: z.string().optional(),
taskId: z.string().optional(),
}) satisfies z.ZodType<SubagentSpawnedEvent>;
export const subagentStartedEventSchema = z.object({