mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575dbbec3c | ||
|
|
d47bd52cfc | ||
|
|
81f75f57c8 |
@@ -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,204 @@ 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, store };
|
||||
}
|
||||
|
||||
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.each(["present", "deleted"])(
|
||||
"reconciles a live child skipped by the reconnect cursor when its grandchild is %s",
|
||||
async (grandchild) => {
|
||||
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);
|
||||
if (grandchild === "deleted") sessions.delete("grandchild");
|
||||
const remaining = expected.filter((id) => grandchild === "present" || id !== "grandchild");
|
||||
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(remaining);
|
||||
expect(store.list("parent").find((child) => child.id === "first")).toMatchObject({
|
||||
title: "Updated during reconnect",
|
||||
});
|
||||
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(remaining);
|
||||
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");
|
||||
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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,44 @@ 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);
|
||||
// Updates can move a live child ahead of the cursor. Confirm deletion before pruning.
|
||||
for (const [id] of previous) {
|
||||
if (seen.has(id)) continue;
|
||||
let info: SessionInfo;
|
||||
try {
|
||||
info = await this.options.client().session.get({ sessionID: id });
|
||||
} catch (error) {
|
||||
if (isSessionNotFoundError(error)) this.remove(id);
|
||||
continue;
|
||||
}
|
||||
await this.reconcileChild(info, parentID);
|
||||
await this.reconcile(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 +123,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",
|
||||
|
||||
Reference in New Issue
Block a user