diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 55e129b0..492899ad 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -26,6 +26,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- The Pi integration now preserves short blocked transitions while an earlier lifecycle report is still in flight. (#2971) - High-rate output from many hidden panes no longer floods the server loop with redundant wakeups, and terminal input-mode synchronization no longer formats pane scrollback to read one keyboard flag. - Chinese IME commits now reach panes on macOS when the focused application requests printable key-release events. (#2924) - Windows now recognizes `Ctrl+1` through `Ctrl+9` keybindings instead of decoding those key records as control characters. (#2910) diff --git a/src/integration/assets/herdr-agent-state.test.ts b/src/integration/assets/herdr-agent-state.test.ts index 94081e6d..9f053430 100644 --- a/src/integration/assets/herdr-agent-state.test.ts +++ b/src/integration/assets/herdr-agent-state.test.ts @@ -315,6 +315,52 @@ test("Pi settlement preserves explicit blocked-state precedence", async () => { expect(requestStates(requests)).toEqual(["idle", "working", "blocked", "idle"]); }); +test("Pi preserves a blocked transition while a state report is in flight", async () => { + const recordingSocketPath = join(tmpdir(), `herdr-pi-blocked-order-${process.pid}.sock`); + socketPath = recordingSocketPath; + await rm(recordingSocketPath, { force: true }); + + const requests: unknown[] = []; + let acknowledgeFirstReport: (() => 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; + } + requests.push(JSON.parse(input.slice(0, newline))); + if (requestStates(requests).length === 1) { + acknowledgeFirstReport = () => socket.end("{}\n"); + return; + } + socket.end("{}\n"); + }); + }); + server = recordingServer; + await new Promise((resolve, reject) => { + recordingServer.once("error", reject); + recordingServer.listen(recordingSocketPath, resolve); + }); + + configureIntegrationEnvironment(recordingSocketPath); + const { eventHandlers, handlers, pi } = createExtensionHarness(); + const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + install(pi); + + await handlers.get("session_start")?.({ reason: "startup" }, piContext(() => false)); + await waitFor(() => acknowledgeFirstReport !== undefined); + + eventHandlers.get("herdr:blocked")?.({ active: true, label: "approval" }, {}); + eventHandlers.get("herdr:blocked")?.({ active: false }, {}); + acknowledgeFirstReport?.(); + + await waitFor(() => requestStates(requests).length === 3); + expect(requestStates(requests)).toEqual(["working", "blocked", "working"]); +}); + test("Pi reports the session replacement source", async () => { const requests = await startRecordingServer("pi-session-source"); const { handlers, pi } = createExtensionHarness(); diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index 67bc4188..7c3b6c65 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=8 +// HERDR_INTEGRATION_VERSION=9 // @ts-nocheck import net from "node:net"; @@ -143,10 +143,10 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()): } let sendInFlight = false; -let queuedState: QueuedState | undefined; +const queuedStates: QueuedState[] = []; function queueState(state: AgentState, message?: string): void { - queuedState = { state, message, seq: nextReportSeq() }; + queuedStates.push({ state, message, seq: nextReportSeq() }); if (!sendInFlight) { void drainStateQueue(); } @@ -159,14 +159,16 @@ async function drainStateQueue(): Promise { sendInFlight = true; try { - while (queuedState) { - const next = queuedState; - queuedState = undefined; + while (true) { + const next = queuedStates.shift(); + if (!next) { + break; + } await sendState(next.state, next.message, next.seq); } } finally { sendInFlight = false; - if (queuedState) { + if (queuedStates.length > 0) { void drainStateQueue(); } } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index c87ccac6..e8173760 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -24,7 +24,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 = 8; +const PI_INTEGRATION_VERSION: u32 = 9; 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 = 8; diff --git a/tests/cli/sessions.rs b/tests/cli/sessions.rs index d86de44a..bce4aa73 100644 --- a/tests/cli/sessions.rs +++ b/tests/cli/sessions.rs @@ -293,7 +293,7 @@ fn integration_commands_run_locally_when_server_is_missing() { .unwrap(); assert_eq!(integration_status.status.code(), Some(0)); let status_stdout = String::from_utf8_lossy(&integration_status.stdout); - assert!(status_stdout.contains("pi: current (v8)")); + assert!(status_stdout.contains("pi: current (v9)")); assert!(status_stdout.contains("claude: not installed")); let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))