aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/opencode/herdr-tui-session.test.ts
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/herdr-tui-session.test.ts
parent46a5091838de66c1be193990afc3ad9145e6b4f5 (diff)
fix: track opencode subagent status per pane (#4357)
refs #1362
Diffstat (limited to 'src/integration/assets/opencode/herdr-tui-session.test.ts')
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts318
1 files changed, 311 insertions, 7 deletions
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();