diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 1ac5bcbc3c..c65a814753 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -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 = 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(), diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 99d156473d..fa274039a5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -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()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 0ab3ddecd7..77ece5f667 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1035,6 +1035,7 @@ class PluginAgentSession implements AgentSession { private readonly permissionResponses = new Map(); private readonly revertTokens = new Map(); private readonly timelineSnapshots = new Map(); + private readonly subagentIdsBySession = new Map(); private readonly childUnsubscribes = new Map void>(); private readonly childSnapshots = new Map>(); 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, ): 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", diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts index 7dfc4bc510..9c27d2039c 100644 --- a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -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 () => {