diff options
Diffstat (limited to 'src/integration')
| -rw-r--r-- | src/integration/assets/herdr-agent-state.test.ts | 213 | ||||
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 9 | ||||
| -rw-r--r-- | src/integration/mod.rs | 2 | ||||
| -rw-r--r-- | src/integration/tests.rs | 72 |
4 files changed, 239 insertions, 57 deletions
diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index 69cb8894..077d1894 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -48,42 +48,13 @@ function importFresh(modulePath: string) { return import(`${modulePath}?test=${importCounter}`); } -for (const integration of integrations) { - test(`${integration.name} reload preserves working state when the agent is active`, async () => { - const recordingSocketPath = join( - tmpdir(), - `herdr-${integration.name.toLowerCase().replaceAll(" ", "-")}-${process.pid}.sock`, - ); - socketPath = recordingSocketPath; - await rm(recordingSocketPath, { force: true }); - - const requests: unknown[] = []; - const recordingServer = createServer((socket) => { - let input = ""; - socket.setEncoding("utf8"); - socket.on("data", (chunk) => { - input += chunk; - const newline = input.indexOf("\n"); - if (newline === -1) { - return; - } - requests.push(JSON.parse(input.slice(0, newline))); - socket.end("{}\n"); - }); - }); - server = recordingServer; - await new Promise<void>((resolve, reject) => { - recordingServer.once("error", reject); - recordingServer.listen(recordingSocketPath, resolve); - }); +type Handler = (event: unknown, context: unknown) => unknown; - process.env.HERDR_ENV = "1"; - process.env.HERDR_SOCKET_PATH = recordingSocketPath; - process.env.HERDR_PANE_ID = "test:p1"; - - type Handler = (event: unknown, context: unknown) => unknown; - const handlers = new Map<string, Handler>(); - const pi = { +function createExtensionHarness() { + const handlers = new Map<string, Handler>(); + return { + handlers, + pi: { on(event: string, handler: Handler) { handlers.set(event, handler); }, @@ -92,7 +63,50 @@ for (const integration of integrations) { return () => {}; }, }, - }; + }, + }; +} + +function configureIntegrationEnvironment(recordingSocketPath: string) { + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = recordingSocketPath; + process.env.HERDR_PANE_ID = "test:p1"; +} + +async function startRecordingServer(name: string): Promise<unknown[]> { + const recordingSocketPath = join(tmpdir(), `herdr-${name}-${process.pid}.sock`); + socketPath = recordingSocketPath; + await rm(recordingSocketPath, { force: true }); + + const requests: unknown[] = []; + const recordingServer = createServer((socket) => { + let input = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + input += chunk; + const newline = input.indexOf("\n"); + if (newline === -1) { + return; + } + requests.push(JSON.parse(input.slice(0, newline))); + socket.end("{}\n"); + }); + }); + server = recordingServer; + await new Promise<void>((resolve, reject) => { + recordingServer.once("error", reject); + recordingServer.listen(recordingSocketPath, resolve); + }); + configureIntegrationEnvironment(recordingSocketPath); + return requests; +} + +for (const integration of integrations) { + test(`${integration.name} reload preserves working state when the agent is active`, async () => { + const requests = await startRecordingServer( + integration.name.toLowerCase().replaceAll(" ", "-"), + ); + const { handlers, pi } = createExtensionHarness(); const { default: install } = await importFresh(integration.modulePath); install(pi); @@ -133,6 +147,115 @@ for (const integration of integrations) { }); } +test("Pi reports the session replacement source", async () => { + const requests = await startRecordingServer("pi-session-source"); + const { handlers, pi } = createExtensionHarness(); + + const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + install(pi); + + const sessionStart = handlers.get("session_start"); + expect(sessionStart).toBeDefined(); + await sessionStart?.( + { reason: "new" }, + { + hasUI: true, + isIdle: () => true, + sessionManager: { + getSessionFile: () => "/tmp/pi-new.jsonl", + getSessionId: () => "pi-new", + }, + }, + ); + + const reportedSession = () => + requests.find((request) => isRecord(request) && request.method === "pane.report_agent_session"); + const deadline = Date.now() + 1_000; + while (Date.now() < deadline && reportedSession() === undefined) { + await Bun.sleep(5); + } + + const request = reportedSession(); + expect(request).toBeDefined(); + expect(isRecord(request) && isRecord(request.params) ? request.params.session_start_source : null) + .toBe("new"); +}); + +test("Pi waits for a replacement session report before publishing state", async () => { + const recordingSocketPath = join(tmpdir(), `herdr-pi-session-order-${process.pid}.sock`); + socketPath = recordingSocketPath; + await rm(recordingSocketPath, { force: true }); + + const requests: unknown[] = []; + let acknowledgeSessionReport: (() => void) | undefined; + const recordingServer = createServer((socket) => { + let input = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + input += chunk; + const newline = input.indexOf("\n"); + if (newline === -1) { + return; + } + const request = JSON.parse(input.slice(0, newline)); + requests.push(request); + if (isRecord(request) && request.method === "pane.report_agent_session") { + acknowledgeSessionReport = () => socket.end("{}\n"); + return; + } + socket.end("{}\n"); + }); + }); + server = recordingServer; + await new Promise<void>((resolve, reject) => { + recordingServer.once("error", reject); + recordingServer.listen(recordingSocketPath, resolve); + }); + + configureIntegrationEnvironment(recordingSocketPath); + const { handlers, pi } = createExtensionHarness(); + const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + install(pi); + + const sessionStart = handlers.get("session_start"); + expect(sessionStart).toBeDefined(); + const sessionStartResult = sessionStart?.( + { reason: "new" }, + { + hasUI: true, + isIdle: () => false, + sessionManager: { + getSessionFile: () => "/tmp/pi-new.jsonl", + getSessionId: () => "pi-new", + }, + }, + ); + + const deadline = Date.now() + 1_000; + while (Date.now() < deadline && acknowledgeSessionReport === undefined) { + await Bun.sleep(5); + } + expect(acknowledgeSessionReport).toBeDefined(); + expect( + requests.some((request) => isRecord(request) && request.method === "pane.report_agent"), + ).toBe(false); + + acknowledgeSessionReport?.(); + await sessionStartResult; + + const stateDeadline = Date.now() + 1_000; + while ( + Date.now() < stateDeadline && + !requests.some((request) => isRecord(request) && request.method === "pane.report_agent") + ) { + await Bun.sleep(5); + } + expect(requests.map((request) => (isRecord(request) ? request.method : undefined))).toEqual([ + "pane.report_agent_session", + "pane.report_agent", + ]); +}); + test("Pi retries working state after an unanswered socket attempt", async () => { const recordingSocketPath = join(tmpdir(), `herdr-pi-retry-${process.pid}.sock`); socketPath = recordingSocketPath; @@ -167,22 +290,8 @@ test("Pi retries working state after an unanswered socket attempt", async () => recordingServer.listen(recordingSocketPath, resolve); }); - process.env.HERDR_ENV = "1"; - process.env.HERDR_SOCKET_PATH = recordingSocketPath; - process.env.HERDR_PANE_ID = "test:p1"; - - type Handler = (event: unknown, context: unknown) => unknown; - const handlers = new Map<string, Handler>(); - const pi = { - on(event: string, handler: Handler) { - handlers.set(event, handler); - }, - events: { - on() { - return () => {}; - }, - }, - }; + configureIntegrationEnvironment(recordingSocketPath); + const { handlers, pi } = createExtensionHarness(); const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); install(pi); diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index 9048911f..52340ebb 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -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=pi -// HERDR_INTEGRATION_VERSION=4 +// HERDR_INTEGRATION_VERSION=5 // @ts-nocheck import { createConnection } from "node:net"; @@ -121,7 +121,7 @@ function currentSessionRef(): Record<string, unknown> | undefined { return undefined; } -function reportSession(): Promise<void> { +function reportSession(sessionStartSource?: string): Promise<void> { const sessionRef = currentSessionRef(); if (!sessionRef) { return Promise.resolve(); @@ -135,6 +135,7 @@ function reportSession(): Promise<void> { source, agent: "pi", seq: nextReportSeq(), + session_start_source: sessionStartSource, ...sessionRef, }, }); @@ -336,13 +337,13 @@ export default function (pi) { publishState(); }); - pi.on("session_start", (_event, ctx) => { + pi.on("session_start", async (event, ctx) => { if (ctx?.hasUI !== true) { return; } rootSession = true; updateSessionRef(ctx); - void reportSession(); + await reportSession(event?.reason); // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; publishState(true); diff --git a/src/integration/mod.rs b/src/integration/mod.rs index d356175c..39414cbf 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -22,7 +22,7 @@ pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, Integration const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts"; const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts"); -const PI_INTEGRATION_VERSION: u32 = 4; +const PI_INTEGRATION_VERSION: u32 = 5; const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts"; const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts"); const OMP_INTEGRATION_VERSION: u32 = 4; diff --git a/src/integration/tests.rs b/src/integration/tests.rs index b09e3c23..5d8538c7 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -733,6 +733,36 @@ fn outdated_integrations_treat_missing_version_marker_as_legacy() { } #[test] +fn outdated_integrations_detect_previous_pi_version() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write( + &extension_path, + "// HERDR_INTEGRATION_ID=pi\n// HERDR_INTEGRATION_VERSION=4\n", + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let outdated = outdated_installed_integrations(); + + assert_eq!(outdated.len(), 1); + assert_eq!( + outdated[0].target, + crate::api::schema::IntegrationTarget::Pi + ); + assert_eq!(outdated[0].path, extension_path); + assert_eq!(outdated[0].installed_version, Some(4)); + assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] fn outdated_integrations_accept_current_version_marker() { let _lock = integration_env_lock(); let base = unique_base(); @@ -2483,6 +2513,48 @@ fn install_hermes_errors_when_config_dir_missing() { } #[test] +fn bundled_integration_asset_versions_match_expected_versions() { + for (name, asset, expected_version) in [ + ("pi", PI_EXTENSION_ASSET, PI_INTEGRATION_VERSION), + ("omp", OMP_EXTENSION_ASSET, OMP_INTEGRATION_VERSION), + ("claude", CLAUDE_HOOK_ASSET, CLAUDE_INTEGRATION_VERSION), + ("codex", CODEX_HOOK_ASSET, CODEX_INTEGRATION_VERSION), + ("kimi", KIMI_HOOK_ASSET, KIMI_INTEGRATION_VERSION), + ("copilot", COPILOT_HOOK_ASSET, COPILOT_INTEGRATION_VERSION), + ("devin", DEVIN_HOOK_ASSET, DEVIN_INTEGRATION_VERSION), + ("droid", DROID_HOOK_ASSET, DROID_INTEGRATION_VERSION), + ( + "opencode", + OPENCODE_PLUGIN_ASSET, + OPENCODE_INTEGRATION_VERSION, + ), + ("kilo", KILO_PLUGIN_ASSET, KILO_INTEGRATION_VERSION), + ( + "hermes", + HERMES_PLUGIN_INIT_ASSET, + HERMES_INTEGRATION_VERSION, + ), + ( + "qodercli", + QODERCLI_HOOK_ASSET, + QODERCLI_INTEGRATION_VERSION, + ), + ("cursor", CURSOR_HOOK_ASSET, CURSOR_INTEGRATION_VERSION), + ( + "mastracode", + MASTRACODE_HOOK_ASSET, + MASTRACODE_INTEGRATION_VERSION, + ), + ] { + assert_eq!( + parse_integration_version(asset), + Some(expected_version), + "{name} asset version must match its integration version constant" + ); + } +} + +#[test] fn bundled_integration_assets_report_session_refs() { assert!(PI_EXTENSION_ASSET.contains("agent_session_path")); assert!(PI_EXTENSION_ASSET.contains("agent_session_id")); |
