From 60c040057e3949ae93ee88d495df09b4b848d22d Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 18 Jul 2026 23:54:03 +0300 Subject: fix: serialize opencode lifecycle reports refs #1519 --- .../assets/opencode/herdr-agent-state.js | 15 ++- .../assets/opencode/herdr-agent-state.test.ts | 142 +++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/integration/assets/opencode/herdr-agent-state.test.ts (limited to 'src/integration/assets/opencode') 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 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 { + 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 { + return typeof value === "object" && value !== null; +} -- cgit v1.2.3