mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(plugins): preserve nested provider subagent links (#4970)
* fix(plugins): preserve nested provider subagent links The plugin adapter routed descendants to their managed root but discarded their direct parent when publishing descriptors. Preserve runtime-to-public child identities and forward optional spawning tool IDs. * test(plugins): separate opening and live subagent scenarios * test(server): retry terminal fixture directory cleanup Match the daemon harness cleanup policy so transient Windows EBUSY locks do not fail terminal ownership tests after their assertions complete.
This commit is contained in:
@@ -531,7 +531,10 @@ export type ProviderEvent =
|
||||
type: "session.opened";
|
||||
requestId?: string;
|
||||
sessionId: string;
|
||||
/** Direct parent in the provider session ID namespace. */
|
||||
parentSessionId?: string;
|
||||
/** ID of the spawning tool call in the direct parent's timeline. */
|
||||
toolCallId?: string;
|
||||
capabilities: readonly string[];
|
||||
restoration: "core" | "parent";
|
||||
persistence?: ProviderPersistence;
|
||||
@@ -1278,6 +1281,7 @@ export const ProviderEventSchema: z.ZodType<ProviderEvent> = z.discriminatedUnio
|
||||
requestId: idSchema.optional(),
|
||||
sessionId: idSchema,
|
||||
parentSessionId: idSchema.optional(),
|
||||
toolCallId: idSchema.optional(),
|
||||
capabilities: z.array(z.string()),
|
||||
restoration: z.enum(["core", "parent"]),
|
||||
persistence: persistenceSchema.optional(),
|
||||
|
||||
@@ -24,6 +24,7 @@ const CAPABILITIES = [
|
||||
interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
}
|
||||
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
@@ -108,6 +109,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
state: "completed",
|
||||
});
|
||||
emit({ type: "session.ready", sessionId: "child-1" });
|
||||
options.openChildren?.(input.sessionId, emit);
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +204,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
|
||||
return {
|
||||
registration,
|
||||
emit,
|
||||
inputs,
|
||||
closeCount: () => closeCount,
|
||||
waitForClose: () => closed,
|
||||
@@ -212,7 +215,107 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
|
||||
for (const [sessionId, parentSessionId] of [
|
||||
["a", rootSessionId],
|
||||
["a.b", "a"],
|
||||
["a.b.c", "a.b"],
|
||||
["sibling", rootSessionId],
|
||||
]) {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId,
|
||||
parentSessionId,
|
||||
toolCallId: `${sessionId}-task`,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: "/workspace",
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId,
|
||||
item: { type: "assistant_message", id: `${sessionId}-message`, text: sessionId },
|
||||
});
|
||||
emit({
|
||||
type: "session.turn",
|
||||
sessionId,
|
||||
turnId: `${sessionId}-turn`,
|
||||
state: "completed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
for (const [id, parentSubagentId] of [
|
||||
["a", null],
|
||||
["a.b", "a"],
|
||||
["a.b.c", "a.b"],
|
||||
["sibling", null],
|
||||
]) {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "upsert",
|
||||
id,
|
||||
parentSubagentId,
|
||||
toolCallId: `${id}-task`,
|
||||
status: "running",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
id,
|
||||
item: expect.objectContaining({ text: id }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test("preserves nested provider child ownership during opening", async () => {
|
||||
const harness = createProviderHarness({ openChildren: openNestedChildren });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) events.push(event);
|
||||
expectNestedChildren(events);
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves nested provider child ownership during live events", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
openNestedChildren(open.sessionId, harness.emit);
|
||||
expectNestedChildren(events);
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("gates persistence operations on negotiated provider capabilities", async () => {
|
||||
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
|
||||
@@ -1035,6 +1035,7 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly permissionResponses = new Map<string, AgentPermissionResponse>();
|
||||
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
|
||||
private readonly timelineSnapshots = new Map<string, ProviderTimelineItem>();
|
||||
private readonly subagentIdsBySession = new Map<string, string | null>();
|
||||
private readonly childUnsubscribes = new Map<string, () => void>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, ProviderTimelineItem>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
@@ -1046,6 +1047,7 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly bridge: ProviderRuntimeSession,
|
||||
private readonly onClose: () => void,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
|
||||
}
|
||||
@@ -1187,6 +1189,7 @@ class PluginAgentSession implements AgentSession {
|
||||
this.unsubscribe = null;
|
||||
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
|
||||
this.childUnsubscribes.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.listeners.clear();
|
||||
this.onClose();
|
||||
await this.bridge.close();
|
||||
@@ -1243,13 +1246,22 @@ class PluginAgentSession implements AgentSession {
|
||||
child: ProviderRuntimeSession,
|
||||
opened: Extract<ProviderEvent, { type: "session.opened" }>,
|
||||
): void {
|
||||
const parentSubagentId = opened.parentSessionId
|
||||
? this.subagentIdsBySession.get(opened.parentSessionId)
|
||||
: undefined;
|
||||
if (parentSubagentId === undefined) {
|
||||
throw new Error(`Missing plugin child parent ${opened.parentSessionId}`);
|
||||
}
|
||||
const childId = child.providerId;
|
||||
this.subagentIdsBySession.set(child.id, childId);
|
||||
this.publish({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
parentSubagentId,
|
||||
toolCallId: opened.toolCallId ?? null,
|
||||
title: opened.title ?? null,
|
||||
description: opened.description ?? null,
|
||||
status: "running",
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(async () => {
|
||||
await client.close();
|
||||
await sdk.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
|
||||
|
||||
Reference in New Issue
Block a user