diff options
| author | Mark Jaquith <mark.jaquith@gusto.com> | 2026-09-11 16:36:18 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-11 22:36:18 +0200 |
| commit | fb5525fc2234a246dfdfc9e0d7ed73c824cbd8a6 (patch) | |
| tree | f989f8175e8afdd89824fd94477b1b1645af05ff /src/integration/assets/opencode/herdr-tui-session.test.ts | |
| parent | 38d002de59331b27aee2f5838639ea27a04878f8 (diff) | |
fix: opencode v2 lifecycle reporting (#3757)
* fix: support opencode v2 lifecycle reporting
* fix: repair Japanese docs
* fix: ignore payload-less OpenCode events
* fix: harden opencode v2 integration install and reporting
Follow-ups on top of the V2 lifecycle support:
- create `cli.json` when OpenCode has no V1 TUI preferences (`tui.json` or
`kv.json`) to migrate, instead of only registering into an existing file
- settle stalled socket attempts with a plain connect timer, and resend the
latest lifecycle state after a failed delivery so the pane cannot get stuck
- resolve the OpenCode state directory from `XDG_STATE_HOME`
- remove the managed `herdr-opencode` directory on uninstall
- document failed executions reporting `blocked`, keep the integration test
environment independent of an inherited `XDG_STATE_HOME`, and simplify
`reconcileBlockers` to reassign its map
---------
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/integration/assets/opencode/herdr-tui-session.test.ts')
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.test.ts | 224 |
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(); +}); |
