aboutsummaryrefslogtreecommitdiff
path: root/src/integration/assets/opencode
diff options
context:
space:
mode:
Diffstat (limited to 'src/integration/assets/opencode')
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js17
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.test.ts31
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.js113
-rw-r--r--src/integration/assets/opencode/herdr-tui-session.test.ts181
4 files changed, 331 insertions, 11 deletions
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 76265050..2840c221 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=9
+// HERDR_INTEGRATION_VERSION=10
import net from "node:net";
@@ -102,15 +102,11 @@ function requestOnce(method, params) {
});
}
-function reportSession(sessionID, sessionStartSource) {
+function reportSession(sessionID) {
if (!sessionID) {
return Promise.resolve();
}
- const params = { agent_session_id: sessionID };
- if (sessionStartSource) {
- params.session_start_source = sessionStartSource;
- }
- return request("pane.report_agent_session", params);
+ return request("pane.report_agent_session", { agent_session_id: sessionID });
}
function reportState(state, sessionID) {
@@ -157,10 +153,9 @@ export const HerdrAgentStatePlugin = async () => {
switch (type) {
case "session.created":
- // A root session.created is a genuine new-session start (subagent
- // creates are dropped above). Signal it so herdr replaces the pane's
- // prior session id instead of treating the change as cross-talk.
- await reportSession(sessionID, "new");
+ // Creation is server-global, so an attached client may own it. The
+ // TUI plugin separately reports the root selected in this pane.
+ reportedRootSessionID = sessionID;
break;
case "session.updated":
if (sessionID && sessionID !== reportedRootSessionID) {
diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts
index be21fd71..6f8ee590 100644
--- a/src/integration/assets/opencode/herdr-agent-state.test.ts
+++ b/src/integration/assets/opencode/herdr-agent-state.test.ts
@@ -114,6 +114,37 @@ test("suppresses redundant same-session updates", async () => {
expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]);
});
+test("does not classify server activity in another root session as a selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin["chat.message"]({ sessionID: "visible-session" });
+ await plugin["chat.message"]({ sessionID: "attached-client-session" });
+
+ expect(requests.map(requestMethod)).toEqual([
+ "pane.report_agent",
+ "pane.report_agent",
+ ]);
+ expect(requests.map(requestSessionID)).toEqual([
+ "visible-session",
+ "attached-client-session",
+ ]);
+});
+
+test("does not classify server-global root creation as a local selection", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin.event({
+ event: { type: "session.created", properties: { sessionID: "attached-session" } },
+ });
+ await plugin.event({
+ event: { type: "session.updated", properties: { sessionID: "attached-session" } },
+ });
+ await plugin["chat.message"]({ sessionID: "attached-session" });
+
+ expect(requests.map(requestMethod)).toEqual(["pane.report_agent"]);
+ expect(requests.map(requestSessionID)).toEqual(["attached-session"]);
+});
+
test("reports retry status as working", async () => {
const plugin = await loadPlugin();
diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js
new file mode 100644
index 00000000..b5be5bbc
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.js
@@ -0,0 +1,113 @@
+// installed by herdr
+// managed by herdr; reinstalling or updating the integration overwrites this file.
+// HERDR_INTEGRATION_ID=opencode-tui
+// HERDR_INTEGRATION_VERSION=10
+
+import net from "node:net";
+
+const SOURCE = "herdr:opencode";
+const AGENT = "opencode";
+const ROUTE_POLL_INTERVAL_MS = 100;
+const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000];
+
+function requestOnce(sessionID) {
+ const paneId = process.env.HERDR_PANE_ID;
+ const socketPath = process.env.HERDR_SOCKET_PATH;
+ if (!paneId || !socketPath) {
+ return Promise.resolve();
+ }
+
+ const socketEndpoint =
+ process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
+ const request = {
+ id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
+ .toString()
+ .padStart(6, "0")}`,
+ method: "pane.report_agent_session",
+ params: {
+ pane_id: paneId,
+ source: SOURCE,
+ agent: AGENT,
+ agent_session_id: sessionID,
+ session_start_source: "select",
+ },
+ };
+
+ return new Promise((resolve) => {
+ const client = net.createConnection(socketEndpoint, () => {
+ client.write(`${JSON.stringify(request)}\n`);
+ });
+ const finish = () => {
+ client.destroy();
+ resolve();
+ };
+
+ client.setTimeout(500, finish);
+ client.on("data", finish);
+ client.on("error", finish);
+ client.on("end", finish);
+ client.on("close", resolve);
+ });
+}
+
+export default {
+ id: "herdr.opencode.session-selection",
+ tui: async (api) => {
+ if (
+ process.env.HERDR_ENV !== "1" ||
+ !process.env.HERDR_SOCKET_PATH ||
+ !process.env.HERDR_PANE_ID
+ ) {
+ return;
+ }
+
+ 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;
+ }
+ if (sessionID !== selectedSessionID) {
+ selectedSessionID = sessionID;
+ retryIndex = 0;
+ nextReportAt = 0;
+ }
+ if (reportPending || Date.now() < nextReportAt) {
+ return;
+ }
+
+ const reportingSessionID = sessionID;
+ reportPending = true;
+ try {
+ await requestOnce(reportingSessionID);
+ } catch {
+ // Best-effort reporting retries below while the selected route remains active.
+ } finally {
+ reportPending = false;
+ }
+ if (selectedSessionID !== reportingSessionID) {
+ retryIndex = 0;
+ nextReportAt = 0;
+ return;
+ }
+ const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex];
+ retryIndex += 1;
+ nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay;
+ };
+
+ await syncSelectedSession();
+ const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS);
+ api.lifecycle.onDispose(() => clearInterval(routePoll));
+ },
+};
diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts
new file mode 100644
index 00000000..1bcb1f81
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-tui-session.test.ts
@@ -0,0 +1,181 @@
+import { afterEach, beforeEach, expect, mock, test } from "bun:test";
+
+const requests: unknown[] = [];
+const activeDisposers: Array<() => void> = [];
+const requestWaiters: Array<() => void> = [];
+let importCounter = 0;
+
+mock.module("node:net", () => ({
+ default: {
+ createConnection(_path: string, onConnect: () => void) {
+ const handlers = new Map<string, () => void>();
+ const client = {
+ write(input: string) {
+ requests.push(JSON.parse(input.trim()));
+ requestWaiters.shift()?.();
+ queueMicrotask(() => client.emit("data"));
+ },
+ setTimeout() {},
+ on(event: string, handler: () => void) {
+ handlers.set(event, handler);
+ },
+ destroy() {},
+ emit(event: string) {
+ handlers.get(event)?.();
+ },
+ };
+ queueMicrotask(onConnect);
+ return client;
+ },
+ },
+}));
+
+beforeEach(() => {
+ requests.length = 0;
+ requestWaiters.length = 0;
+ process.env.HERDR_ENV = "1";
+ process.env.HERDR_SOCKET_PATH = "test.sock";
+ process.env.HERDR_PANE_ID = "test:p1";
+});
+
+afterEach(() => {
+ for (const dispose of activeDisposers.splice(0)) {
+ dispose();
+ }
+});
+
+async function loadPlugin() {
+ importCounter += 1;
+ const module = await import(`./herdr-tui-session.js?test=${importCounter}`);
+ return module.default;
+}
+
+function fakeApi() {
+ const sessions = new Map<string, { id: string; parentID?: string }>();
+ let current: { name: string; params?: { sessionID: string } } = { name: "home" };
+ let dispose: (() => void) | undefined;
+ activeDisposers.push(() => dispose?.());
+
+ return {
+ api: {
+ route: {
+ get current() {
+ return current;
+ },
+ },
+ state: {
+ session: {
+ get(sessionID: string) {
+ return sessions.get(sessionID);
+ },
+ },
+ },
+ lifecycle: {
+ onDispose(handler: () => void) {
+ dispose = handler;
+ return () => {};
+ },
+ },
+ },
+ addSession(session: { id: string; parentID?: string }) {
+ sessions.set(session.id, session);
+ },
+ select(sessionID: string) {
+ current = { name: "session", params: { sessionID } };
+ },
+ dispose() {
+ dispose?.();
+ },
+ };
+}
+
+function waitForNextRequest(): Promise<void> {
+ return new Promise((resolve) => requestWaiters.push(resolve));
+}
+
+test("reports a root session when only the local route changes", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+
+ const dispatched = waitForNextRequest();
+ tui.select("session-a");
+ await dispatched;
+
+ expect(requests).toHaveLength(1);
+ expect(requestParam(requests[0], "agent_session_id")).toBe("session-a");
+ expect(requestParam(requests[0], "session_start_source")).toBe("select");
+ expect(requestParam(requests[0], "seq")).toBeUndefined();
+});
+
+test("retries an initial selection while Herdr detects the process", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.select("session-a");
+
+ 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",
+ ]);
+});
+
+test("does not report root sessions not selected by this TUI", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ tui.addSession({ id: "session-b" });
+ tui.select("session-a");
+ await plugin.tui(tui.api);
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests.length).toBeGreaterThan(0);
+ expect(requests.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(
+ true,
+ );
+});
+
+test("does not replace the root session with a selected child session", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "root-session" });
+ tui.addSession({ id: "child-session", parentID: "root-session" });
+ tui.select("root-session");
+ await plugin.tui(tui.api);
+ expect(requests).toHaveLength(1);
+
+ 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");
+});
+
+test("stops route polling when the TUI plugin is disposed", async () => {
+ const plugin = await loadPlugin();
+ const tui = fakeApi();
+ tui.addSession({ id: "session-a" });
+ await plugin.tui(tui.api);
+ tui.dispose();
+ tui.select("session-a");
+
+ await new Promise((resolve) => setTimeout(resolve, 125));
+
+ expect(requests).toHaveLength(0);
+});
+
+function requestParam(request: unknown, name: string): unknown {
+ if (!isRecord(request) || !isRecord(request.params)) {
+ return undefined;
+ }
+ return request.params[name];
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}