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 02d9b26567..b30b80c5bc 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 @@ -68,7 +68,7 @@ function setupTree() { .list("parent") .map((child) => child.id) .sort(); - return { harness, sessions, children, ids }; + return { harness, sessions, children, ids, store }; } describe("OpenCode v2 child presentation", () => { @@ -145,6 +145,68 @@ describe("OpenCode v2 child presentation", () => { expect(ids()).toEqual(["first", "grandchild", "second"]); }); + test("retains a live child and descendants when an update moves it ahead of the reconnect cursor", async () => { + const { harness, sessions, children, ids, store } = setupTree(); + for (let i = 3; i <= 101; i++) { + sessions.set(`child-${i}`, { + ...harness.info, + id: `child-${i}`, + parentID: "session", + time: { created: i, updated: i }, + }); + } + sessions.get("second")!.time = { created: 2, updated: 2 }; + await children.reconcile("session"); + const expected = ids(); + expect(expected).toHaveLength(102); + const list = harness.api.session.list; + harness.api.session.list = async (input) => { + if (input?.parentID !== "session" && !input?.cursor) return list(input); + const page = [...sessions.values()] + .filter((info) => info.parentID === "session") + .filter((info) => !input.cursor || info.time.updated < Number(input.cursor)) + .sort((a, b) => b.time.updated - a.time.updated) + .slice(0, input.limit); + const next = page.length === input.limit ? String(page.at(-1)!.time.updated) : undefined; + if (!input.cursor) { + // The oldest child was not on page one. Updating it moves it ahead of that page's cursor. + const first = sessions.get("first")!; + first.time = { ...first.time, updated: 200 }; + first.title = "Updated during reconnect"; + } + return { data: page, cursor: { next } }; + }; + await children.reconcile("session"); + expect(ids()).toEqual(expected); + await children.observe({ + id: "queued-update", + created: 200, + type: "session.execution.started", + durable: { aggregateID: "first", seq: 1, version: 1 }, + data: { sessionID: "first" }, + }); + expect(ids()).toEqual(expected); + expect(store.list("parent").find((child) => child.id === "first")).toMatchObject({ + title: "Updated during reconnect", + }); + }); + + test("retains an absent child's subtree when its existence cannot be confirmed", async () => { + const { harness, sessions, children, ids } = setupTree(); + await children.reconcile("session"); + sessions.delete("first"); + sessions.delete("grandchild"); + const get = harness.api.session.get; + harness.api.session.get = async () => { + throw new Error("Provider disconnected"); + }; + await children.reconcile("session"); + expect(ids()).toEqual(["first", "grandchild", "second"]); + harness.api.session.get = get; + await children.reconcile("session"); + expect(ids()).toEqual(["second"]); + }); + test("keeps missing children when a listing fails before its last page", async () => { const { harness, sessions, children, ids } = setupTree(); 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 6488139b50..588ecbb649 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/children.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -1,6 +1,6 @@ import { V2Timeline } from "./timeline.js"; -import type { SessionInfo } from "@opencode/client"; +import { isSessionNotFoundError, type SessionInfo } from "@opencode/client"; import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; @@ -62,9 +62,14 @@ export class SessionChildren { } cursor = page.cursor.next ?? undefined; } while (cursor); - // A complete parent listing is authoritative. Do not prune on a partial or failed page. + // Updates can move a live child ahead of the cursor. Confirm deletion before pruning. for (const [id] of previous) { - if (!seen.has(id)) this.remove(id); + if (seen.has(id)) continue; + try { + await this.options.client().session.get({ sessionID: id }); + } catch (error) { + if (isSessionNotFoundError(error)) this.remove(id); + } } } private remove(id: string) {