fix(agent-core-v2): preserve swarm metadata compatibility

This commit is contained in:
_Kerman 2026-07-08 15:11:15 +08:00
parent 19f90dff27
commit 69df799810
13 changed files with 534 additions and 67 deletions

View file

@ -46,6 +46,7 @@ package "App scope (process-wide)" #EAF3FB {
rectangle "<b>chatProvider</b>\n<size:9><i>App</i></size>\n IChatProviderFactory" as chatProvider #D6EAF8
rectangle "<b>model</b>\n<size:9><i>App</i></size>\n IModelService" as model #D6EAF8
rectangle "<b>modelCatalog</b>\n<size:9><i>App</i></size>\n IModelCatalogService" as modelCatalog #D6EAF8
rectangle "<b>agentProfileCatalog</b>\n<size:9><i>App</i></size>\n IAgentProfileCatalogService" as agentProfileCatalog #D6EAF8
rectangle "<b>skillCatalog</b>\n<size:9><i>App</i></size>\n ISkillDiscovery\n ISkillSource\n Builtin/UserSkillSource" as skillCatalog #D6EAF8
}
@ -54,6 +55,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>sessionMetadata</b>\n<size:9><i>Session</i></size>\n ISessionMetadata" as session_metadata #D5F5E3
rectangle "<b>sessionActivity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
rectangle "<b>agentLifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "<b>sessionSwarm</b>\n<size:9><i>Session</i></size>\n ISessionSwarmService" as sessionSwarm #D5F5E3
rectangle "<b>interaction</b>\n<size:9><i>Session</i></size>\n IInteractionService" as interaction #D5F5E3
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "<b>workspaceCommand</b>\n<size:9><i>Session</i></size>\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
@ -171,6 +173,12 @@ session_metadata --> log #34495E
agent_lifecycle --> storage #34495E
session_activity --> agent_lifecycle #34495E
session_activity --> interaction #34495E
sessionSwarm --> agent_lifecycle #34495E
sessionSwarm --> agentProfileCatalog #34495E
sessionSwarm --> session_context #34495E
sessionSwarm --> session_metadata #34495E
sessionSwarm --> process #34495E
sessionSwarm --> log #34495E
wireRecord --> blobStore #34495E
wireRecord --> bootstrap #34495E
wireRecord --> storage #34495E

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Before After
Before After

View file

