aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/opencode
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-22 16:25:17 +0300
committerGitHub <noreply@github.com>2026-09-22 16:25:17 +0300
commitf6390752a57a5ac3256f3213af870c0b62e224d7 (patch)
tree5b622345c5b274d82a9ff8a80a2be8adf7f67968 /src/integration/assets/opencode
parent46a5091838de66c1be193990afc3ad9145e6b4f5 (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.js22
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.test.ts27
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.js376
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts318
-rw-r--r--src/integration/assets/opencode/tui.js2
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";