diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-18 23:54:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-18 23:54:14 +0300 |
| commit | 60c040057e3949ae93ee88d495df09b4b848d22d (patch) | |
| tree | 6648ae1f49a43b38351acbf9e4ab3b28a1d1d6aa | |
| parent | 74db2f3a79e03d752802a9c6a2ee7b55b0ca088d (diff) | |
fix: serialize opencode lifecycle reports
refs #1519
| -rw-r--r-- | justfile | 1 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 15 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.test.ts | 142 | ||||
| -rw-r--r-- | src/integration/mod.rs | 2 |
4 files changed, 157 insertions, 3 deletions
@@ -50,6 +50,7 @@ website-build: # Test bundled agent integration assets integration-assets-test: bun test src/integration/assets/herdr-agent-state.test.ts + bun test src/integration/assets/opencode/herdr-agent-state.test.ts # Run plugin marketplace Worker tests plugin-marketplace-test: 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; +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 9cf3d24b..66c62d7a 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -140,7 +140,7 @@ const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ ]; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 8; +const OPENCODE_INTEGRATION_VERSION: u32 = 9; const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); const KILO_INTEGRATION_VERSION: u32 = 2; |
