mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: restore resumed subagents after an adapter restart (#1364)
After a restart the AIR subagent map is empty, so a SendMessage resume opened no generation and the child's messages stayed buffered under the old launch id. Restore the agent from the session history on a resume signal or on a message with an unseen parent_tool_use_id, with a generation id that can't collide with ids the client stored before.
This commit is contained in:
@@ -188,6 +188,7 @@ import {
|
||||
readSubagentMessages,
|
||||
subagentHistory,
|
||||
subagentLaunchIds,
|
||||
subagentLaunchedBy,
|
||||
subagentLineage,
|
||||
} from "./subagent-history.js";
|
||||
import {
|
||||
@@ -4329,6 +4330,7 @@ export class ClaudeAcpAgent {
|
||||
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
this.logger,
|
||||
(agentId) => subagentLineage(params.sessionId, agentId, this.logger),
|
||||
(launchId) => subagentLaunchedBy(params.sessionId, launchId),
|
||||
));
|
||||
// Adapter-composed advisories (hook feedback, model fallbacks) are live
|
||||
// events, not something the model said. Clients on the notice contract get
|
||||
|
||||
@@ -108,6 +108,12 @@ export type HistoricalSubagent = {
|
||||
*/
|
||||
export type SubagentLineage = (agentId: string) => Promise<readonly HistoricalSubagent[]>;
|
||||
|
||||
/**
|
||||
* The agent that the Agent or Task call `launchId` launched, from the session's
|
||||
* subagent history; undefined when the history has no such call.
|
||||
*/
|
||||
export type LaunchedAgent = (launchId: string) => Promise<string | undefined>;
|
||||
|
||||
const MAX_PENDING_PARENTS = 64;
|
||||
const MAX_PENDING_UPDATES = 256;
|
||||
const MAX_PENDING_UPDATES_PER_PARENT = 32;
|
||||
@@ -136,17 +142,30 @@ export class NativeSubagentRuntime {
|
||||
*/
|
||||
private readonly childByToolCall = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
/** The RFD form: the session that made each SendMessage call. */
|
||||
/** The session that made each SendMessage call. */
|
||||
private readonly senderByToolUse = new Map<string, string>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
private pendingCount = 0;
|
||||
/** The AIR form: agents being restored from the history, by agent id. */
|
||||
private readonly agentRestores = new Map<string, Promise<void>>();
|
||||
/** The AIR form: launch ids being resolved to an agent, by launch id. */
|
||||
private readonly launchRestores = new Map<string, Promise<void>>();
|
||||
/** The AIR form: launch ids that the history has no restorable agent for. */
|
||||
private readonly unresolvedLaunches = new Set<string>();
|
||||
/** The AIR form: agents that the history could not place, so it is read once. */
|
||||
private readonly unrestorableAgents = new Set<string>();
|
||||
/** The latest generation number given to a restored agent. */
|
||||
private lastRestoredGeneration = 0;
|
||||
/** Bumped when the restore state is dropped; a restore that began before it is void. */
|
||||
private restoreEpoch = 0;
|
||||
/** The RFD form: how many times the parent was cancelled (see {@link awaitingUser}). */
|
||||
private parentCancels = 0;
|
||||
|
||||
/**
|
||||
* `form` is undefined for a client without native subagent sessions. The RFD
|
||||
* form restores an agent from before a load or resume with `lineage`.
|
||||
* form restores an agent from before a load or resume with `lineage`. The AIR
|
||||
* form does too, and `launchedAgent` finds the agent that a launch call made.
|
||||
*/
|
||||
constructor(
|
||||
form: SubagentForm | undefined,
|
||||
@@ -155,6 +174,7 @@ export class NativeSubagentRuntime {
|
||||
private readonly publish: Publish,
|
||||
private readonly logger: Logger,
|
||||
private readonly lineage?: SubagentLineage,
|
||||
private readonly launchedAgent?: LaunchedAgent,
|
||||
) {
|
||||
this.form = form;
|
||||
this.enabled = form !== undefined;
|
||||
@@ -174,7 +194,7 @@ export class NativeSubagentRuntime {
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
||||
if (routed && this.form === "rfd") this.rememberSendMessage(routed);
|
||||
if (routed) this.rememberSendMessage(routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
@@ -266,7 +286,13 @@ export class NativeSubagentRuntime {
|
||||
if (this.enabled && claudeMeta?.parentToolUseId) {
|
||||
const child = this.childByParentToolUse.get(claudeMeta.parentToolUseId);
|
||||
if (!child || !child.announced) {
|
||||
this.buffer(claudeMeta.parentToolUseId, notification);
|
||||
const launchId = claudeMeta.parentToolUseId;
|
||||
this.buffer(launchId, notification);
|
||||
// An id that this process never saw is the launch call of an agent
|
||||
// from before a load or resume; a new Agent call is known by now.
|
||||
if (!child && this.form === "air" && this.isUnseenLaunch(launchId)) {
|
||||
await this.restoreLaunch(launchId, deliver);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return this.toChild(child, notification, toolCallId);
|
||||
@@ -294,6 +320,24 @@ export class NativeSubagentRuntime {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The SDK starts an agent from before a restart under the id of the
|
||||
// SendMessage call that resumed it, as in the RFD form (see `restore`).
|
||||
// Its output comes under the id of the call that launched it.
|
||||
if (
|
||||
this.form === "air" &&
|
||||
task.subagentType &&
|
||||
task.toolUseId &&
|
||||
this.senderByToolUse.has(task.toolUseId) &&
|
||||
!this.children.has(task.taskId)
|
||||
) {
|
||||
await this.restoreAgent(task.taskId, deliver, promptText(task.prompt));
|
||||
const restored = this.children.get(task.taskId);
|
||||
if (restored) {
|
||||
this.taskByToolUse.set(task.toolUseId, task.taskId);
|
||||
this.childByParentToolUse.set(task.toolUseId, restored);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!task.subagentType) {
|
||||
if (task.toolUseId) {
|
||||
this.takePending(task.toolUseId);
|
||||
@@ -350,11 +394,17 @@ export class NativeSubagentRuntime {
|
||||
* In the RFD form, the running patch only reports that the child runs: its
|
||||
* prompt is a guess (the latest SendMessage to the agent), and the
|
||||
* `task_started` or the SendMessage result carries the delegation.
|
||||
*
|
||||
* In the AIR form, an agent that the runtime does not know is restored from
|
||||
* the session's history (see {@link restoreAgent}).
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const previous = this.children.get(taskId);
|
||||
if (!previous) return;
|
||||
if (!previous) {
|
||||
if (this.form === "air") await this.restoreAgent(taskId, deliver, prompt);
|
||||
return;
|
||||
}
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.form === "rfd") {
|
||||
@@ -508,6 +558,7 @@ export class NativeSubagentRuntime {
|
||||
this.identityByToolUse.clear();
|
||||
this.controlByToolUse.clear();
|
||||
this.parentByToolUse.clear();
|
||||
this.forgetRestores();
|
||||
}
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
|
||||
@@ -662,6 +713,115 @@ export class NativeSubagentRuntime {
|
||||
return this.children.get(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The AIR form: whether no call of this process made `launchId`. A new Agent
|
||||
* call has a control update before its `task_started`, and a launch that was
|
||||
* restored or looked up is registered or remembered.
|
||||
*/
|
||||
private isUnseenLaunch(launchId: string): boolean {
|
||||
return (
|
||||
!!this.launchedAgent && !this.unresolvedLaunches.has(launchId) && this.isUnseenCall(launchId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether this runtime has no record of the tool call `toolUseId`. */
|
||||
private isUnseenCall(toolUseId: string): boolean {
|
||||
return (
|
||||
!this.taskByToolUse.has(toolUseId) &&
|
||||
!this.childByParentToolUse.has(toolUseId) &&
|
||||
!this.controlByToolUse.has(toolUseId) &&
|
||||
!this.parentByToolUse.has(toolUseId) &&
|
||||
!this.identityByToolUse.has(toolUseId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The AIR form: restores the agent that the call `launchId` launched, once.
|
||||
* Concurrent callers wait for the same lookup. Without an agent, the updates
|
||||
* buffered under `launchId` stay buffered and `launchId` is not looked up again.
|
||||
*/
|
||||
private restoreLaunch(launchId: string, deliver: Publish): Promise<void> {
|
||||
const existing = this.launchRestores.get(launchId);
|
||||
if (existing) return existing;
|
||||
const epoch = this.restoreEpoch;
|
||||
const restoring = (async () => {
|
||||
const agentId = await this.launchedAgent?.(launchId).catch((error: unknown) => {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: failed to read the history of launch ${launchId}: ${error}`,
|
||||
);
|
||||
return undefined;
|
||||
});
|
||||
if (epoch !== this.restoreEpoch) return;
|
||||
if (agentId) await this.restoreAgent(agentId, deliver);
|
||||
if (epoch !== this.restoreEpoch) return;
|
||||
const child = this.childByParentToolUse.get(launchId);
|
||||
if (!child) {
|
||||
this.unresolvedLaunches.add(launchId);
|
||||
return;
|
||||
}
|
||||
// Updates that arrived while the generation was announced.
|
||||
if (child.announced) for (const pending of this.takePending(launchId)) await deliver(pending);
|
||||
})().finally(() => {
|
||||
if (this.launchRestores.get(launchId) === restoring) this.launchRestores.delete(launchId);
|
||||
});
|
||||
this.launchRestores.set(launchId, restoring);
|
||||
return restoring;
|
||||
}
|
||||
|
||||
/**
|
||||
* The AIR form: opens a generation for the agent `agentId` that the runtime
|
||||
* does not know, because it ran before the session was loaded or resumed.
|
||||
* The session's subagent history (see {@link SubagentLineage}) gives the
|
||||
* call that launched it: Claude Code sends the resumed run's messages under
|
||||
* that call's id, so it is the generation's parent tool use, and the updates
|
||||
* buffered under it are delivered. The parent is the agent that made the call
|
||||
* if it is live, else the root. An agent that the history cannot place is
|
||||
* not restored. Concurrent signals for one agent open one generation.
|
||||
*/
|
||||
private restoreAgent(agentId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.lineage || this.children.has(agentId) || this.unrestorableAgents.has(agentId)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const existing = this.agentRestores.get(agentId);
|
||||
if (existing) return existing;
|
||||
const lineage = this.lineage;
|
||||
const epoch = this.restoreEpoch;
|
||||
const restoring = (async () => {
|
||||
const entries = await lineage(agentId).catch((error: unknown) => {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: failed to read the history of subagent ${agentId}: ${error}`,
|
||||
);
|
||||
return [];
|
||||
});
|
||||
if (epoch !== this.restoreEpoch || this.children.has(agentId)) return;
|
||||
const entry = entries.at(-1);
|
||||
if (!entry || entry.agentId !== agentId) {
|
||||
this.unrestorableAgents.add(agentId);
|
||||
return;
|
||||
}
|
||||
const parent = entry.parentAgentId ? this.children.get(entry.parentAgentId) : undefined;
|
||||
await this.openGeneration(
|
||||
agentId,
|
||||
undefined,
|
||||
{
|
||||
parentSessionId:
|
||||
parent && this.isLiveSession(parent.sessionId) ? parent.sessionId : this.rootSessionId,
|
||||
parentToolUseId: entry.launchId,
|
||||
name: subagentDisplayName(entry.name, entry.description, entry.agentType, agentId),
|
||||
task: subagentDescription(undefined, entry.description),
|
||||
...promptField(promptText(prompt)),
|
||||
},
|
||||
true,
|
||||
deliver,
|
||||
true,
|
||||
);
|
||||
})().finally(() => {
|
||||
if (this.agentRestores.get(agentId) === restoring) this.agentRestores.delete(agentId);
|
||||
});
|
||||
this.agentRestores.set(agentId, restoring);
|
||||
return restoring;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the child runs again, and keeps its session. `toolUseId` is
|
||||
* the call that delegated, usually a SendMessage: the SDK runs the resumed
|
||||
@@ -862,6 +1022,16 @@ export class NativeSubagentRuntime {
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
this.forgetRestores();
|
||||
}
|
||||
|
||||
/** Lets a later resume signal restore an agent again, and look up a launch again. */
|
||||
private forgetRestores(): void {
|
||||
this.agentRestores.clear();
|
||||
this.launchRestores.clear();
|
||||
this.unresolvedLaunches.clear();
|
||||
this.unrestorableAgents.clear();
|
||||
this.restoreEpoch++;
|
||||
}
|
||||
|
||||
private takePending(parentToolUseId: string): AcpSessionNotification[] {
|
||||
@@ -932,9 +1102,10 @@ export class NativeSubagentRuntime {
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
restored = false,
|
||||
): Promise<void> {
|
||||
const child: NativeSubagent = {
|
||||
sessionId: this.nextChildSessionId(taskId, previous),
|
||||
sessionId: this.nextChildSessionId(taskId, previous, restored),
|
||||
...fields,
|
||||
};
|
||||
const toolUseId = child.parentToolUseId;
|
||||
@@ -958,8 +1129,25 @@ export class NativeSubagentRuntime {
|
||||
* `subagents/` history, so a fork cannot resume an agent of the original.
|
||||
* If a fork ever can, the RFD requires remapping the copied ids, for
|
||||
* example by deriving the child id from the root session id.
|
||||
*
|
||||
* An agent restored from the history (`restored`) is not on its first
|
||||
* generation: before the restart the client already stored ids such as
|
||||
* `taskId` and `taskId:generation:2`. Its generation is the current time in
|
||||
* milliseconds, so it is `taskId:generation:1760000000000`, and each later
|
||||
* resume in this process adds one.
|
||||
*/
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
private nextChildSessionId(
|
||||
taskId: string,
|
||||
previous: NativeSubagent | undefined,
|
||||
restored = false,
|
||||
): string {
|
||||
if (restored) {
|
||||
// A clear() and a new restore within one millisecond must not repeat an id.
|
||||
const generation = Math.max(Date.now(), this.lastRestoredGeneration + 1);
|
||||
this.lastRestoredGeneration = generation;
|
||||
this.generationByTaskId.set(taskId, generation);
|
||||
return `${taskId}:generation:${generation}`;
|
||||
}
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
return taskId;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
getSessionInfo,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
type SessionMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
import { isNativeSubagentControlTool, type HistoricalSubagent } from "./native-subagents.js";
|
||||
import { findTranscript } from "./resumed-session.js";
|
||||
|
||||
@@ -48,7 +49,7 @@ export async function subagentHistory(sessionId: string): Promise<SubagentHistor
|
||||
const agents = new Map<string, SubagentRecord>();
|
||||
const transcript = await findTranscript(sessionId);
|
||||
if (!transcript) return { agents };
|
||||
const directory = path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
const directory = subagentsDirectory(transcript, sessionId);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(directory);
|
||||
@@ -86,6 +87,22 @@ export async function subagentHistory(sessionId: string): Promise<SubagentHistor
|
||||
return dir ? { agents, dir } : { agents };
|
||||
}
|
||||
|
||||
/** The folder of the subagent transcripts, next to the session transcript `transcript`. */
|
||||
function subagentsDirectory(transcript: string, sessionId: string): string {
|
||||
return path.join(path.dirname(transcript), sessionId, "subagents");
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent that the Agent or Task call `launchId` launched, from the subagent
|
||||
* history of the session `sessionId`.
|
||||
*/
|
||||
export async function subagentLaunchedBy(
|
||||
sessionId: string,
|
||||
launchId: string,
|
||||
): Promise<string | undefined> {
|
||||
return (await subagentHistory(sessionId)).agents.get(launchId)?.agentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent `agentId` and its ancestors, root first, from the subagent history
|
||||
* of the session `sessionId`. The runtime restores an agent from it when Claude
|
||||
@@ -109,15 +126,11 @@ export async function subagentLineage(
|
||||
}
|
||||
if (!launchOf.has(agentId)) return [];
|
||||
|
||||
// Without the project directory, as the replay reads it (`readResumedSession`).
|
||||
const rootLaunches = new Set(
|
||||
launchIdsIn(
|
||||
await getSessionMessages(sessionId).catch((error: unknown) => {
|
||||
logger.error(`Failed to read the transcript of session ${sessionId}:`, error);
|
||||
return [];
|
||||
}),
|
||||
),
|
||||
);
|
||||
// The SDK's readers return only the active chain, which starts after the
|
||||
// last compaction, so the launch calls come from the transcript files.
|
||||
const transcript = await findTranscript(sessionId);
|
||||
if (!transcript) return [];
|
||||
const rootLaunches = await readLaunchIdsFromTranscript(transcript, null, logger);
|
||||
const agentLaunches = new Map<string, Set<string>>();
|
||||
/** The agent whose transcript has the launch `launchId`, `null` for the root's. */
|
||||
const launcherOf = async (launchId: string): Promise<string | null | undefined> => {
|
||||
@@ -125,8 +138,10 @@ export async function subagentLineage(
|
||||
for (const candidate of launchOf.keys()) {
|
||||
let launches = agentLaunches.get(candidate);
|
||||
if (!launches) {
|
||||
launches = new Set(
|
||||
launchIdsIn(await readSubagentMessages(sessionId, candidate, history, logger)),
|
||||
launches = await readLaunchIdsFromTranscript(
|
||||
path.join(subagentsDirectory(transcript, sessionId), `agent-${candidate}.jsonl`),
|
||||
candidate,
|
||||
logger,
|
||||
);
|
||||
agentLaunches.set(candidate, launches);
|
||||
}
|
||||
@@ -170,10 +185,52 @@ export function subagentLaunchIds(content: unknown): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function launchIdsIn(messages: readonly SessionMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
subagentLaunchIds((message as { message?: { content?: unknown } }).message?.content),
|
||||
);
|
||||
/**
|
||||
* The ids of the launch calls in the transcript `file`, every record of it,
|
||||
* including those before a compaction. `owner` is the agent the transcript
|
||||
* belongs to, null for the session's. The session's transcript can hold
|
||||
* sidechain records, which are an agent's, not the root's; an agent's
|
||||
* transcript keeps only the records of that agent. A file that cannot be read
|
||||
* has no launches, so the agent is not placed under the root.
|
||||
*/
|
||||
async function readLaunchIdsFromTranscript(
|
||||
file: string,
|
||||
owner: string | null,
|
||||
logger: { error(...args: unknown[]): void },
|
||||
): Promise<Set<string>> {
|
||||
const launches = new Set<string>();
|
||||
const stream = createReadStream(file, { encoding: "utf8" });
|
||||
try {
|
||||
for await (const line of readline.createInterface({ input: stream, crlfDelay: Infinity })) {
|
||||
if (!line.includes('"tool_use"')) continue;
|
||||
let record: {
|
||||
type?: unknown;
|
||||
isSidechain?: unknown;
|
||||
agentId?: unknown;
|
||||
message?: { content?: unknown };
|
||||
} | null;
|
||||
try {
|
||||
record = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (record?.type !== "assistant") continue;
|
||||
if (
|
||||
owner === null
|
||||
? record.isSidechain === true
|
||||
: record.agentId !== undefined && record.agentId !== owner
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const id of subagentLaunchIds(record.message?.content)) launches.add(id);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read the transcript ${file}:`, error);
|
||||
return new Set();
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
return launches;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7506,22 +7506,8 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
// The SDK reads no transcript for a session id that is not a UUID, as
|
||||
// the test session's is, so it answers with the transcript above.
|
||||
// the test session's is, so it has no session info.
|
||||
vi.mocked(getSessionInfo).mockResolvedValueOnce({ cwd: "/tmp/proj" } as any);
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "a1",
|
||||
session_id: "test-session",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_old", name: "Agent", input: { prompt: "Go" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>);
|
||||
await writeFile(
|
||||
path.join(directory, "test-session", "subagents", "agent-agent-7.meta.json"),
|
||||
JSON.stringify({
|
||||
@@ -8400,6 +8386,167 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** After an adapter restart the child's messages carry the old launch id; SendMessage resumes it. */
|
||||
describe("an agent from before an adapter restart, in the AIR form", () => {
|
||||
/**
|
||||
* Writes the history on disk, as the restarted adapter finds it: the root
|
||||
* launched agent-7 with `toolu_old` before a compaction, which drops that
|
||||
* call from the SDK's active chain. Runs `messages` through a new agent
|
||||
* and returns the lifecycle and output the client saw.
|
||||
*/
|
||||
async function restarted(messages: unknown[]) {
|
||||
const configDir = await mkdtemp(path.join(os.tmpdir(), "claude-acp-air-restore-"));
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
try {
|
||||
const directory = path.join(configDir, "projects", "-tmp-proj");
|
||||
await mkdir(path.join(directory, "test-session", "subagents"), { recursive: true });
|
||||
const record = (uuid: string, parentUuid: string | null, extra: object) =>
|
||||
JSON.stringify({
|
||||
sessionId: "test-session",
|
||||
cwd: "/tmp/proj",
|
||||
version: "2.0.0",
|
||||
userType: "external",
|
||||
isSidechain: false,
|
||||
timestamp: "2026-10-13T00:00:00.000Z",
|
||||
uuid,
|
||||
parentUuid,
|
||||
...extra,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(directory, "test-session.jsonl"),
|
||||
[
|
||||
record("u1", null, { type: "user", message: { role: "user", content: "Go" } }),
|
||||
record("a1", "u1", {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "m-a1",
|
||||
role: "assistant",
|
||||
model: "claude-opus-5",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_old", name: "Agent", input: { prompt: "Go" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
record("b1", null, {
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
content: "Conversation compacted",
|
||||
}),
|
||||
record("u2", "b1", { type: "user", message: { role: "user", content: "More" } }),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
// The test session id is no UUID, so the SDK finds no session info.
|
||||
vi.mocked(getSessionInfo).mockResolvedValue({ cwd: "/tmp/proj" } as any);
|
||||
await writeFile(
|
||||
path.join(directory, "test-session", "subagents", "agent-agent-7.meta.json"),
|
||||
JSON.stringify({
|
||||
agentType: "Explore",
|
||||
description: "Echo a word",
|
||||
name: "echoer",
|
||||
toolUseId: "toolu_old",
|
||||
}),
|
||||
);
|
||||
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: AcpSessionNotification) => {
|
||||
updates.push(update);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: AIR_NATIVE_SUBAGENTS,
|
||||
});
|
||||
injectGeneratorSession(agent, makeGenerator(messages));
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
|
||||
|
||||
return updates.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "subagent_spawned"
|
||||
? [[sessionId, update.sessionUpdate, update.subagentSessionId]]
|
||||
: update.sessionUpdate === "agent_message_chunk"
|
||||
? [[sessionId, update.sessionUpdate, (update.content as { text: string }).text]]
|
||||
: update.sessionUpdate === "subagent_state_update"
|
||||
? [[sessionId, update.sessionUpdate, update.subagentSessionId]]
|
||||
: [],
|
||||
);
|
||||
} finally {
|
||||
vi.mocked(getSessionInfo).mockReset();
|
||||
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
const childMessage = (text: string) => ({
|
||||
type: "stream_event" as const,
|
||||
parent_tool_use_id: "toolu_old",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta" as const,
|
||||
index: 0,
|
||||
delta: { type: "text_delta" as const, text },
|
||||
},
|
||||
});
|
||||
const resumed = {
|
||||
type: "user",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
tool_use_result: { success: true, resumedAgentId: "agent-7" },
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_send", content: "Resuming" }],
|
||||
},
|
||||
};
|
||||
const childId = expect.stringMatching(/^agent-7:generation:\d+$/);
|
||||
|
||||
/** SendMessage resumes the agent; its launch is before a compaction; a repeated resume signal adds nothing. */
|
||||
it("restores it on a SendMessage resume and routes its run to one generation", async () => {
|
||||
const seen = await restarted([
|
||||
rootToolUse("toolu_send", "SendMessage", { to: "agent-7", message: "Say PING" }),
|
||||
{ ...taskStarted("agent-7", "toolu_send"), subagent_type: "Explore", prompt: "Say PING" },
|
||||
childMessage("PING"),
|
||||
resumed,
|
||||
childMessage("PONG"),
|
||||
{ ...resumed, uuid: randomUUID() },
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "agent-7",
|
||||
tool_use_id: "toolu_send",
|
||||
status: "completed",
|
||||
output_file: "",
|
||||
summary: "done",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
},
|
||||
successResult(),
|
||||
]);
|
||||
|
||||
expect(seen).toEqual([
|
||||
["test-session", "subagent_spawned", childId],
|
||||
[childId, "agent_message_chunk", "PING"],
|
||||
[childId, "agent_message_chunk", "PONG"],
|
||||
["test-session", "subagent_state_update", childId],
|
||||
]);
|
||||
});
|
||||
|
||||
/** Only a message under the old launch id arrives, with no resume signal. */
|
||||
it("restores it from a message under the old launch id alone", async () => {
|
||||
const seen = await restarted([childMessage("PING"), childMessage("PONG"), successResult()]);
|
||||
|
||||
expect(seen).toEqual([
|
||||
["test-session", "subagent_spawned", childId],
|
||||
[childId, "agent_message_chunk", "PING"],
|
||||
[childId, "agent_message_chunk", "PONG"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the immediate child as parent for a nested subagent", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
|
||||
@@ -1651,3 +1651,337 @@ describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NativeSubagentRuntime in the AIR form, for an agent from before a load or resume", () => {
|
||||
const worker: HistoricalSubagent = {
|
||||
agentId: "worker-1",
|
||||
launchId: "launch-1",
|
||||
name: "worker",
|
||||
agentType: "Explore",
|
||||
description: "Inspect",
|
||||
};
|
||||
const coordinator: HistoricalSubagent = {
|
||||
agentId: "coordinator-1",
|
||||
launchId: "launch-c",
|
||||
name: "coordinator",
|
||||
agentType: "general-purpose",
|
||||
description: "Coordinate",
|
||||
};
|
||||
const NOW = 1_760_000_000_000;
|
||||
|
||||
const output = (parentToolUseId: string, text = "working"): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text },
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const agentCall = (toolCallId: string): AcpSessionNotification => {
|
||||
const launch = control("tool_call", "pending");
|
||||
return { ...launch, update: { ...launch.update, toolCallId } } as AcpSessionNotification;
|
||||
};
|
||||
const spawned = (published: AcpSessionNotification[]) =>
|
||||
published.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "subagent_spawned"
|
||||
? [`${sessionId} -> ${update.subagentSessionId}`]
|
||||
: [],
|
||||
);
|
||||
|
||||
/** A runtime that knows no child, with the history's lineages and launches. */
|
||||
function reloaded(
|
||||
lineages: Record<string, readonly HistoricalSubagent[]>,
|
||||
{ lookUpLaunches = true } = {},
|
||||
) {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const log = vi.fn();
|
||||
const lineage = vi.fn(async (agentId: string) => lineages[agentId] ?? []);
|
||||
const launchedAgent = vi.fn(
|
||||
async (launchId: string) =>
|
||||
Object.values(lineages)
|
||||
.flat()
|
||||
.find((entry) => entry.launchId === launchId)?.agentId,
|
||||
);
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log },
|
||||
lineage,
|
||||
lookUpLaunches ? launchedAgent : undefined,
|
||||
);
|
||||
return { runtime, published, lineage, launchedAgent, log };
|
||||
}
|
||||
|
||||
/** Runs `test` with the clock at {@link NOW}. */
|
||||
async function atNow(test: () => Promise<void>) {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
try {
|
||||
await test();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}
|
||||
|
||||
/** Output waits under the launch id; a resume signal restores the agent and a later resume numbers on from the first id. */
|
||||
it("restores the agent on a resume signal, delivers its buffered output, and numbers later generations from it", async () => {
|
||||
await atNow(async () => {
|
||||
const { runtime, published } = reloaded({ "worker-1": [worker] }, { lookUpLaunches: false });
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const deliver = async (n: AcpSessionNotification) => void delivered.push(n);
|
||||
|
||||
await expect(runtime.route(output("launch-1", "early"), deliver)).resolves.toBeNull();
|
||||
await runtime.taskResumed("worker-1", deliver, "Say PING");
|
||||
|
||||
expect(spawned(published)).toEqual([`root -> worker-1:generation:${NOW}`]);
|
||||
expect(published[0]?.update).toMatchObject({
|
||||
sessionUpdate: "subagent_spawned",
|
||||
subagentSessionId: `worker-1:generation:${NOW}`,
|
||||
name: "worker",
|
||||
task: "Inspect",
|
||||
prompt: "Say PING",
|
||||
});
|
||||
expect(delivered).toHaveLength(1);
|
||||
await expect(runtime.route(output("launch-1"), deliver)).resolves.toMatchObject({
|
||||
sessionId: `worker-1:generation:${NOW}`,
|
||||
});
|
||||
|
||||
await runtime.finishTask("worker-1", "completed", deliver);
|
||||
await runtime.taskResumed("worker-1", deliver);
|
||||
expect(spawned(published)).toEqual([
|
||||
`root -> worker-1:generation:${NOW}`,
|
||||
`root -> worker-1:generation:${NOW + 1}`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A message under a launch id that the history knows restores the agent with no resume signal. */
|
||||
it("restores the agent from a message under an unknown parent_tool_use_id alone, and routes it", async () => {
|
||||
const { runtime, published, launchedAgent } = reloaded({ "worker-1": [worker] });
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const deliver = async (n: AcpSessionNotification) => void delivered.push(n);
|
||||
|
||||
await expect(runtime.route(output("launch-1"), deliver)).resolves.toBeNull();
|
||||
|
||||
expect(launchedAgent).toHaveBeenCalledWith("launch-1");
|
||||
expect(spawned(published)).toHaveLength(1);
|
||||
expect(delivered).toHaveLength(1);
|
||||
await expect(runtime.route(output("launch-1"), deliver)).resolves.toMatchObject({
|
||||
sessionId: expect.stringMatching(/^worker-1:generation:\d+$/),
|
||||
});
|
||||
});
|
||||
|
||||
/** An Agent call seen in this process waits for its task_started and is never looked up. */
|
||||
it("buffers a new Agent call of this process until its task_started, with no history lookup", async () => {
|
||||
const { runtime, published, launchedAgent } = reloaded({});
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const deliver = async (n: AcpSessionNotification) => void delivered.push(n);
|
||||
|
||||
await runtime.route(agentCall("launch-2"), deliver);
|
||||
await expect(runtime.route(output("launch-2"), deliver)).resolves.toBeNull();
|
||||
expect(launchedAgent).not.toHaveBeenCalled();
|
||||
expect(published).toEqual([]);
|
||||
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "fresh-1", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
expect(spawned(published)).toEqual(["root -> fresh-1"]);
|
||||
expect(delivered).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** A launch id that resolves to no agent is looked up once. */
|
||||
it("looks up a launch id that the history does not know only once", async () => {
|
||||
const { runtime, published, launchedAgent } = reloaded({});
|
||||
|
||||
await runtime.route(output("launch-9"), async () => {});
|
||||
await runtime.route(output("launch-9"), async () => {});
|
||||
|
||||
expect(launchedAgent).toHaveBeenCalledTimes(1);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
/** An agent launched by a live restored agent opens under it. */
|
||||
it("opens a nested agent under its parent while the parent is live", async () => {
|
||||
const { runtime, published } = reloaded({
|
||||
"coordinator-1": [coordinator],
|
||||
"worker-1": [coordinator, { ...worker, parentAgentId: "coordinator-1" }],
|
||||
});
|
||||
await runtime.taskResumed("coordinator-1", async () => {});
|
||||
await runtime.taskResumed("worker-1", async () => {});
|
||||
|
||||
const parentId = (published[0]?.update as { subagentSessionId: string }).subagentSessionId;
|
||||
expect(published[1]?.sessionId).toBe(parentId);
|
||||
expect(spawned(published)).toEqual([
|
||||
`root -> ${parentId}`,
|
||||
expect.stringMatching(/^coordinator-1:generation:\d+ -> worker-1:generation:\d+$/),
|
||||
]);
|
||||
});
|
||||
|
||||
/** An agent whose parent is not live opens under the root. */
|
||||
it("opens a nested agent under the root when its parent is not live", async () => {
|
||||
const { runtime, published } = reloaded({
|
||||
"worker-1": [coordinator, { ...worker, parentAgentId: "coordinator-1" }],
|
||||
});
|
||||
await runtime.taskResumed("worker-1", async () => {});
|
||||
|
||||
expect(published[0]?.sessionId).toBe("root");
|
||||
});
|
||||
|
||||
/** An agent that the history cannot place is not announced. */
|
||||
it("opens nothing when the history cannot place the agent", async () => {
|
||||
const { runtime, published, lineage } = reloaded({});
|
||||
|
||||
await runtime.taskResumed("worker-1", async () => {});
|
||||
|
||||
expect(lineage).toHaveBeenCalledWith("worker-1");
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
/** After clear(), a resume signal restores the agent again. */
|
||||
it("restores the agent again after the runtime was cleared", async () => {
|
||||
const { runtime, published } = reloaded({ "worker-1": [worker] });
|
||||
await runtime.taskResumed("worker-1", async () => {});
|
||||
runtime.clear();
|
||||
await runtime.taskResumed("worker-1", async () => {});
|
||||
|
||||
const ids = spawned(published);
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
});
|
||||
|
||||
/** After every child ended, a launch id that resolved to nothing is looked up again. */
|
||||
it("looks up an unknown launch again after the runtime ended every child", async () => {
|
||||
const { runtime, launchedAgent } = reloaded({});
|
||||
await runtime.route(output("launch-9"), async () => {});
|
||||
await runtime.finishAll("cancelled", async () => {});
|
||||
await runtime.route(output("launch-9"), async () => {});
|
||||
|
||||
expect(launchedAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/** Three signals for one agent in flight together open a single generation. */
|
||||
it("opens one generation for a message, a resumedAgentId and a task_updated that arrive together", async () => {
|
||||
const { runtime, published, lineage } = reloaded({ "worker-1": [worker] });
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const deliver = async (n: AcpSessionNotification) => void delivered.push(n);
|
||||
|
||||
await Promise.all([
|
||||
runtime.route(output("launch-1"), deliver),
|
||||
runtime.sendMessageResumed("worker-1", deliver, "Say PING", "send-1"),
|
||||
runtime.taskResumed("worker-1", deliver),
|
||||
]);
|
||||
|
||||
expect(spawned(published)).toHaveLength(1);
|
||||
expect(lineage).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** After a lineage that places no agent, every later message skips the history. */
|
||||
it("reads the history once for a launch whose agent cannot be placed", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const lineage = vi.fn(async () => []);
|
||||
const launchedAgent = vi.fn(async () => "worker-1");
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => void published.push(notification),
|
||||
{ log: vi.fn() },
|
||||
lineage,
|
||||
launchedAgent,
|
||||
);
|
||||
|
||||
for (let i = 0; i < 5; i++) await runtime.route(output("launch-1"), async () => {});
|
||||
|
||||
expect(launchedAgent).toHaveBeenCalledTimes(1);
|
||||
expect(lineage).toHaveBeenCalledTimes(1);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
/** A clear() while the lineage is read leaves the stale restore announcing nothing. */
|
||||
it("announces nothing for a restore that a clear() overtook", async () => {
|
||||
const release = deferred();
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => void published.push(notification),
|
||||
{ log: vi.fn() },
|
||||
async () => {
|
||||
await release.promise;
|
||||
return [worker];
|
||||
},
|
||||
);
|
||||
|
||||
const resumed = runtime.taskResumed("worker-1", async () => {});
|
||||
runtime.clear();
|
||||
release.resolve();
|
||||
await resumed;
|
||||
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
const sendMessage = (toolCallId: string): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "SendMessage",
|
||||
status: "pending",
|
||||
_meta: { claudeCode: { toolName: "SendMessage" } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A SendMessage task_started restores the agent, and its id and the launch id both route. */
|
||||
it("restores the agent from a task_started under a SendMessage id", async () => {
|
||||
const { runtime, published } = reloaded({ "worker-1": [worker] }, { lookUpLaunches: false });
|
||||
const deliver = async () => {};
|
||||
|
||||
await runtime.route(sendMessage("send-1"), deliver);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "send-1", subagentType: "Explore", prompt: "Say PING" },
|
||||
deliver,
|
||||
);
|
||||
await expect(runtime.route(output("launch-1"), deliver)).resolves.toMatchObject({
|
||||
sessionId: expect.stringMatching(/^worker-1:generation:\d+$/),
|
||||
});
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "send-1");
|
||||
|
||||
expect(spawned(published)).toHaveLength(1);
|
||||
expect(published[0]?.update).toMatchObject({ prompt: "Say PING" });
|
||||
expect(published.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
});
|
||||
|
||||
/** A task_started without a subagent type, such as a background shell, reads no history. */
|
||||
it("does not read the history for a task_started without a subagent type", async () => {
|
||||
const { runtime, lineage } = reloaded({ "worker-1": [worker] });
|
||||
|
||||
await runtime.route(sendMessage("send-1"), async () => {});
|
||||
await runtime.taskStarted({ taskId: "shell-1", toolUseId: "send-1" }, async () => {});
|
||||
|
||||
expect(lineage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A new Agent whose task_started precedes its tool call frame opens under its bare id and waits. */
|
||||
it("opens a new Agent whose task_started comes before its frame as the bare id, unannounced", async () => {
|
||||
const { runtime, published, lineage } = reloaded({ "worker-1": [worker] });
|
||||
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
|
||||
expect(lineage).not.toHaveBeenCalled();
|
||||
expect(published).toEqual([]);
|
||||
await runtime.route(agentCall("launch-1"), async () => {});
|
||||
expect(spawned(published)).toEqual(["root -> worker-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("subagentLineage", () => {
|
||||
let parentUuid: string | null = null;
|
||||
return records
|
||||
.map((record) => {
|
||||
const linked = { ...record, parentUuid };
|
||||
const linked = "parentUuid" in record ? record : { ...record, parentUuid };
|
||||
parentUuid = (record as { uuid: string }).uuid;
|
||||
return JSON.stringify(linked);
|
||||
})
|
||||
@@ -131,6 +131,66 @@ describe("subagentLineage", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
const boundary = (uuid: string) => ({
|
||||
...base,
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
content: "Conversation compacted",
|
||||
});
|
||||
|
||||
/** The root's launch comes before a compaction, which drops it from the SDK's active chain. */
|
||||
it("places an agent whose launch the root made before a compaction", async () => {
|
||||
await writeSession(
|
||||
[user("u1", "Go"), launch("a1", "launch-echoer"), boundary("b1"), user("u2", "More")],
|
||||
{ echoer1: { meta: meta("launch-echoer", "echoer"), records: [user("e1", "Work")] } },
|
||||
);
|
||||
|
||||
const lineage = await subagentLineage(sessionId, "echoer1", logger);
|
||||
|
||||
expect(lineage.map(({ agentId, launchId }) => ({ agentId, launchId }))).toEqual([
|
||||
{ agentId: "echoer1", launchId: "launch-echoer" },
|
||||
]);
|
||||
});
|
||||
|
||||
/** An agent's own launch of a nested agent comes before a compaction in that agent's transcript. */
|
||||
it("places a nested agent whose launch its parent agent made before a compaction", async () => {
|
||||
await writeSession([user("u1", "Go"), launch("a1", "launch-coordinator")], {
|
||||
coordinator1: {
|
||||
meta: meta("launch-coordinator", "coordinator"),
|
||||
records: [
|
||||
user("c1", "Work"),
|
||||
launch("c2", "launch-worker"),
|
||||
boundary("c3"),
|
||||
user("c4", "More"),
|
||||
],
|
||||
},
|
||||
worker1: { meta: meta("launch-worker", "worker"), records: [user("w1", "Work")] },
|
||||
});
|
||||
|
||||
const lineage = await subagentLineage(sessionId, "worker1", logger);
|
||||
|
||||
expect(
|
||||
lineage.map(({ agentId, launchId, parentAgentId }) => ({ agentId, launchId, parentAgentId })),
|
||||
).toEqual([
|
||||
{ agentId: "coordinator1", launchId: "launch-coordinator", parentAgentId: undefined },
|
||||
{ agentId: "worker1", launchId: "launch-worker", parentAgentId: "coordinator1" },
|
||||
]);
|
||||
});
|
||||
|
||||
/** A sidechain record in the root file is an agent's launch, so it does not make the root the parent. */
|
||||
it("does not take a launch in a sidechain record of the root file for the root's", async () => {
|
||||
await writeSession(
|
||||
[user("u1", "Go"), { ...launch("a1", "launch-worker"), isSidechain: true }],
|
||||
{
|
||||
worker1: { meta: meta("launch-worker", "worker"), records: [user("w1", "Work")] },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(subagentLineage(sessionId, "worker1", logger)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("is empty for an agent whose launch no transcript has, since its parent is unknown", async () => {
|
||||
await writeSession([user("u1", "Go")], {
|
||||
lost1: { meta: meta("launch-lost", "lost"), records: [user("l1", "Work")] },
|
||||
|
||||
Reference in New Issue
Block a user