fix: address rpc subagent review feedback

This commit is contained in:
Kormákur
2026-06-10 00:25:06 +00:00
committed by can1357
parent d1b7e6cad3
commit 6f75b34efd
8 changed files with 328 additions and 94 deletions
+3 -33
View File
@@ -8,39 +8,9 @@ import { postmortem } from "@oh-my-pi/pi-utils";
* barrel does not pull print, RPC server, or ACP server mode into the normal
* TUI graph.
*/
export { InteractiveMode, type InteractiveModeOptions } from "./interactive-mode";
export {
defineRpcClientTool,
type ModelInfo,
RpcClient,
type RpcClientCustomTool,
type RpcClientOptions,
type RpcClientToolContext,
type RpcClientToolResult,
type RpcEventListener,
type RpcSessionEventListener,
type RpcSubagentEventListener,
type RpcSubagentLifecycleListener,
type RpcSubagentProgressListener,
} from "./rpc/rpc-client";
export type {
RpcCommand,
RpcHostToolCallRequest,
RpcHostToolCancelRequest,
RpcHostToolDefinition,
RpcHostToolResult,
RpcHostToolUpdate,
RpcResponse,
RpcSessionEventFrame,
RpcSessionState,
RpcSubagentEventFrame,
RpcSubagentFrame,
RpcSubagentLifecycleFrame,
RpcSubagentMessagesResult,
RpcSubagentProgressFrame,
RpcSubagentSnapshot,
RpcSubagentSubscriptionLevel,
} from "./rpc/rpc-types";
export * from "./interactive-mode";
export * from "./rpc/rpc-client";
export * from "./rpc/rpc-types";
postmortem.register("terminal-restore", () => {
emergencyTerminalRestore();
@@ -9,6 +9,7 @@ import type { AgentEvent, AgentMessage, AgentToolResult, ThinkingLevel } from "@
import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
import { isRecord, ptree, readJsonl } from "@oh-my-pi/pi-utils";
import type { FileSink } from "bun";
import type { BashResult } from "../../exec/bash-executor";
import type { AgentSessionEvent, SessionStats } from "../../session/agent-session";
import type {
@@ -353,7 +354,7 @@ export class RpcClient {
}
/**
* Subscribe to subagent lifecycle frames emitted by the task tool.
* Subscribe to subagent lifecycle frames after setSubagentSubscription("progress" | "events").
*/
onSubagentLifecycle(listener: RpcSubagentLifecycleListener): () => void {
this.#subagentLifecycleListeners.add(listener);
@@ -361,7 +362,7 @@ export class RpcClient {
}
/**
* Subscribe to aggregated subagent progress frames emitted by the task tool.
* Subscribe to aggregated subagent progress frames after setSubagentSubscription("progress" | "events").
*/
onSubagentProgress(listener: RpcSubagentProgressListener): () => void {
this.#subagentProgressListeners.add(listener);
@@ -449,8 +450,8 @@ export class RpcClient {
}
/**
* Configure subagent frames emitted by the RPC server.
* Progress emits lifecycle/progress frames; events additionally emits raw subagent session events.
* Configure subagent frames emitted by the RPC server. Servers default to "off".
* "progress" emits lifecycle/progress frames; "events" additionally emits raw subagent session events.
*/
async setSubagentSubscription(level: RpcSubagentSubscriptionLevel): Promise<RpcSubagentSubscriptionLevel> {
const response = await this.#send({ type: "set_subagent_subscription", level });
@@ -939,7 +940,7 @@ export class RpcClient {
if (!this.#process?.stdin) {
throw new Error("Client not started");
}
const stdin = this.#process.stdin as import("bun").FileSink;
const stdin = this.#process.stdin as FileSink;
stdin.write(`${JSON.stringify(frame)}\n`);
const flushResult = stdin.flush();
if (isPromise(flushResult)) {
+53 -13
View File
@@ -59,6 +59,47 @@ type RpcOutput = (
| object,
) => void;
export type RpcSessionChangeCommand = Extract<
RpcCommand,
{ type: "new_session" } | { type: "switch_session" } | { type: "branch" }
>;
export type RpcSessionChangeResult =
| { type: "new_session"; data: { cancelled: boolean } }
| { type: "switch_session"; data: { cancelled: boolean } }
| { type: "branch"; data: { text: string; cancelled: boolean } };
export type RpcSessionChangeSession = Pick<AgentSession, "newSession" | "switchSession" | "branch">;
export type RpcSubagentResetRegistry = Pick<RpcSubagentRegistry, "clear">;
export async function handleRpcSessionChange(
session: RpcSessionChangeSession,
command: RpcSessionChangeCommand,
subagentRegistry?: RpcSubagentResetRegistry,
): Promise<RpcSessionChangeResult> {
switch (command.type) {
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const cancelled = !(await session.newSession(options));
if (!cancelled) subagentRegistry?.clear();
return { type: "new_session", data: { cancelled } };
}
case "switch_session": {
const cancelled = !(await session.switchSession(command.sessionPath));
if (!cancelled) subagentRegistry?.clear();
return { type: "switch_session", data: { cancelled } };
}
case "branch": {
const result = await session.branch(command.entryId);
if (!result.cancelled) subagentRegistry?.clear();
return { type: "branch", data: { text: result.selectedText, cancelled: result.cancelled } };
}
}
throw new Error("Unsupported RPC session change command");
}
function normalizeHostToolDefinitions(tools: RpcHostToolDefinition[]): RpcHostToolDefinition[] {
return tools.map((tool, index) => {
const name = typeof tool.name === "string" ? tool.name.trim() : "";
@@ -516,9 +557,8 @@ export async function runRpcMode(
}
case "new_session": {
const options = command.parentSession ? { parentSession: command.parentSession } : undefined;
const cancelled = !(await session.newSession(options));
return success(id, "new_session", { cancelled });
const result = await handleRpcSessionChange(session, command, subagentRegistry);
return success(id, result.type, result.data);
}
// =================================================================
@@ -589,7 +629,10 @@ export async function runRpcMode(
}
case "get_subagents": {
return success(id, "get_subagents", { subagents: subagentRegistry?.getSubagents() ?? [] });
if (!subagentRegistry) {
return error(id, "get_subagents", "Subagent event bus is unavailable");
}
return success(id, "get_subagents", { subagents: subagentRegistry.getSubagents() });
}
case "get_subagent_messages": {
@@ -727,14 +770,10 @@ export async function runRpcMode(
return success(id, "export_html", { path });
}
case "switch_session": {
const cancelled = !(await session.switchSession(command.sessionPath));
return success(id, "switch_session", { cancelled });
}
case "switch_session":
case "branch": {
const result = await session.branch(command.entryId);
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
const result = await handleRpcSessionChange(session, command, subagentRegistry);
return success(id, result.type, result.data);
}
case "get_branch_messages": {
@@ -894,8 +933,9 @@ export async function runRpcMode(
// Check for deferred shutdown request (idle between commands)
await checkShutdownRequested();
} catch (e: any) {
output(error(undefined, "parse", `Failed to parse command: ${e.message}`));
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
output(error(undefined, "parse", `Failed to parse command: ${message}`));
}
}
@@ -27,6 +27,8 @@ export interface RpcSubagentTranscriptSelector {
type RpcSubagentOutput = (frame: RpcSubagentFrame) => void;
const MAX_RETAINED_TRANSCRIPT_REFERENCES = 256;
function isSessionMessageEntry(entry: FileEntry): entry is SessionMessageEntry {
return entry.type === "message";
}
@@ -35,6 +37,10 @@ function statusFromLifecycle(status: SubagentLifecyclePayload["status"]): AgentP
return status === "started" ? "running" : status;
}
function isTerminalLifecycleStatus(status: SubagentLifecyclePayload["status"]): boolean {
return status !== "started";
}
export async function readRpcSubagentTranscript(sessionFile: string, fromByte = 0): Promise<RpcSubagentMessagesResult> {
let startByte = Number.isFinite(fromByte) ? Math.max(0, Math.trunc(fromByte)) : 0;
const file = Bun.file(sessionFile);
@@ -63,9 +69,10 @@ export async function readRpcSubagentTranscript(sessionFile: string, fromByte =
export class RpcSubagentRegistry {
#subagents = new Map<string, RpcSubagentSnapshot>();
#transcriptSessionFilesBySubagentId = new Map<string, string>();
#unsubscribers: Array<() => void> = [];
#output: RpcSubagentOutput;
#subscriptionLevel: RpcSubagentSubscriptionLevel = "progress";
#subscriptionLevel: RpcSubagentSubscriptionLevel = "off";
constructor(eventBus: EventBus, output: RpcSubagentOutput) {
this.#output = output;
@@ -86,6 +93,12 @@ export class RpcSubagentRegistry {
for (const unsubscribe of this.#unsubscribers) unsubscribe();
this.#unsubscribers = [];
this.#subagents.clear();
this.#transcriptSessionFilesBySubagentId.clear();
}
clear(): void {
this.#subagents.clear();
this.#transcriptSessionFilesBySubagentId.clear();
}
setSubscriptionLevel(level: RpcSubagentSubscriptionLevel): void {
@@ -100,8 +113,30 @@ export class RpcSubagentRegistry {
return [...this.#subagents.values()].sort((a, b) => a.index - b.index || a.id.localeCompare(b.id));
}
#rememberTranscriptSession(subagentId: string, sessionFile: string | undefined): void {
if (!sessionFile) return;
this.#transcriptSessionFilesBySubagentId.delete(subagentId);
this.#transcriptSessionFilesBySubagentId.set(subagentId, sessionFile);
while (this.#transcriptSessionFilesBySubagentId.size > MAX_RETAINED_TRANSCRIPT_REFERENCES) {
const oldest = this.#transcriptSessionFilesBySubagentId.keys().next();
if (oldest.done) break;
this.#transcriptSessionFilesBySubagentId.delete(oldest.value);
}
}
#hasTranscriptSessionFile(sessionFile: string): boolean {
for (const snapshot of this.#subagents.values()) {
if (snapshot.sessionFile === sessionFile) return true;
}
for (const transcriptSessionFile of this.#transcriptSessionFilesBySubagentId.values()) {
if (transcriptSessionFile === sessionFile) return true;
}
return false;
}
handleLifecycle(payload: SubagentLifecyclePayload): void {
const existing = this.#subagents.get(payload.id);
const sessionFile = payload.sessionFile ?? existing?.sessionFile;
const snapshot: RpcSubagentSnapshot = {
id: payload.id,
index: payload.index,
@@ -111,12 +146,17 @@ export class RpcSubagentRegistry {
status: statusFromLifecycle(payload.status),
task: existing?.task,
assignment: existing?.assignment,
sessionFile: payload.sessionFile ?? existing?.sessionFile,
sessionFile,
parentToolCallId: payload.parentToolCallId ?? existing?.parentToolCallId,
lastUpdate: Date.now(),
progress: existing?.progress,
};
this.#subagents.set(payload.id, snapshot);
this.#rememberTranscriptSession(payload.id, sessionFile);
if (isTerminalLifecycleStatus(payload.status)) {
this.#subagents.delete(payload.id);
} else {
this.#subagents.set(payload.id, snapshot);
}
if (this.#subscriptionLevel !== "off") {
this.#output({ type: "subagent_lifecycle", payload });
}
@@ -125,6 +165,8 @@ export class RpcSubagentRegistry {
handleProgress(payload: SubagentProgressPayload): void {
const progress = payload.progress;
const existing = this.#subagents.get(progress.id);
const sessionFile = payload.sessionFile ?? existing?.sessionFile;
this.#rememberTranscriptSession(progress.id, sessionFile);
this.#subagents.set(progress.id, {
id: progress.id,
index: payload.index,
@@ -134,7 +176,7 @@ export class RpcSubagentRegistry {
status: progress.status,
task: payload.task,
assignment: payload.assignment,
sessionFile: payload.sessionFile ?? existing?.sessionFile,
sessionFile,
lastUpdate: Date.now(),
parentToolCallId: payload.parentToolCallId ?? existing?.parentToolCallId,
progress,
@@ -152,16 +194,15 @@ export class RpcSubagentRegistry {
resolveSessionFile(selector: RpcSubagentTranscriptSelector): string {
if (selector.subagentId) {
const snapshot = this.#subagents.get(selector.subagentId);
if (!snapshot?.sessionFile) {
const sessionFile = snapshot?.sessionFile ?? this.#transcriptSessionFilesBySubagentId.get(selector.subagentId);
if (!sessionFile) {
throw new Error(`Unknown subagent or session file unavailable: ${selector.subagentId}`);
}
return snapshot.sessionFile;
return sessionFile;
}
if (selector.sessionFile) {
for (const snapshot of this.#subagents.values()) {
if (snapshot.sessionFile === selector.sessionFile) return selector.sessionFile;
}
if (this.#hasTranscriptSessionFile(selector.sessionFile)) return selector.sessionFile;
throw new Error("Unknown subagent session file");
}
@@ -928,14 +928,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (!options.eventBus) return;
options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
id,
index,
agent: agent.name,
agentSource: agent.source,
task,
assignment,
sessionFile: subtaskSessionFile,
event,
parentToolCallId: options.parentToolCallId,
});
};
+8 -10
View File
@@ -413,7 +413,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
async execute(
_toolCallId: string,
toolCallId: string,
rawParams: unknown,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
@@ -428,7 +428,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
const asyncEnabled = this.session.settings.get("async.enabled");
const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
if (!asyncEnabled || selectedAgent?.blocking === true) {
return this.#executeSync(_toolCallId, params, signal, onUpdate);
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const manager = this.session.asyncJobManager;
@@ -438,12 +438,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
// to the sync path keeps the tool usable; only background/job-poll
// semantics are lost.
logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
return this.#executeSync(_toolCallId, params, signal, onUpdate);
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const taskItems = params.tasks ?? [];
if (taskItems.length === 0) {
return this.#executeSync(_toolCallId, params, signal, onUpdate);
return this.#executeSync(toolCallId, params, signal, onUpdate);
}
const taskIdProblem = validateTaskIds(taskItems);
@@ -553,9 +553,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
buildAsyncDetails("running", startedJobs[0]?.jobId ?? label) as unknown as Record<string, unknown>,
);
try {
const result = await this.#executeSync(_toolCallId, singleParams, runSignal, undefined, [
uniqueId,
]);
const result = await this.#executeSync(toolCallId, singleParams, runSignal, undefined, [uniqueId]);
const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
const singleResult = result.details?.results[0];
// A missing per-task result means #executeSync failed at the
@@ -708,7 +706,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
}
async #executeSync(
_toolCallId: string,
toolCallId: string,
params: TaskParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
@@ -1036,7 +1034,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
planReference,
description: task.description,
index,
parentToolCallId: _toolCallId,
parentToolCallId: toolCallId,
id: task.id,
taskDepth,
modelOverride,
@@ -1099,7 +1097,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
planReference,
description: task.description,
index,
parentToolCallId: _toolCallId,
parentToolCallId: toolCallId,
id: task.id,
taskDepth,
modelOverride,
-7
View File
@@ -51,13 +51,6 @@ export interface SubagentProgressPayload {
/** Payload emitted on TASK_SUBAGENT_EVENT_CHANNEL */
export interface SubagentEventPayload {
id: string;
index: number;
agent: string;
agentSource: AgentSource;
task: string;
parentToolCallId?: string;
assignment?: string;
sessionFile?: string;
event: AgentSessionEvent;
}
+208 -10
View File
@@ -3,6 +3,12 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { RpcClient } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-client";
import {
handleRpcSessionChange,
type RpcSessionChangeCommand,
type RpcSessionChangeResult,
type RpcSessionChangeSession,
} from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode";
import { RpcSubagentRegistry, readRpcSubagentTranscript } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-subagents";
import type { RpcSubagentFrame } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-types";
import {
@@ -44,8 +50,37 @@ function createProgress(overrides: Partial<AgentProgress> = {}): AgentProgress {
};
}
function createRegistryWithSnapshot(): RpcSubagentRegistry {
const eventBus = new EventBus();
const registry = new RpcSubagentRegistry(eventBus, () => {});
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
status: "started",
sessionFile: "/tmp/subagent.jsonl",
} satisfies SubagentLifecyclePayload);
expect(registry.getSubagents()).toHaveLength(1);
return registry;
}
type SessionChangeStubOptions = {
newSession?: boolean;
switchSession?: boolean;
branch?: { selectedText: string; cancelled: boolean };
};
function createSessionChangeSession(options: SessionChangeStubOptions): RpcSessionChangeSession {
return {
newSession: async (_options?: unknown) => options.newSession ?? true,
switchSession: async (_sessionPath: string) => options.switchSession ?? true,
branch: async (_entryId: string) => options.branch ?? { selectedText: "branched text", cancelled: false },
};
}
describe("RPC subagent registry", () => {
test("emits progress frames and snapshots tracked subagents", () => {
test("defaults subagent frame emission to off while tracking snapshots", () => {
const eventBus = new EventBus();
const frames: RpcSubagentFrame[] = [];
const registry = new RpcSubagentRegistry(eventBus, frame => frames.push(frame));
@@ -69,6 +104,52 @@ describe("RPC subagent registry", () => {
sessionFile: "/tmp/subagent.jsonl",
progress: createProgress(),
};
const eventPayload: SubagentEventPayload = {
id: "SubagentA",
event: { type: "agent_start" },
};
expect(registry.getSubscriptionLevel()).toBe("off");
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, lifecycle);
eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, progressPayload);
eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, eventPayload);
expect(frames).toHaveLength(0);
expect(registry.getSubagents()).toMatchObject([
{
id: "SubagentA",
status: "running",
sessionFile: "/tmp/subagent.jsonl",
},
]);
registry.dispose();
});
test("emits progress frames after explicit progress subscription and snapshots tracked subagents", () => {
const eventBus = new EventBus();
const frames: RpcSubagentFrame[] = [];
const registry = new RpcSubagentRegistry(eventBus, frame => frames.push(frame));
registry.setSubscriptionLevel("progress");
const lifecycle: SubagentLifecyclePayload = {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
description: "Worker",
status: "started",
sessionFile: "/tmp/subagent.jsonl",
parentToolCallId: "toolu_parent",
};
const progressPayload: SubagentProgressPayload = {
index: 0,
agent: "task",
agentSource: "bundled",
task: "Do work",
assignment: "Implement work",
parentToolCallId: "toolu_parent",
sessionFile: "/tmp/subagent.jsonl",
progress: createProgress(),
};
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, lifecycle);
eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, progressPayload);
@@ -88,19 +169,136 @@ describe("RPC subagent registry", () => {
registry.dispose();
});
test("clears stale snapshots when the active RPC session changes", () => {
const eventBus = new EventBus();
const registry = new RpcSubagentRegistry(eventBus, () => {});
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
status: "started",
sessionFile: "/tmp/subagent.jsonl",
} satisfies SubagentLifecyclePayload);
expect(registry.getSubagents()).toHaveLength(1);
registry.clear();
expect(registry.getSubagents()).toHaveLength(0);
registry.dispose();
});
test("clears stale snapshots after successful RPC session changes", async () => {
const cases: Array<{
command: RpcSessionChangeCommand;
session: RpcSessionChangeSession;
expected: RpcSessionChangeResult;
}> = [
{
command: { type: "new_session", parentSession: "/tmp/parent.jsonl" },
session: createSessionChangeSession({ newSession: true }),
expected: { type: "new_session", data: { cancelled: false } },
},
{
command: { type: "switch_session", sessionPath: "/tmp/next.jsonl" },
session: createSessionChangeSession({ switchSession: true }),
expected: { type: "switch_session", data: { cancelled: false } },
},
{
command: { type: "branch", entryId: "entry-1" },
session: createSessionChangeSession({ branch: { selectedText: "Branch text", cancelled: false } }),
expected: { type: "branch", data: { text: "Branch text", cancelled: false } },
},
];
for (const testCase of cases) {
const registry = createRegistryWithSnapshot();
try {
const result = await handleRpcSessionChange(testCase.session, testCase.command, registry);
expect(result).toEqual(testCase.expected);
expect(registry.getSubagents()).toHaveLength(0);
expect(() => registry.resolveSessionFile({ subagentId: "SubagentA" })).toThrow(
/Unknown subagent or session file unavailable/,
);
} finally {
registry.dispose();
}
}
});
test("keeps stale snapshots when RPC session changes are cancelled", async () => {
const cases: Array<{
command: RpcSessionChangeCommand;
session: RpcSessionChangeSession;
expected: RpcSessionChangeResult;
}> = [
{
command: { type: "new_session", parentSession: "/tmp/parent.jsonl" },
session: createSessionChangeSession({ newSession: false }),
expected: { type: "new_session", data: { cancelled: true } },
},
{
command: { type: "switch_session", sessionPath: "/tmp/next.jsonl" },
session: createSessionChangeSession({ switchSession: false }),
expected: { type: "switch_session", data: { cancelled: true } },
},
{
command: { type: "branch", entryId: "entry-1" },
session: createSessionChangeSession({ branch: { selectedText: "", cancelled: true } }),
expected: { type: "branch", data: { text: "", cancelled: true } },
},
];
for (const testCase of cases) {
const registry = createRegistryWithSnapshot();
try {
const result = await handleRpcSessionChange(testCase.session, testCase.command, registry);
expect(result).toEqual(testCase.expected);
expect(registry.getSubagents()).toMatchObject([{ id: "SubagentA" }]);
expect(registry.resolveSessionFile({ subagentId: "SubagentA" })).toBe("/tmp/subagent.jsonl");
} finally {
registry.dispose();
}
}
});
test("prunes terminal lifecycle snapshots while retaining transcript selectors", () => {
const eventBus = new EventBus();
const registry = new RpcSubagentRegistry(eventBus, () => {});
const sessionFile = "/tmp/subagent.jsonl";
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
status: "started",
sessionFile,
} satisfies SubagentLifecyclePayload);
expect(registry.getSubagents()).toHaveLength(1);
eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
status: "completed",
sessionFile,
} satisfies SubagentLifecyclePayload);
expect(registry.getSubagents()).toHaveLength(0);
expect(registry.resolveSessionFile({ subagentId: "SubagentA" })).toBe(sessionFile);
expect(registry.resolveSessionFile({ sessionFile })).toBe(sessionFile);
registry.dispose();
});
test("gates raw subagent events behind the events subscription level", () => {
const eventBus = new EventBus();
const frames: RpcSubagentFrame[] = [];
const registry = new RpcSubagentRegistry(eventBus, frame => frames.push(frame));
const eventPayload: SubagentEventPayload = {
id: "SubagentA",
index: 0,
agent: "task",
agentSource: "bundled",
task: "Do work",
assignment: "Implement work",
parentToolCallId: "toolu_parent",
sessionFile: "/tmp/subagent.jsonl",
event: { type: "agent_start" },
};
@@ -111,7 +309,7 @@ describe("RPC subagent registry", () => {
eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, eventPayload);
expect(frames).toHaveLength(1);
expect(frames[0]).toMatchObject({ type: "subagent_event", payload: { id: "SubagentA" } });
expect(frames[0]).toEqual({ type: "subagent_event", payload: eventPayload });
registry.dispose();
});
});
@@ -195,7 +393,7 @@ function handle(frame) {
write({ type: "notice", level: "info", message: "subagent test" });
write({ type: "subagent_lifecycle", payload: { id: "SubagentA", index: 0, agent: "task", agentSource: "bundled", status: "started", sessionFile: "/tmp/subagent.jsonl" } });
write({ type: "subagent_progress", payload: { index: 0, agent: "task", agentSource: "bundled", task: "Do work", assignment: "Implement work", sessionFile: "/tmp/subagent.jsonl", progress } });
write({ type: "subagent_event", payload: { id: "SubagentA", index: 0, agent: "task", agentSource: "bundled", task: "Do work", assignment: "Implement work", sessionFile: "/tmp/subagent.jsonl", event: { type: "agent_start" } } });
write({ type: "subagent_event", payload: { id: "SubagentA", event: { type: "agent_start" } } });
write({ type: "agent_end", messages: [] });
}
}