Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 81f75f57c8 fix(opencode): clear deleted subagents from the track
Handle provider deletion events before fetching the missing session, and reconcile removals after complete parent listings so reconnects also clear stale rows. Keep descendant cleanup scoped to the deleted child.
2026-10-31 00:18:57 +00:00
2 changed files with 173 additions and 19 deletions
@@ -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<string, SessionInfo>(
[
{ ...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");
@@ -17,11 +17,19 @@ interface ChildrenOptions {
reconcilePermissions(id: string): Promise<void>;
bindChild?: (id: string) => void;
}
interface Child {
parentID: string;
timeline: V2Timeline;
signature?: string;
}
export class SessionChildren {
private readonly children = new Map<string, V2Timeline>();
private readonly childStates = new Map<string, string>();
private readonly children = new Map<string, Child>();
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<string>();
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",