@ -2,10 +2,10 @@
* `swarm` domain (L4) `AgentSwarm` collaboration tool.
*
* Launches a batch of child agents (an ordinary Agent scope each) through the
* session swarm coordinator and renders the per-subagent XML result. Keeps a
* module-level map of spawned agent id swarm item so a later
* `resume_agent_ids` call can relabel resumed subagents. Pure tool owns no
* scoped state.
* session swarm coordinator and renders the per-subagent XML result. Reads
* persisted swarm item labels through the Session-scoped coordinator so later
* `resume_agent_ids` calls relabel resumed subagents like v1. Pure tool
* owns no scoped state.
*/
import { z } from 'zod';
@ -24,8 +24,6 @@ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
const MAX_AGENT_SWARM_SUBAGENTS = 128;
const swarmItems = new Map<string, string>();
export const AgentSwarmToolInputSchema = z
.object({
description: z
@ -39,7 +37,7 @@ export const AgentSwarmToolInputSchema = z
.min(1)
.optional()
.describe(
'Subagent type used for every spawned subagent. Defaults to coder when omitted.',
'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.',
),
prompt_template: z
.string()
@ -147,7 +145,9 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
toolCallId: string,
): Promise<string> {
const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE;
const specs = createAgentSwarmSpecs(args, (id) => swarmItems.get(id));
const specs = await createAgentSwarmSpecs(args, (agentId) =>
this.swarmService.getSwarmItem({ callerAgentId: this.callerAgentId, agentId }),
);
const tasks: SessionSwarmTask<AgentSwarmSpec>[] = specs.map((spec) => {
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
const common = {
@ -178,11 +178,6 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
callerAgentId: this.callerAgentId,
tasks,
});
for (const result of results) {
if (result.agentId !== undefined && result.task.swarmItem !== undefined) {
swarmItems.set(result.agentId, result.task.swarmItem);
}
}
return renderSwarmResults(
results.map(({ task, ...result }) => ({ spec: task.data as AgentSwarmSpec, ...result })),
);
@ -191,10 +186,10 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
registerTool(AgentSwarmTool);
function createAgentSwarmSpecs(
async function createAgentSwarmSpecs(
args: AgentSwarmToolInput,
getResumeItem: (agentId: string) => string | undefined,
): AgentSwarmSpec[] {
getResumeItem: (agentId: string) => Promise<string | undefined>,
): Promise<AgentSwarmSpec[]> {
const resumeEntries = Object.entries(args.resume_agent_ids ?? {}).map(([agentId, prompt]) => ({
agentId: agentId.trim(),
prompt: prompt.trim(),
@ -226,7 +221,7 @@ function createAgentSwarmSpecs(
kind: 'resume',
index: specs.length + 1,
agentId: entry.agentId,
item: getResumeItem(entry.agentId),
item: await getResumeItem(entry.agentId),
prompt: entry.prompt,
});
}

View file

@ -32,6 +32,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import { ErrorCodes, KimiError } from '#/errors';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionActivity } from '#/session/sessionActivity/sessionActivity';
import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
@ -307,13 +308,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
// log. Creating them registers fresh agent entries with TARGET homedirs.
for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
const legacy = sourceAgent as { parentAgentId?: string };
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom ?? legacy.parentAgentId,
labels:
sourceAgent.labels ??
(sourceAgent.swarmItem !== undefined ? { swarmItem: sourceAgent.swarmItem } : undefined),
forkedFrom: sourceAgent.forkedFrom,
labels: labelsFromAgentMeta(sourceAgent),
});
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
await forkWireRecord.restore();

View file

@ -0,0 +1,55 @@
/**
* `agentLifecycle` domain (L6) persisted subagent relationship labels.
*
* Provides the label helpers used by caller-owned agent-run wrappers (`Agent`
* and `AgentSwarm`) to record and read the requester subagent relationship
* without making the flat lifecycle registry interpret parentage itself.
*/
import type { AgentMeta } from '#/session/sessionMetadata/sessionMetadata';
export function subagentLabels(
parentAgentId: string,
options: { readonly swarmItem?: string } = {},
): Readonly<Record<string, string>> {
const labels: Record<string, string> = { parentAgentId };
if (options.swarmItem !== undefined) {
labels['swarmItem'] = options.swarmItem;
}
return labels;
}
export function labelsFromAgentMeta(
meta: AgentMeta,
): Readonly<Record<string, string>> | undefined {
const labels: Record<string, string> = { ...meta.labels };
const parentAgentId = subagentParentAgentId(meta);
if (parentAgentId !== undefined) {
labels['parentAgentId'] = parentAgentId;
}
const swarmItem = subagentSwarmItem(meta);
if (swarmItem !== undefined) {
labels['swarmItem'] = swarmItem;
}
return Object.keys(labels).length > 0 ? labels : undefined;
}
export function isSubagentMeta(meta: AgentMeta | undefined): boolean {
if (meta === undefined) return false;
if (subagentParentAgentId(meta) !== undefined) return true;
return meta.type === 'sub';
}
export function subagentParentAgentId(meta: AgentMeta | undefined): string | undefined {
if (meta === undefined) return undefined;
return firstNonEmpty(meta.labels?.['parentAgentId'], meta.parentAgentId ?? undefined);
}
export function subagentSwarmItem(meta: AgentMeta | undefined): string | undefined {
if (meta === undefined) return undefined;
return firstNonEmpty(meta.labels?.['swarmItem'], meta.swarmItem);
}
function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined {
return values.find((value) => value !== undefined && value.length > 0);
}

View file

@ -42,6 +42,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo
import { IAgentLifecycleService } from '../agentLifecycle';
import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun';
import { subagentLabels } from '../subagentMetadata';
import { SubagentTask, type SubagentHandle } from './subagent-task';
import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw';
@ -253,6 +254,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
cwd: own.cwd,
},
permissionMode: this.permissionMode.mode,
labels: subagentLabels(this.callerAgentId),
});
agentId = created.id;
profileName = profile.name;

