diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.test.ts b/packages/server/src/server/agent/providers/opencode/v2/children.test.ts index 21e2520f80..02d9b26567 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test } from "vitest"; import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; import { V2Harness } from "../test-utils/v2-harness.js"; import { SessionChildren } from "./children.js"; +import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; function setup() { const harness = new V2Harness(); @@ -35,7 +36,134 @@ function setup() { return { children, child, upserts }; } +function setupTree() { + const harness = new V2Harness(); + const sessions = new Map( + [ + { ...harness.info, id: "first", parentID: "session" }, + { ...harness.info, id: "second", parentID: "session" }, + { ...harness.info, id: "grandchild", parentID: "first" }, + ].map((info) => [info.id, info]), + ); + harness.api.session.list = async (input) => ({ + data: [...sessions.values()].filter((info) => info.parentID === input?.parentID), + cursor: {}, + }); + harness.api.session.get = async ({ sessionID }) => { + const info = sessions.get(sessionID); + if (!info) throw { _tag: "SessionNotFoundError", sessionID }; + return info; + }; + const store = new ProviderSubagentStore(); + const children = new SessionChildren({ + client: () => harness.api, + id: "session", + emit: (event) => { + if (event.type === "provider_subagent") store.apply("parent", "opencode", event.event); + }, + reconcilePermissions: async () => {}, + }); + const ids = () => + store + .list("parent") + .map((child) => child.id) + .sort(); + return { harness, sessions, children, ids }; +} + describe("OpenCode v2 child presentation", () => { + test.each(["deletion event", "reconnect"])("removes a deleted child on %s", async (trigger) => { + const harness = new V2Harness(); + const child: SessionInfo = { ...harness.info, id: "child", parentID: "session" }; + let exists = true; + harness.api.session.list = async (input) => ({ + data: exists && input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + harness.api.session.get = async () => { + if (!exists) throw { _tag: "SessionNotFoundError", sessionID: child.id }; + return child; + }; + harness.api.session.active = async () => (exists ? { child: { type: "running" } } : {}); + const store = new ProviderSubagentStore(); + const children = new SessionChildren({ + client: () => harness.api, + id: "session", + emit: (event) => { + if (event.type === "provider_subagent") store.apply("parent", "opencode", event.event); + }, + reconcilePermissions: async () => {}, + }); + await children.reconcile("session"); + expect(store.list("parent")).toMatchObject([{ id: "child", status: "running" }]); + exists = false; + if (trigger === "reconnect") await children.reconcile("session"); + else + await children.observe({ + id: "deleted", + created: 2, + type: "session.deleted", + durable: { aggregateID: "child", seq: 1, version: 1 }, + data: { sessionID: "child" }, + }); + expect(store.list("parent")).toEqual([]); + }); + + test.each(["deletion event", "reconnect"])( + "removes a deleted child's descendants on %s without removing its sibling", + async (trigger) => { + const { sessions, children, ids } = setupTree(); + await children.reconcile("session"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + sessions.delete("first"); + sessions.delete("grandchild"); + if (trigger === "reconnect") await children.reconcile("session"); + else + await children.observe({ + id: "deleted", + created: 2, + type: "session.deleted", + durable: { aggregateID: "first", seq: 1, version: 1 }, + data: { sessionID: "first" }, + }); + expect(ids()).toEqual(["second"]); + }, + ); + + test("retains children on later pages and when reconciling only another subtree", async () => { + const { harness, sessions, children, ids } = setupTree(); + await children.reconcile("session"); + const list = harness.api.session.list; + harness.api.session.list = async (input) => { + if (input?.cursor === "second-page") return { data: [sessions.get("second")!], cursor: {} }; + if (input?.parentID === "session") + return { data: [sessions.get("first")!], cursor: { next: "second-page" } }; + return list(input); + }; + await children.reconcile("session"); + await children.reconcile("first"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + }); + + test("keeps missing children when a listing fails before its last page", async () => { + const { harness, sessions, children, ids } = setupTree(); + await children.reconcile("session"); + sessions.delete("first"); + sessions.delete("grandchild"); + const list = harness.api.session.list; + harness.api.session.list = async (input) => { + if (input?.cursor) throw new Error("Provider disconnected"); + if (input?.parentID === "session") + return { data: [sessions.get("second")!], cursor: { next: "second-page" } }; + return list(input); + }; + await expect(children.reconcile("session")).rejects.toThrow("Provider disconnected"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + harness.api.session.list = list; + await children.reconcile("session"); + expect(ids()).toEqual(["second"]); + }); + test("restores agent, model, variant and all token categories in the child subtitle", async () => { const { children, upserts } = setup(); await children.reconcile("session"); diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts index 08af88dcb0..6488139b50 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -17,11 +17,19 @@ interface ChildrenOptions { reconcilePermissions(id: string): Promise; bindChild?: (id: string) => void; } +interface Child { + parentID: string; + timeline: V2Timeline; + signature?: string; +} export class SessionChildren { - private readonly children = new Map(); - private readonly childStates = new Map(); + private readonly children = new Map(); constructor(private readonly options: ChildrenOptions) {} async observe(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "session.deleted") { + this.remove(event.data.sessionID); + return; + } if ( event.type === "session.created" && event.data.parentID && @@ -29,17 +37,18 @@ export class SessionChildren { ) { await this.reconcile(event.data.parentID); } - if ( - "sessionID" in event.data && - typeof event.data.sessionID === "string" && - this.children.has(event.data.sessionID) - ) { - await this.reconcileChild( - await this.options.client().session.get({ sessionID: event.data.sessionID }), - ); + if ("sessionID" in event.data && typeof event.data.sessionID === "string") { + const child = this.children.get(event.data.sessionID); + if (child) + await this.reconcileChild( + await this.options.client().session.get({ sessionID: event.data.sessionID }), + child.parentID, + ); } } async reconcile(parentID: string) { + const previous = [...this.children].filter(([, child]) => child.parentID === parentID); + const seen = new Set(); let cursor: string | undefined; do { const page = await this.options.client().session.list({ @@ -47,18 +56,35 @@ export class SessionChildren { limit: 100, }); for (const child of page.data) { - await this.reconcileChild(child); + seen.add(child.id); + await this.reconcileChild(child, parentID); await this.reconcile(child.id); } cursor = page.cursor.next ?? undefined; } while (cursor); + // A complete parent listing is authoritative. Do not prune on a partial or failed page. + for (const [id] of previous) { + if (!seen.has(id)) this.remove(id); + } } - private async reconcileChild(info: SessionInfo) { + private remove(id: string) { + if (!this.children.delete(id)) return; + // OpenCode deletes a session's descendants with it. + for (const [childID, child] of this.children) { + if (child.parentID === id) this.remove(childID); + } + this.options.emit({ + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id }, + }); + } + private async reconcileChild(info: SessionInfo, parentID: string) { await this.options.reconcilePermissions(info.id); - let timeline = this.children.get(info.id); - if (!timeline) { - timeline = new V2Timeline(); - this.children.set(info.id, timeline); + let child = this.children.get(info.id); + if (!child) { + child = { parentID, timeline: new V2Timeline() }; + this.children.set(info.id, child); this.options.bindChild?.(info.id); } const active = await this.options.client().session.active(); @@ -88,11 +114,11 @@ export class SessionChildren { timestamp: new Date(info.time.updated).toISOString(), }; const signature = JSON.stringify(presentation); - if (this.childStates.get(info.id) !== signature) { - this.childStates.set(info.id, signature); + if (child.signature !== signature) { + child.signature = signature; this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); } - await timeline.messages(await messages(this.options.client(), info.id), (event) => { + await child.timeline.messages(await messages(this.options.client(), info.id), (event) => { if (event.type === "timeline") this.options.emit({ type: "provider_subagent",