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.js15
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.test.ts142
2 files changed, 155 insertions, 2 deletions
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 9a14b3c8..4d59f9ec 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -2,13 +2,15 @@
// 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=8
+// HERDR_INTEGRATION_VERSION=9
import net from "node:net";
const SOURCE = "herdr:opencode";
const AGENT = "opencode";
let reportSeq = Date.now() * 1000;
+let requestChain = Promise.resolve();
+let reportedRootSessionID;
// Subagent (task tool) sessions carry a parentID; the main agent session does
// not. Their lifecycle events would otherwise clobber the pane's real state, so
@@ -47,6 +49,12 @@ function stateFromSessionStatus(status) {
}
function request(method, params) {
+ const pending = requestChain.then(() => requestOnce(method, params));
+ requestChain = pending.catch(() => {});
+ return pending;
+}
+
+function requestOnce(method, params) {
const paneId = process.env.HERDR_PANE_ID;
const socketPath = process.env.HERDR_SOCKET_PATH;
@@ -101,6 +109,7 @@ function reportSession(sessionID, sessionStartSource) {
function reportState(state, sessionID) {
const params = { state };
if (sessionID) {
+ reportedRootSessionID = sessionID;
params.agent_session_id = sessionID;
}
return request("pane.report_agent", params);
@@ -160,7 +169,9 @@ export const HerdrAgentStatePlugin = async () => {
await reportSession(sessionID, "new");
break;
case "session.updated":
- await reportSession(sessionID);
+ if (sessionID && sessionID !== reportedRootSessionID) {
+ await reportSession(sessionID);
+ }
break;
case "session.status": {
const state = stateFromSessionStatus(properties.status);
diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts
new file mode 100644
index 00000000..d8d6d14d
--- /dev/null
+++ b/src/integration/assets/opencode/herdr-agent-state.test.ts
@@ -0,0 +1,142 @@
+import { beforeEach, expect, mock, test } from "bun:test";
+
+const requests: unknown[] = [];
+const clients: FakeClient[] = [];
+const requestWaiters: Array<() => void> = [];
+let autoAcknowledge = true;
+let importCounter = 0;
+
+type FakeClient = {
+ emit: (event: string) => void;
+};
+
+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()?.();
+ if (autoAcknowledge) {
+ queueMicrotask(() => client.emit("data"));
+ }
+ },
+ setTimeout() {},
+ on(event: string, handler: () => void) {
+ handlers.set(event, handler);
+ },
+ destroy() {},
+ emit(event: string) {
+ handlers.get(event)?.();
+ },
+ };
+ clients.push(client);
+ queueMicrotask(onConnect);
+ return client;
+ },
+ },
+}));
+
+beforeEach(() => {
+ requests.length = 0;
+ clients.length = 0;
+ requestWaiters.length = 0;
+ autoAcknowledge = true;
+ process.env.HERDR_ENV = "1";
+ process.env.HERDR_SOCKET_PATH = "test.sock";
+ process.env.HERDR_PANE_ID = "test:p1";
+});
+
+async function loadPlugin() {
+ importCounter += 1;
+ const { HerdrAgentStatePlugin } = await import(`./herdr-agent-state.js?test=${importCounter}`);
+ return HerdrAgentStatePlugin();
+}
+
+function waitForNextRequest(): Promise<void> {
+ return new Promise((resolve) => requestWaiters.push(resolve));
+}
+
+test("serializes lifecycle reports", async () => {
+ autoAcknowledge = false;
+ const plugin = await loadPlugin();
+ const firstDispatched = waitForNextRequest();
+ const working = plugin.event({
+ event: {
+ type: "session.status",
+ properties: { sessionID: "root-session", status: { type: "busy" } },
+ },
+ });
+ await firstDispatched;
+
+ const secondDispatched = waitForNextRequest();
+ const idle = plugin.event({
+ event: {
+ type: "session.status",
+ properties: { sessionID: "root-session", status: { type: "idle" } },
+ },
+ });
+ expect(clients).toHaveLength(1);
+
+ clients[0]?.emit("data");
+ await secondDispatched;
+ expect(clients).toHaveLength(2);
+ clients[1]?.emit("data");
+ await Promise.all([working, idle]);
+
+ expect(requests.map(requestState)).toEqual(["working", "idle"]);
+ const sequences = requests.map(requestSeq);
+ expect(sequences[0]).toEqual(expect.any(Number));
+ expect(sequences[1]).toBe((sequences[0] as number) + 1);
+});
+
+test("suppresses redundant same-session updates", async () => {
+ const plugin = await loadPlugin();
+
+ await plugin.event({
+ event: {
+ type: "session.status",
+ properties: { sessionID: "root-session", status: { type: "busy" } },
+ },
+ });
+ await plugin.event({
+ event: { type: "session.updated", properties: { sessionID: "root-session" } },
+ });
+ await plugin.event({
+ event: { type: "session.updated", properties: { sessionID: "replacement-session" } },
+ });
+
+ expect(requests.map(requestMethod)).toEqual([
+ "pane.report_agent",
+ "pane.report_agent_session",
+ ]);
+ expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]);
+});
+
+function requestMethod(request: unknown): unknown {
+ return isRecord(request) ? request.method : undefined;
+}
+
+function requestState(request: unknown): unknown {
+ return requestParam(request, "state");
+}
+
+function requestSeq(request: unknown): unknown {
+ return requestParam(request, "seq");
+}
+
+function requestSessionID(request: unknown): unknown {
+ return requestParam(request, "agent_session_id");
+}
+
+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;
+}