diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-22 16:25:17 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-22 16:25:17 +0300 |
| commit | f6390752a57a5ac3256f3213af870c0b62e224d7 (patch) | |
| tree | 5b622345c5b274d82a9ff8a80a2be8adf7f67968 /src/integration/assets/opencode | |
| parent | 46a5091838de66c1be193990afc3ad9145e6b4f5 (diff) | |
fix: track opencode subagent status per pane (#4357)
refs #1362
Diffstat (limited to 'src/integration/assets/opencode')
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 22 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.test.ts | 27 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.js | 376 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.test.ts | 318 | ||||
| -rw-r--r-- | src/integration/assets/opencode/tui.js | 2 |
5 files changed, 682 insertions, 63 deletions
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 73a70534..09c00704 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -118,8 +118,23 @@ function reportState(state, sessionID) { return request("pane.report_agent", params); } +function ownsLocalLifecycle() { + const args = process.argv.slice(2); + const separator = args.indexOf("--"); + if (separator !== -1) args.splice(separator); + if (args.some((arg) => arg === "--attach" || arg.startsWith("--attach="))) return false; + while (args[0] === "--print-logs" || args[0] === "--log-level" || args[0]?.startsWith("--log-level=")) { + args.splice(0, args[0] === "--log-level" ? 2 : 1); + } + // These local clients have no TUI plugin. Shared servers and the TUI worker + // cannot identify their attached panes; their lifecycle belongs to each TUI. + return args[0] === "run" || + (!["serve", "web", "attach"].includes(args[0]) && args.includes("--mini")); +} + export const HerdrAgentStatePlugin = async () => { if ( + !ownsLocalLifecycle() || process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID @@ -200,9 +215,8 @@ export const HerdrAgentStatePlugin = async () => { }; }; -// V1 (1.18.29+) calls server(). V2 calls setup() instead. Its shared server -// cannot attribute sessions using its process environment: the pane-local TUI -// owns both selection and lifecycle reporting there, including remote servers. +// V1 local run/Mini retain their server hooks. V1/V2 full TUIs own both +// selection and lifecycle, including when attached to a shared remote server. export default { id: "herdr.opencode", server: HerdrAgentStatePlugin, diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts index 38c1f05c..93d27583 100644 --- a/src/integration/assets/opencode/herdr-agent-state.test.ts +++ b/src/integration/assets/opencode/herdr-agent-state.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + +const originalArgv = process.argv; +afterEach(() => { process.argv = originalArgv; }); const requests: unknown[] = []; const clients: FakeClient[] = []; @@ -43,6 +46,7 @@ beforeEach(() => { clients.length = 0; requestWaiters.length = 0; autoAcknowledge = true; + process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = "test.sock"; process.env.HERDR_PANE_ID = "test:p1"; @@ -223,6 +227,27 @@ test("routes nested child prompts to their own root, not the last active root", ]); }); +test("only local run and Mini own server lifecycle, never shared servers or TUI workers", async () => { + for (const args of [ + ["run"], ["run", "--session", "existing"], ["--mini"], ["--mini", "--session", "existing"], + ["--print-logs", "--log-level", "DEBUG", "run"], ["run", "--", "--attach"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect((await loadPlugin()).event).toBeFunction(); + } + for (const args of [ + [], ["--session", "existing"], ["serve"], ["web"], ["attach", "http://localhost:4096"], + ["run", "--attach", "http://localhost:4096"], ["--mini", "--attach=http://localhost:4096"], + ["serve", "--", "--mini"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect(await loadPlugin()).toEqual({}); + } + process.argv = ["bun", "/$bunfs/root/src/cli/tui/worker.js"]; + expect(await loadPlugin()).toEqual({}); + expect(requests).toHaveLength(0); +}); + function requestMethod(request: unknown): unknown { return isRecord(request) ? request.method : undefined; } diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js index 4e5631ac..d21c1f7e 100644 --- a/src/integration/assets/opencode/herdr-tui-session.js +++ b/src/integration/assets/opencode/herdr-tui-session.js @@ -1,7 +1,7 @@ // installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // HERDR_INTEGRATION_ID=opencode-tui -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -67,65 +67,341 @@ export default { // Keep this plain object dependency-free: V1 and V2 expose different SDK // packages, but both loaders accept their own lifecycle entry on this object. setup, - tui: async (api) => { - if ( - process.env.HERDR_ENV !== "1" || - !process.env.HERDR_SOCKET_PATH || - !process.env.HERDR_PANE_ID - ) { - return; + tui, +}; + +async function tui(api) { + if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; + + let disposed = false; + let context; + let sequence = Date.now() * 1000; + let chain = Promise.resolve(); + + function routeID() { + const route = api.route.current; + return route?.name === "session" ? route.params?.sessionID : undefined; + } + + function current(ctx) { + return !disposed && context === ctx && routeID() === ctx.route; + } + + async function read(ctx, request) { + const result = await request({ + signal: AbortSignal.any([ctx.controller.signal, AbortSignal.timeout(5_000)]), + throwOnError: true, + }); + if (!current(ctx) || result?.data === undefined) throw new Error("session data unavailable"); + return result.data; + } + + // The first selected session owns this pane's subtree. Browsing descendants + // keeps that boundary; directly attaching to a child does not claim siblings. + function root(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + const session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) return; + if (!session.parentID) return id; + id = session.parentID; } + } - let selectedSessionID; - let retryIndex = 0; - let nextReportAt = 0; - let reportPending = false; - const syncSelectedSession = async () => { - const route = api.route.current; - const sessionID = route?.name === "session" ? route.params?.sessionID : undefined; - const session = - typeof sessionID === "string" && sessionID - ? api.state.session.get(sessionID) - : undefined; - if (!session || session.parentID) { - selectedSessionID = undefined; - retryIndex = 0; - nextReportAt = 0; - return; + async function resolveRoot(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + let session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) { + if (!ctx.lookups.has(id)) { + const requestedID = id; + const lookup = read(ctx, (options) => api.client.session.get({ sessionID: requestedID }, options)); + ctx.lookups.set(id, lookup); + lookup.finally(() => ctx.lookups.delete(requestedID)).catch(() => {}); + } + session = await ctx.lookups.get(id); + if (ctx.deleted.has(id)) return; + session = ctx.sessions.get(id) ?? session; + if (session.id !== id) throw new Error("unexpected session identity"); + ctx.sessions.set(id, session); } - if (sessionID !== selectedSessionID) { - selectedSessionID = sessionID; - retryIndex = 0; - nextReportAt = 0; + if (!session.parentID) return id; + id = session.parentID; + } + } + + function owners(ctx) { + return new Set([...ctx.statuses.keys(), ...[...ctx.blockers.values()].map((r) => r.sessionID)]); + } + + function state(ctx) { + if (ctx.settled) return "idle"; + for (const request of ctx.blockers.values()) { + if (root(ctx, request.sessionID) === ctx.selected) return "blocked"; + } + if (ctx.errors.has(ctx.selected)) return "blocked"; + for (const id of ctx.statuses.keys()) { + if (root(ctx, id) === ctx.selected) return "working"; + } + if (ctx.hydrated && [...owners(ctx)].every((id) => root(ctx, id) !== undefined)) return "idle"; + } + + function publish(ctx, selection = false) { + if (!current(ctx) || !ctx.selected) return; + ctx.selectionPending ||= selection; + if (ctx.queued) return; + ctx.queued = true; + chain = chain.then(async () => { + ctx.queued = false; + const selected = ctx.selected; + const isCurrent = () => current(ctx) && !!selected && ctx.selected === selected && + (ctx.settled || root(ctx, ctx.route) === selected); + if (!isCurrent()) return; + if (ctx.selectionPending) { + if (!await requestOnce(selected, undefined, undefined, isCurrent)) { + ctx.retryAt = Date.now() + 500; + return; + } + if (!isCurrent()) return; + ctx.selectionPending = false; + ctx.lastState = undefined; } - if (reportPending || Date.now() < nextReportAt) { - return; + const value = state(ctx); + if (value === undefined || value === ctx.lastState) return; + const delivered = await requestOnce(selected, value, ++sequence, isCurrent); + if (!isCurrent()) return; + ctx.lastState = delivered ? value : undefined; + if (!delivered) ctx.retryAt = Date.now() + 500; + }).catch(() => { + if (current(ctx)) { + ctx.lastState = undefined; + ctx.retryAt = Date.now() + 500; + } + }); + } + + function retire(ctx, route) { + ctx?.controller?.abort(); + context = ctx?.selected + ? { route, selected: ctx.selected, settled: true, retryAt: Infinity } + : undefined; + if (context) publish(context); + } + + function reconcile(ctx) { + if (!current(ctx)) return; + publish(ctx); + if (ctx.resolving) { + ctx.resolveAgain = true; + return; + } + ctx.resolving = true; + void (async () => { + const ancestor = await resolveRoot(ctx, ctx.route); + if (!current(ctx) || !ancestor) return; + const selected = ancestor === ctx.boundary ? ctx.boundary : ctx.route; + if (selected !== ctx.selected) { + ctx.boundary = selected; + ctx.selected = selected; + publish(ctx, true); } + // Fetch ancestry only for active/request-bearing sessions, not historical trees. + await Promise.all([...owners(ctx)].map((id) => resolveRoot(ctx, id))); + if (current(ctx)) publish(ctx); + })().catch(() => { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + }).finally(() => { + ctx.resolving = false; + if (ctx.resolveAgain) { + ctx.resolveAgain = false; + reconcile(ctx); + } + }); + } - const reportingSessionID = sessionID; - reportPending = true; - try { - await requestOnce(reportingSessionID); - } catch { - // Best-effort reporting retries below while the selected route remains active. - } finally { - reportPending = false; + function clearRequests(ctx, matches) { + for (const [key, request] of ctx.blockers) if (matches(request)) ctx.blockers.delete(key); + } + + function terminalTool(part) { + return part?.type === "tool" && ["completed", "error"].includes(part.state?.status); + } + + function apply(ctx, event) { + const data = event.properties; + if (!data) return; + if (event.type === "message.part.updated") { + const part = data.part; + if (terminalTool(part)) { + clearRequests(ctx, (r) => r.sessionID === part.sessionID && + r.tool?.messageID === part.messageID && r.tool?.callID === part.callID); + } + return; + } + const id = data.sessionID ?? data.info?.id; + if (typeof id !== "string" || ctx.deleted.has(id)) return; + switch (event.type) { + case "session.created": + case "session.updated": + if (data.info) ctx.sessions.set(id, data.info); + break; + case "session.deleted": + ctx.deleted.add(id); + ctx.sessions.delete(id); + ctx.statuses.delete(id); + ctx.errors.delete(id); + clearRequests(ctx, (r) => r.sessionID === id); + if (ctx.selected === id) retire(ctx, ctx.route); + break; + case "session.status": + case "session.idle": { + const status = event.type === "session.idle" ? "idle" : data.status?.type; + if (status === "busy" || status === "retry") { + ctx.statuses.set(id, status); + ctx.errors.delete(id); + } else if (status === "idle") { + ctx.statuses.delete(id); + ctx.errors.delete(id); + } + break; } - if (selectedSessionID !== reportingSessionID) { - retryIndex = 0; - nextReportAt = 0; + case "session.error": + if (data.error?.name !== "MessageAbortedError") ctx.errors.add(id); + break; + case "permission.asked": + case "question.asked": + if (typeof data.id === "string") ctx.blockers.set(`${event.type.split(".")[0]}:${data.id}`, data); + break; + case "permission.replied": + case "question.replied": + case "question.rejected": + ctx.blockers.delete(`${event.type.split(".")[0]}:${data.requestID}`); + break; + } + } + + async function hydrate(ctx) { + if (!current(ctx) || ctx.loading) return; + ctx.loading = true; + try { + const [statuses, permissions, questions] = await Promise.all([ + read(ctx, (options) => api.client.session.status(undefined, options)), + read(ctx, (options) => api.client.permission.list(undefined, options)), + read(ctx, (options) => api.client.question.list(undefined, options)), + ]); + if (!statuses || typeof statuses !== "object" || Array.isArray(statuses) || + !Object.values(statuses).every((s) => ["busy", "retry", "idle"].includes(s?.type)) || + !Array.isArray(permissions) || !Array.isArray(questions)) { + throw new Error("incomplete session snapshot"); + } + const messages = new Map(); + const blockers = new Map(); + let validated = true; + await Promise.all([["permission", permissions], ["question", questions]].flatMap(([kind, requests]) => + requests.map(async (request) => { + if (ctx.deleted.has(request.sessionID)) return; + if (request.tool) { + const { messageID, callID } = request.tool; + const key = `${request.sessionID}:${messageID}`; + if (!messages.has(key)) messages.set(key, read(ctx, (options) => api.client.session.message({ + sessionID: request.sessionID, messageID, + }, options))); + try { + const message = await messages.get(key); + const part = message.parts?.find((p) => p.type === "tool" && p.callID === callID); + // OpenCode can retain pending requests after abort. Status alone is + // insufficient: the same session may already be running a new turn. + if (terminalTool(part)) return; + } catch { + validated = false; + } + } + if (!ctx.deleted.has(request.sessionID)) blockers.set(`${kind}:${request.id}`, request); + }))); + if (!current(ctx)) return; + ctx.statuses = new Map(Object.entries(statuses) + .filter(([id, status]) => !ctx.deleted.has(id) && (status.type === "busy" || status.type === "retry"))); + ctx.blockers = blockers; + // Preserve deltas until hydration succeeds, including retry backoff: + // a later snapshot must not revive a replied or cancelled request. + for (const event of ctx.events) apply(ctx, event); + ctx.hydrated = validated; + if (!validated) ctx.retryAt = Date.now() + 500; + reconcile(ctx); + } catch { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + } finally { + ctx.loading = false; + if (ctx.hydrated) ctx.events = []; + } + } + + function syncSelection(reset = false) { + if (disposed) return; + const id = routeID(); + if (reset || id !== context?.route) { + if (typeof id !== "string" || !id) { + retire(context, id); return; } - const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex]; - retryIndex += 1; - nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay; - }; + const boundary = context?.settled ? undefined : context?.selected; + context?.controller?.abort(); + const ctx = { + route: id, boundary, controller: new AbortController(), sessions: new Map(), lookups: new Map(), + statuses: new Map(), blockers: new Map(), errors: new Set(), deleted: new Set(), + events: [], hydrated: false, loading: false, resolving: false, + retryAt: Infinity, selectionAt: 0, retryIndex: 0, + }; + context = ctx; + reconcile(ctx); + void hydrate(ctx); + } + const ctx = context; + if (!ctx) return; + if (!ctx.settled && ctx.selected && Date.now() >= ctx.selectionAt) { + publish(ctx, true); + const delay = SELECTION_RETRY_DELAYS_MS[ctx.retryIndex++]; + ctx.selectionAt = delay === undefined ? Infinity : Date.now() + delay; + } + if (Date.now() >= ctx.retryAt) { + ctx.retryAt = Infinity; + if (ctx.settled) publish(ctx); + else { + if (!ctx.hydrated) void hydrate(ctx); + reconcile(ctx); + } + } + } - await syncSelectedSession(); - const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS); - api.lifecycle.onDispose(() => clearInterval(routePoll)); - }, -}; + const subscriptions = [ + "session.created", "session.updated", "session.deleted", "session.status", "session.idle", "session.error", + "permission.asked", "permission.replied", "question.asked", "question.replied", "question.rejected", + "message.part.updated", + ].map((type) => api.event.on(type, (event) => { + if (type === "message.part.updated" && !terminalTool(event.properties?.part)) return; + syncSelection(); + const ctx = context; + if (!ctx || ctx.settled) return; + if (ctx.loading || !ctx.hydrated) ctx.events.push(event); + apply(ctx, event); + reconcile(ctx); + })); + for (const type of ["server.connected", "server.instance.disposed", "global.disposed"]) { + subscriptions.push(api.event.on(type, () => syncSelection(true))); + } + const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS); + api.lifecycle.onDispose(() => { + disposed = true; + context?.controller?.abort(); + clearInterval(poll); + for (const unsubscribe of subscriptions) unsubscribe(); + }); + syncSelection(); +} function setup(api) { if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts index dd031527..5ef5ce1a 100644 --- a/src/integration/assets/opencode/herdr-tui-session.test.ts +++ b/src/integration/assets/opencode/herdr-tui-session.test.ts @@ -70,12 +70,48 @@ async function loadPlugin() { function fakeApi() { const sessions = new Map<string, { id: string; parentID?: string }>(); + const statuses: Record<string, { type: string }> = {}; + const permissions: Array<{ id: string; sessionID: string; tool?: { messageID: string; callID: string } }> = []; + const questions: typeof permissions = []; + const messages = new Map<string, { info?: { error?: { name: string } }; parts: Array<object> }>(); + const listeners = new Map<string, Set<(event: object) => void>>(); + const calls: string[] = []; let current: { name: string; params?: { sessionID: string } } = { name: "home" }; let dispose: (() => void) | undefined; activeDisposers.push(() => dispose?.()); return { + statuses, permissions, questions, messages, listeners, calls, + emit(type: string, properties: object) { + for (const receive of listeners.get(type) ?? []) receive({ type, properties }); + }, api: { + client: { + session: { + async get({ sessionID }: { sessionID: string }) { + calls.push(`get:${sessionID}`); + const data = sessions.get(sessionID); + if (!data) throw new Error("session not found"); + return { data }; + }, + async status() { calls.push("status"); return { data: { ...statuses } }; }, + async message({ messageID }: { sessionID: string; messageID: string }) { + calls.push(`message:${messageID}`); + const data = messages.get(messageID); + if (!data) throw new Error("message unavailable"); + return { data }; + }, + }, + permission: { async list() { return { data: [...permissions] }; } }, + question: { async list() { return { data: [...questions] }; } }, + }, + event: { + on(type: string, receive: (event: object) => void) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(receive); + return () => listeners.get(type)!.delete(receive); + }, + }, route: { get current() { return current; @@ -101,6 +137,7 @@ function fakeApi() { select(sessionID: string) { current = { name: "session", params: { sessionID } }; }, + home() { current = { name: "home" }; }, dispose() { dispose?.(); }, @@ -140,10 +177,9 @@ test("retries an initial selection while Herdr detects the process", async () => await plugin.tui(tui.api); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([ - "session-a", - "session-a", - ]); + const selections = requests.filter((request) => requestParam(request, "state") === undefined); + expect(selections.length).toBeGreaterThanOrEqual(2); + expect(selections.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(true); }); test("does not report root sessions not selected by this TUI", async () => { @@ -169,13 +205,13 @@ test("does not replace the root session with a selected child session", async () tui.addSession({ id: "child-session", parentID: "root-session" }); tui.select("root-session"); await plugin.tui(tui.api); - expect(requests).toHaveLength(1); + await flushReports(); tui.select("child-session"); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests).toHaveLength(1); - expect(requestParam(requests[0], "agent_session_id")).toBe("root-session"); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root-session")).toBe(true); }); test("stops route polling when the TUI plugin is disposed", async () => { @@ -245,6 +281,274 @@ const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10)); const states = () => requests.filter((r) => requestParam(r, "state") !== undefined) .map((r) => requestParam(r, "state")); +function familyApi() { + const tui = fakeApi(); + tui.addSession({ id: "root" }); + tui.addSession({ id: "child", parentID: "root" }); + tui.addSession({ id: "sibling", parentID: "root" }); + tui.addSession({ id: "grandchild", parentID: "child" }); + tui.addSession({ id: "other" }); + tui.select("root"); + return tui; +} + +test("V1 hydrates active descendants and keeps working until the whole family settles", async () => { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui.statuses.grandchild = { type: "retry" }; + tui.statuses.other = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "child", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root")).toBe(true); +}); + +test("V1 retains sibling blockers and cancellation does not finish an active parent", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.asked", { id: "p", sessionID: "child" }); + tui.emit("question.asked", { id: "q", sessionID: "sibling", tool: { messageID: "m", callID: "call" } }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("permission.replied", { requestID: "p", sessionID: "child" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("session.idle", { sessionID: "sibling" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "sibling", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.idle", { sessionID: "root" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 hydration rejects aborted tool requests even if their session is busy again", async () => { + for (const kind of ["permissions", "questions"] as const) { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui[kind].push({ id: "stale", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { info: { error: { name: "MessageAbortedError" } }, parts: [ + { type: "tool", callID: "call", state: { status: "error" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.dispose(); + } +}); + +test("V1 validates pending tools instead of assuming an idle owner has no requests", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "pending", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 replays completion and replies over a late initial snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states()).not.toContain("idle"); + tui.emit("session.idle", { sessionID: "child" }); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 retains replies received between failed hydration and its retry", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child" }); + const status = tui.api.client.session.status; + tui.api.client.session.status = async () => { + tui.api.client.session.status = status; + throw new Error("temporarily unavailable"); + }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + expect(states()).not.toContain("blocked"); +}); + +test("V1 deleted sessions cannot return through a late hydration snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record<string, { type: string }> }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("session.deleted", { info: { id: "child", parentID: "root" } }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 ignores stale hydration across A/B/A route changes and disposal", async () => { + const tui = familyApi(); + let resolveOld!: (result: { data: Record<string, { type: string }> }) => void; + const status = tui.api.client.session.status; + tui.api.client.session.status = () => { + tui.api.client.session.status = status; + return new Promise((resolve) => { resolveOld = resolve; }); + }; + await (await loadPlugin()).tui(tui.api); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + requests.length = 0; + resolveOld({ data: { root: { type: "busy" } } }); + await flushReports(); + expect(states()).not.toContain("working"); + tui.dispose(); + expect([...tui.listeners.values()].every((set) => set.size === 0)).toBe(true); +}); + +test("V1 a directly attached child owns its descendants, not its parent or siblings", async () => { + const tui = familyApi(); + tui.select("child"); + tui.statuses.root = { type: "busy" }; + tui.statuses.sibling = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "busy" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.select("grandchild"); + tui.emit("session.updated", { info: { id: "grandchild", parentID: "child" } }); + await flushReports(); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "child")).toBe(true); +}); + +test("V1 a terminal tool event during hydration cannot revive a cancelled blocker", async () => { + const tui = familyApi(); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + let resolveMessage!: (result: { data: { parts: Array<object> } }) => void; + tui.api.client.session.message = () => new Promise((resolve) => { resolveMessage = resolve; }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + resolveMessage({ data: { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 unknown tool evidence remains blocked and retries failed message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 request validation matches the exact tool and deduplicates message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "running" } }); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "finished" } }); + tui.messages.set("m", { parts: [ + { type: "tool", callID: "running", state: { status: "running" } }, + { type: "tool", callID: "finished", state: { status: "completed" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + expect(tui.calls.filter((call) => call === "message:m")).toHaveLength(1); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 reselecting an aborted request does not resurrect it or poll completed tools", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(states()).not.toContain("blocked"); + const reads = tui.calls.length; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(tui.calls).toHaveLength(reads); +}); + +test("V1 home and selected-session deletion settle authority and retry a dropped idle report", async () => { + for (const action of ["home", "delete"]) { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + failConnections = true; + if (action === "home") { + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + } else tui.emit("session.deleted", { info: { id: "root" } }); + await flushReports(); + failConnections = false; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + tui.dispose(); + } +}); + +test("V1 a delayed home settlement cannot overwrite the next selected session", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + requests.length = 0; + holdConnections = true; + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(connections.length).toBeGreaterThan(0); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + holdConnections = false; + for (const connect of connections.splice(0)) connect(); + await flushReports(); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "other")).toBe(true); + expect(states().at(-1)).toBe("idle"); +}); + test("V2 ignores events without data", async () => { const plugin = await loadPlugin(); const tui = v2Api(); diff --git a/src/integration/assets/opencode/tui.js b/src/integration/assets/opencode/tui.js index cc1ffe6a..92423a8f 100644 --- a/src/integration/assets/opencode/tui.js +++ b/src/integration/assets/opencode/tui.js @@ -1,5 +1,5 @@ // installed by herdr // HERDR_INTEGRATION_ID=opencode-tui-v2 -// HERDR_INTEGRATION_VERSION=12 +// HERDR_INTEGRATION_VERSION=13 // V2 resolves the directory's tui entrypoint; V1 uses the original file. export { default } from "../herdr-tui-session.js"; |
