aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/opencode/herdr-tui-session.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/integration/assets/opencode/herdr-tui-session.test.ts')
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts224
1 files changed, 221 insertions, 3 deletions
diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts
index 1bcb1f81..dd031527 100644
--- a/src/integration/assets/opencode/herdr-tui-session.test.ts
+++ b/src/integration/assets/opencode/herdr-tui-session.test.ts
@@ -3,15 +3,25 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test";
const requests: unknown[] = [];
const activeDisposers: Array<() => void> = [];
const requestWaiters: Array<() => void> = [];
+const stateWaiters: Array<() => void> = [];
let importCounter = 0;
+let holdConnections = false;
+let failConnections = false;
+const connections: Array<() => void> = [];
mock.module("node:net", () => ({
default: {
createConnection(_path: string, onConnect: () => void) {
const handlers = new Map<string, () => void>();
const client = {
+ destroyed: false,
write(input: string) {
- requests.push(JSON.parse(input.trim()));
+ if (client.destroyed) return;
+ const request = JSON.parse(input.trim());
+ requests.push(request);
+ if (isRecord(request) && isRecord(request.params) && request.params.state !== undefined) {
+ stateWaiters.shift()?.();
+ }
requestWaiters.shift()?.();
queueMicrotask(() => client.emit("data"));
},
@@ -19,12 +29,16 @@ mock.module("node:net", () => ({
on(event: string, handler: () => void) {
handlers.set(event, handler);
},
- destroy() {},
+ destroy() {
+ client.destroyed = true;
+ },
emit(event: string) {
handlers.get(event)?.();
},
};
- queueMicrotask(onConnect);
+ if (holdConnections) connections.push(onConnect);
+ else if (failConnections) queueMicrotask(() => client.emit("error"));
+ else queueMicrotask(onConnect);
return client;
},
},
@@ -33,6 +47,10 @@ mock.module("node:net", () => ({
beforeEach(() => {
requests.length = 0;
requestWaiters.length = 0;
+ stateWaiters.length = 0;
+ holdConnections = false;
+ failConnections = false;
+ connections.length = 0;
process.env.HERDR_ENV = "1";
process.env.HERDR_SOCKET_PATH = "test.sock";
process.env.HERDR_PANE_ID = "test:p1";
@@ -93,6 +111,10 @@ function waitForNextRequest(): Promise<void> {
return new Promise((resolve) => requestWaiters.push(resolve));
}
+function waitForStateReport(): Promise<void> {
+ return new Promise((resolve) => stateWaiters.push(resolve));
+}
+
test("reports a root session when only the local route changes", async () => {
const plugin = await loadPlugin();
const tui = fakeApi();
@@ -179,3 +201,199 @@ function requestParam(request: unknown, name: string): unknown {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+
+function v2Api() {
+ const sessions = new Map([
+ ["a", { id: "a" }],
+ ["b", { id: "b" }],
+ ["child", { id: "child", parentID: "a" }],
+ ]);
+ let route = { type: "session", sessionID: "a" };
+ const listeners = new Set<(event: unknown) => void>();
+ const permissions = new Map<string, Array<{ id: string }> | undefined>();
+ const forms = new Map<string, Array<{ id: string }> | undefined>();
+ return {
+ api: {
+ ui: { router: { current: () => route } },
+ data: {
+ session: {
+ get: (id: string) => sessions.get(id),
+ family: () => [...sessions.keys()],
+ status: () => "idle",
+ permission: { list: (id: string) => permissions.get(id) },
+ form: { list: (id: string) => forms.get(id) },
+ },
+ listen: (handler: (event: unknown) => void) => {
+ listeners.add(handler);
+ return () => listeners.delete(handler);
+ },
+ },
+ },
+ select(sessionID: string) { route = { type: "session", sessionID }; },
+ home() { route = { type: "home", sessionID: "" }; },
+ emit(type: string, data?: object) {
+ for (const listener of listeners) listener({ details: { type, data } });
+ },
+ listeners,
+ sessions,
+ permissions,
+ forms,
+ };
+}
+
+const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10));
+const states = () => requests.filter((r) => requestParam(r, "state") !== undefined)
+ .map((r) => requestParam(r, "state"));
+
+test("V2 ignores events without data", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ requests.length = 0;
+ expect(() => tui.emit("legacy.event")).not.toThrow();
+ tui.emit("session.execution.started", { sessionID: "a" });
+ await flushReports();
+ expect(states()).toEqual(["working"]);
+});
+
+test("V2 completes and interrupts without legacy idle events", async () => {
+ for (const terminal of ["succeeded", "interrupted", "failed"]) {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ requests.length = 0;
+ tui.emit("session.execution.started", { sessionID: "a" });
+ tui.emit(`session.execution.${terminal}`, { sessionID: "a" });
+ await flushReports();
+ expect(states()).toEqual(["working", terminal === "failed" ? "blocked" : "idle"]);
+ dispose();
+ }
+});
+
+test("V2 aggregates root and child blockers and ignores other roots and child completion", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ requests.length = 0;
+ tui.emit("session.execution.started", { sessionID: "a" });
+ tui.emit("permission.asked", { sessionID: "a", id: "permission-a" });
+ tui.emit("form.created", { form: { sessionID: "child", id: "form-child" } });
+ tui.emit("permission.replied", { sessionID: "a", requestID: "permission-a" });
+ tui.emit("session.execution.succeeded", { sessionID: "child" });
+ tui.emit("session.execution.started", { sessionID: "b" });
+ tui.emit("permission.asked", { sessionID: "b", id: "other" });
+ await flushReports();
+ expect(states().at(-1)).toBe("blocked");
+ expect(requests.every((r) => requestParam(r, "agent_session_id") === "a")).toBe(true);
+ tui.emit("form.cancelled", { sessionID: "child", id: "form-child" });
+ tui.emit("session.execution.succeeded", { sessionID: "a" });
+ await flushReports();
+ expect(states().slice(-2)).toEqual(["working", "idle"]);
+});
+
+test("V2 discards queued reports after selection changes and stops on disposal", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ requests.length = 0;
+ tui.emit("session.execution.started", { sessionID: "a" });
+ tui.select("b");
+ tui.emit("session.execution.started", { sessionID: "b" });
+ await flushReports();
+ expect(requests.every((r) => requestParam(r, "agent_session_id") === "b")).toBe(true);
+ requests.length = 0;
+ tui.emit("session.execution.succeeded", { sessionID: "b" });
+ tui.home();
+ await flushReports();
+ expect(requests).toHaveLength(0);
+ dispose();
+ expect(tui.listeners.size).toBe(0);
+ tui.select("a");
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ expect(requests).toHaveLength(0);
+});
+
+test("V2 reconciles late blocker hydration without reviving an already-replied request", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ tui.permissions.set("child", [{ id: "late" }]);
+ await waitForStateReport();
+ expect(states().at(-1)).toBe("blocked");
+ tui.emit("permission.replied", { sessionID: "child", requestID: "late" });
+ await waitForStateReport();
+ expect(states().at(-1)).toBe("idle");
+ tui.permissions.set("child", []);
+ tui.forms.set("child", [{ id: "second" }]);
+ await waitForStateReport();
+ expect(states().at(-1)).toBe("blocked");
+ tui.sessions.delete("child");
+ tui.emit("session.deleted", { sessionID: "child" });
+ await flushReports();
+ expect(states().at(-1)).toBe("idle");
+});
+
+test("V2 never writes a delayed connection after disposal or a session switch", async () => {
+ for (const action of ["dispose", "switch"]) {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ holdConnections = true;
+ requests.length = 0;
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ expect(connections.length).toBeGreaterThan(0);
+ if (action === "dispose") dispose();
+ else tui.select("b");
+ holdConnections = false;
+ for (const connect of connections.splice(0)) connect();
+ await flushReports();
+ expect(requests).toHaveLength(0);
+ dispose();
+ }
+});
+
+test("V2 settles a connection that never completes", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ holdConnections = true;
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ const started = Date.now();
+ while (connections.length <= 1 && Date.now() - started < 2_000) {
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ expect(connections.length).toBeGreaterThan(1);
+ dispose();
+});
+
+test("V2 resends the latest state after a failed delivery", async () => {
+ const plugin = await loadPlugin();
+ const tui = v2Api();
+ const dispose = await plugin.setup(tui.api);
+ activeDisposers.push(dispose);
+ await flushReports();
+ // Exhaust the selection retry schedule so only the event report remains.
+ await new Promise((resolve) => setTimeout(resolve, 1_600));
+ requests.length = 0;
+ tui.emit("session.execution.started", { sessionID: "a" });
+ await flushReports();
+ failConnections = true;
+ tui.emit("session.execution.succeeded", { sessionID: "a" });
+ const resend = waitForStateReport();
+ await new Promise((resolve) => setTimeout(resolve, 700));
+ failConnections = false;
+ await resend;
+ expect(states().at(-1)).toBe("idle");
+ dispose();
+});