diff options
Diffstat (limited to 'src/integration/assets/opencode')
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; +} |