View file

@ -24,6 +24,10 @@ export interface AgentMeta {
* verbatim. Never interpreted by the lifecycle.
*/
readonly labels?: Readonly<Record<string, string>>;
/** @deprecated Legacy v1 field; read-compat only. */
readonly type?: 'main' | 'sub';
/** @deprecated Legacy v1 field; read-compat only. */
readonly parentAgentId?: string | null;
/** @deprecated Legacy on-disk field predating `labels`; read-compat only. */
readonly swarmItem?: string;
}

View file

@ -55,6 +55,10 @@ export interface SessionSwarmRunResult<T = unknown> {
export interface ISessionSwarmService {
readonly _serviceBrand: undefined;
getSwarmItem(args: {
readonly callerAgentId: string;
readonly agentId: string;
}): Promise<string | undefined>;
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]>;
cancel(args: { readonly callerAgentId: string }): void;
}

View file

@ -27,7 +27,14 @@ import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProf
import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/agentLifecycle/mirrorAgentRun';
import {
isSubagentMeta,
subagentLabels,
subagentParentAgentId,
subagentSwarmItem,
} from '#/session/agentLifecycle/subagentMetadata';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ILogService } from '#/_base/log/log';
@ -67,10 +74,21 @@ export class SessionSwarmService implements ISessionSwarmService {
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService,
@ISessionContext private readonly sessionContext: ISessionContext,
@ISessionMetadata private readonly metadata: ISessionMetadata,
@ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
@ILogService private readonly log: ILogService,
) {}
async getSwarmItem(args: {
readonly callerAgentId: string;
readonly agentId: string;
}): Promise<string | undefined> {
const meta = await this.agentMeta(args.agentId);
if (!isSubagentMeta(meta)) return undefined;
if (subagentParentAgentId(meta) !== args.callerAgentId) return undefined;
return subagentSwarmItem(meta);
}
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]> {
const { callerAgentId, tasks } = args;
const controller = new AbortController();
@ -131,7 +149,7 @@ export class SessionSwarmService implements ISessionSwarmService {
cwd: callerData.cwd,
},
permissionMode: caller.accessor.get(IAgentPermissionModeService).mode,
labels: options.swarmItem === undefined ? undefined : { swarmItem: options.swarmItem },
labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }),
});
emitAgentRunSpawned(caller, child.id, {
profileName: options.profileName,
@ -159,6 +177,7 @@ export class SessionSwarmService implements ISessionSwarmService {
retryTurn: boolean,
): Promise<AgentRunAttemptHandle> {
options.signal.throwIfAborted();
await this.requireOwnedSubagent(callerAgentId, agentId);
const caller = this.requireHandle(callerAgentId, 'Caller agent');
const child = this.requireHandle(agentId, 'Agent instance');
const profileName =
@ -206,6 +225,21 @@ export class SessionSwarmService implements ISessionSwarmService {
if (handle === undefined) throw new Error(`${label} "${agentId}" does not exist`);
return handle;
}
private async requireOwnedSubagent(callerAgentId: string, agentId: string): Promise<void> {
const meta = await this.agentMeta(agentId);
if (!isSubagentMeta(meta)) {
throw new Error(`Agent instance "${agentId}" is not a subagent`);
}
if (subagentParentAgentId(meta) !== callerAgentId) {
throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`);
}
}
private async agentMeta(agentId: string): Promise<AgentMeta | undefined> {
const meta = await this.metadata.read();
return meta.agents?.[agentId];
}
}
// Kept as a type-anchor so future maintenance imports the usage shape from here.

View file

@ -157,6 +157,7 @@ const TEST_HOME_DIR = '/home/test';
const MOCK_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
baseUrl: 'https://api.example.test/v1',
model: 'mock-model',
} as const;
@ -639,9 +640,20 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog
};
}
export function swarmServices(swarmService: ISessionSwarmService): TestAgentServiceOverride {
export function swarmServices(
swarmService: ISessionSwarmService | ISessionSwarmService['run'],
): TestAgentServiceOverride {
const service =
typeof swarmService === 'function'
? {
_serviceBrand: undefined,
getSwarmItem: async () => undefined,
run: swarmService,
cancel: () => {},
} satisfies ISessionSwarmService
: swarmService;
return [
sessionService(ISessionSwarmService, swarmService),
sessionService(ISessionSwarmService, service),
agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService)),
];
}

View file

@ -1,5 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { LifecycleScope } from '#/_base/di/scope';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { Event } from '#/_base/event';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile';
import { IEventBus, type DomainEvent } from '#/app/event/eventBus';
import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
import {
IAgentLifecycleService,
type AgentTaskHooks,
type CreateAgentOptions,
} from '#/session/agentLifecycle/agentLifecycle';
import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
import { createHooks } from '#/hooks';
import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext';
import {
ISessionMetadata,
type AgentMeta,
type SessionMetadataChangedEvent,
} from '#/session/sessionMetadata/sessionMetadata';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ILogService } from '#/_base/log/log';
import {
AgentRunBatch,
resolveSwarmMaxConcurrency,
@ -7,6 +33,10 @@ import {
type AgentSpawnAttemptOptions,
type QueuedAgentRunTask,
} from '#/session/swarm/agentRunBatch';
import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm';
import { SessionSwarmService } from '#/session/swarm/sessionSwarmService';
import { stubLog } from '../log/stubs';
describe('resolveSwarmMaxConcurrency', () => {
it('returns undefined when the variable is unset', () => {
@ -94,3 +124,323 @@ describe('AgentRunBatch swarm item forwarding', () => {
expect(spawned[0]?.swarmItem).toBeUndefined();
});
});
describe('SessionSwarmService metadata compatibility', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
let agents: Record<string, AgentMeta>;
let handles: Map<string, IAgentScopeHandle>;
let lifecycle: IAgentLifecycleService;
let createAgent: ReturnType<typeof vi.fn>;
let runAgent: ReturnType<typeof vi.fn>;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
agents = {};
handles = new Map();
const eventBus = eventBusStub();
lifecycle = lifecycleStub(handles, eventBus);
createAgent = lifecycle.create as ReturnType<typeof vi.fn>;
runAgent = lifecycle.run as ReturnType<typeof vi.fn>;
handles.set('main', agentHandle('main', lifecycle, eventBus));
ix.stub(IAgentLifecycleService, lifecycle);
ix.stub(IAgentProfileCatalogService, {
_serviceBrand: undefined,
get: (name: string) =>
name === 'coder'
? { name: 'coder', tools: [], systemPrompt: () => '' }
: undefined,
getDefault: () => ({ name: 'agent', tools: [], systemPrompt: () => '' }),
list: () => [],
});
ix.stub(
ISessionContext,
makeSessionContext({
sessionId: 's1',
workspaceId: 'w1',
sessionDir: '/tmp/kimi/s1',
sessionScope: 'sessions/w1/s1',
cwd: '/repo',
}),
);
ix.stub(ISessionMetadata, {
_serviceBrand: undefined,
ready: Promise.resolve(),
onDidChangeMetadata: Event.None as Event<SessionMetadataChangedEvent>,
read: async () => ({
id: 's1',
createdAt: 0,
updatedAt: 0,
archived: false,
agents,
}),
update: async () => {},
setTitle: async () => {},
setArchived: async () => {},
registerAgent: async (agentId, meta) => {
agents[agentId] = meta;
},
});
ix.stub(ISessionProcessRunner, {
_serviceBrand: undefined,
exec: async () => {
throw new Error('unexpected process exec');
},
});
ix.stub(ILogService, stubLog());
ix.set(ISessionSwarmService, new SyncDescriptor(SessionSwarmService));
});
afterEach(() => {
disposables.dispose();
});
it('reads swarm items from caller-owned v2 labels and legacy v1 metadata', async () => {
agents['v2-child'] = {
homedir: '/tmp/kimi/s1/agents/v2-child',
labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' },
};
agents['legacy-child'] = {
homedir: '/tmp/kimi/s1/agents/legacy-child',
type: 'sub',
parentAgentId: 'main',
swarmItem: 'src/legacy.ts',
};
agents['other-child'] = {
homedir: '/tmp/kimi/s1/agents/other-child',
labels: { parentAgentId: 'other', swarmItem: 'src/other.ts' },
};
const service = ix.get(ISessionSwarmService);
await expect(
service.getSwarmItem({ callerAgentId: 'main', agentId: 'v2-child' }),
).resolves.toBe('src/a.ts');
await expect(
service.getSwarmItem({ callerAgentId: 'main', agentId: 'legacy-child' }),
).resolves.toBe('src/legacy.ts');
await expect(
service.getSwarmItem({ callerAgentId: 'main', agentId: 'other-child' }),
).resolves.toBeUndefined();
await expect(
service.getSwarmItem({ callerAgentId: 'main', agentId: 'missing' }),
).resolves.toBeUndefined();
});
it('prefers labels over legacy metadata fields when both are present', async () => {
agents['mixed-child'] = {
homedir: '/tmp/kimi/s1/agents/mixed-child',
labels: { parentAgentId: 'main', swarmItem: 'src/labels.ts' },
type: 'sub',
parentAgentId: 'other',
swarmItem: 'src/legacy.ts',
};
const service = ix.get(ISessionSwarmService);
await expect(
service.getSwarmItem({ callerAgentId: 'main', agentId: 'mixed-child' }),
).resolves.toBe('src/labels.ts');
await expect(
service.getSwarmItem({ callerAgentId: 'other', agentId: 'mixed-child' }),
).resolves.toBeUndefined();
});
it('normalizes legacy subagent metadata into labels for new writes', () => {
expect(
labelsFromAgentMeta({
homedir: '/tmp/kimi/s1/agents/legacy-child',
type: 'sub',
parentAgentId: 'main',
swarmItem: 'src/legacy.ts',
}),
).toEqual({ parentAgentId: 'main', swarmItem: 'src/legacy.ts' });
expect(
labelsFromAgentMeta({
homedir: '/tmp/kimi/s1/agents/mixed-child',
labels: { parentAgentId: 'main', swarmItem: 'src/labels.ts', custom: 'kept' },
type: 'sub',
parentAgentId: 'other',
swarmItem: 'src/legacy.ts',
}),
).toEqual({ parentAgentId: 'main', swarmItem: 'src/labels.ts', custom: 'kept' });
});
it('persists caller ownership and swarm item labels on spawned children', async () => {
const service = ix.get(ISessionSwarmService);
await expect(
service.run({
callerAgentId: 'main',
tasks: [spawnSessionTask('src/a.ts')],
}),
).resolves.toMatchObject([
{
agentId: 'agent-new',
status: 'completed',
result: 'child summary',
},
]);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
binding: {
profile: 'coder',
model: 'kimi-test',
thinking: 'medium',
cwd: '/repo',
},
permissionMode: 'auto',
labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' },
}),
);
});
it('keeps v1 resume ownership errors inside the per-subagent result', async () => {
agents['other-child'] = {
homedir: '/tmp/kimi/s1/agents/other-child',
labels: { parentAgentId: 'other', swarmItem: 'src/other.ts' },
};
handles.set('other-child', agentHandle('other-child', lifecycle, eventBusStub()));
const service = ix.get(ISessionSwarmService);
await expect(
service.run({
callerAgentId: 'main',
tasks: [resumeSessionTask('other-child')],
}),
).resolves.toMatchObject([
{
status: 'failed',
state: 'not_started',
error: 'Agent instance "other-child" does not belong to this parent agent',
},
]);
expect(runAgent).not.toHaveBeenCalled();
});
});
function spawnSessionTask(swarmItem?: string): SessionSwarmTask {
return {
kind: 'spawn',
data: {},
profileName: 'coder',
parentToolCallId: 'call_swarm',
prompt: 'Review the file',
description: 'Review #1 (coder)',
swarmIndex: 1,
swarmItem,
runInBackground: false,
};
}
function resumeSessionTask(agentId: string): SessionSwarmTask {
return {
kind: 'resume',
data: {},
profileName: 'subagent',
parentToolCallId: 'call_swarm',
prompt: 'Continue',
description: 'Resume #1 (resume)',
swarmIndex: 1,
runInBackground: false,
resumeAgentId: agentId,
};
}
function lifecycleStub(
handles: Map<string, IAgentScopeHandle>,
eventBus: IEventBus,
): IAgentLifecycleService {
const hooks = createHooks<AgentTaskHooks, keyof AgentTaskHooks>([
'onWillStartAgentTask',
'onDidStopAgentTask',
]);
const lifecycle = {
_serviceBrand: undefined,
hooks,
onDidCreate: Event.None,
onDidCreateMain: Event.None,
onDidDispose: Event.None,
create: vi.fn(async (opts: CreateAgentOptions = {}) => {
const id = opts.agentId ?? 'agent-new';
const handle = agentHandle(id, lifecycle as IAgentLifecycleService, eventBus, {
profileName: opts.binding?.profile ?? 'coder',
modelAlias: opts.binding?.model ?? 'kimi-test',
thinkingLevel: opts.binding?.thinking ?? 'medium',
cwd: opts.binding?.cwd ?? '/repo',
});
handles.set(id, handle);
return handle;
}),
ensureMcpReady: async () => {},
notifyMainCreated: () => {},
fork: vi.fn(),
run: vi.fn(async (agentId: string) => ({
agentId,
turn: {} as never,
completion: Promise.resolve({ summary: 'child summary' }),
})),
getHandle: (agentId: string) => handles.get(agentId),
list: () => [...handles.values()],
remove: async (agentId: string) => {
handles.delete(agentId);
},
};
return lifecycle as IAgentLifecycleService;
}
function agentHandle(
id: string,
lifecycle: IAgentLifecycleService,
eventBus: IEventBus,
data: Partial<ProfileData> = {},
): IAgentScopeHandle {
const profile = profileService({
cwd: '/repo',
modelAlias: 'kimi-test',
modelCapabilities: {} as never,
profileName: 'agent',
thinkingLevel: 'medium',
systemPrompt: '',
...data,
});
const permissionMode = {
_serviceBrand: undefined,
mode: 'auto',
setMode: () => {},
hooks: createHooks(['onChanged']),
} as IAgentPermissionModeService;
return {
id,
kind: LifecycleScope.Agent,
accessor: {
get: ((serviceId: unknown) => {
if (serviceId === IAgentProfileService) return profile;
if (serviceId === IAgentPermissionModeService) return permissionMode;
if (serviceId === IEventBus) return eventBus;
if (serviceId === ITelemetryService) return noopTelemetryService;
if (serviceId === IAgentLifecycleService) return lifecycle;
return undefined;
}) as IAgentScopeHandle['accessor']['get'],
},
dispose: () => {},
};
}
function profileService(data: ProfileData): IAgentProfileService {
return {
_serviceBrand: undefined,
data: () => data,
} as IAgentProfileService;
}
function eventBusStub(): IEventBus {
return {
_serviceBrand: undefined,
publish: vi.fn((_: DomainEvent) => {}),
subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'],
};
}

View file

@ -45,12 +45,15 @@ function context<Input>(
function mockSwarmHost({
run = vi.fn().mockResolvedValue([]),
getSwarmItem = vi.fn().mockResolvedValue(undefined),
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly run?: (...args: any[]) => any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly getSwarmItem?: (...args: any[]) => any;
} = {}) {
return {
swarmService: { _serviceBrand: undefined, run, cancel: vi.fn() },
swarmService: { _serviceBrand: undefined, getSwarmItem, run, cancel: vi.fn() },
callerAgentId: 'main',
};
}
@ -78,7 +81,11 @@ describe('AgentSwarmService', () => {
ix.stub(IAgentTurnService, stubTurnWithHooks());
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.stub(IAgentLifecycleService, {});
ix.stub(ISessionSwarmService, { run: async () => [], cancel: () => {} });
ix.stub(ISessionSwarmService, {
getSwarmItem: async () => undefined,
run: async () => [],
cancel: () => {},
});
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
@ -200,6 +207,16 @@ describe('AgentSwarmTool', () => {
subagent_type: { type: 'string' },
},
});
expect(
(
tool.parameters['properties'] as Record<
string,
{ readonly description?: string }
>
)['subagent_type']?.description,
).toBe(
'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.',
);
expect(Object.keys(tool.parameters['properties'] as Record<string, unknown>).at(-1)).toBe(
'resume_agent_ids',
);
@ -340,38 +357,29 @@ describe('AgentSwarmTool', () => {
});
it('resumes mapped agents before spawning item subagents', async () => {
let runCallCount = 0;
const run = vi.fn(
async <T>({
tasks,
}: {
tasks: readonly SessionSwarmTask<T>[];
}): Promise<Array<SessionSwarmRunResult<T>>> => {
runCallCount++;
return tasks.map((task, index) => ({
task,
agentId:
task.kind === 'resume'
? task.resumeAgentId
: runCallCount === 1
? `agent-old-${String(index + 1)}`
: `agent-new-${String(index + 1)}`,
agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`,
status: 'completed' as const,
result: `result ${String(index + 1)}`,
}));
},
);
const host = mockSwarmHost({ run });
const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode());
// Seed the module-level swarm item map so resume_agent_ids can recover the original items.
await executeTool(
tool,
context({
description: 'Seed swarm items',
prompt_template: 'Review {{item}}',
items: ['src/old-a.ts', 'src/old-b.ts'],
}),
const persistedItems: Record<string, string> = {
'agent-old-1': 'src/old-a.ts',
'agent-old-2': 'src/old-b.ts',
};
const getSwarmItem = vi.fn(
async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId],
);
const host = mockSwarmHost({ run, getSwarmItem });
const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode());
const input = {
description: 'Finish review',
subagent_type: 'explore',
@ -393,6 +401,14 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
expect(getSwarmItem).toHaveBeenCalledWith({
callerAgentId: 'main',
agentId: 'agent-old-1',
});
expect(getSwarmItem).toHaveBeenCalledWith({
callerAgentId: 'main',
agentId: 'agent-old-2',
});
expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',
@ -467,38 +483,23 @@ describe('AgentSwarmTool', () => {
});
it('allows a single resumed subagent without item subagents', async () => {
let runCallCount = 0;
const run = vi.fn(
async <T>({
tasks,
}: {
tasks: readonly SessionSwarmTask<T>[];
}): Promise<Array<SessionSwarmRunResult<T>>> => {
runCallCount++;
return tasks.map((task, index) => ({
task,
agentId:
task.kind === 'resume'
? task.resumeAgentId
: runCallCount === 1
? `agent-old-${String(index + 1)}`
: 'agent-new',
agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`,
status: 'completed' as const,
result: 'resumed result',
}));
},
);
const host = mockSwarmHost({ run });
const getSwarmItem = vi.fn(async () => 'src/old-a.ts');
const host = mockSwarmHost({ run, getSwarmItem });
const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode());
// Seed the module-level swarm item map so resume_agent_ids can recover the original item.
await executeTool(
tool,
context({
description: 'Seed swarm items',
prompt_template: 'Review {{item}}',
items: ['src/old-a.ts', 'src/old-b.ts'],
}),
);
const input = {
description: 'Resume review',
resume_agent_ids: {
@ -508,6 +509,10 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
expect(getSwarmItem).toHaveBeenCalledWith({
callerAgentId: 'main',
agentId: 'agent-old-1',
});
expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',

View file

@ -358,7 +358,7 @@ describe('Agent turn flow', () => {
await ctx.expectResumeMatches();
});
it('removes a replayed swarm enter reminder when restoring swarm exit', async () => {
it('restores swarm exit without synthesizing reminder cleanup records', async () => {
const ctx = testAgent();
const enterReminder: ContextMessage = {
role: 'user',
@ -384,7 +384,7 @@ describe('Agent turn flow', () => {
]);
expect(ctx.get(IAgentSwarmService).isActive).toBe(false);
expect(ctx.contextData().history).toEqual([]);
expect(ctx.contextData().history).toEqual([enterReminder]);
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] swarm_mode.enter { "trigger": "manual" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<system-reminder>\\nlegacy swarm enter reminder\\n</system-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } ] }