mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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",
|
||||
|
||||
Reference in New Issue
Block a user