From ec05328a4413e38ebdcb84365938345e9cf47431 Mon Sep 17 00:00:00 2001 From: Mark Jaquith Date: Sat, 5 Sep 2026 17:08:19 -0400 Subject: fix: prevent OpenCode child prompts from leaving status blocked (#3669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Child permission and question reports omitted the session ID, clearing Herdr’s root session identity and preventing later lifecycle reports from clearing the blocked state. Track child-to-parent relationships and report prompt transitions with their root session ID, including for nested children. This preserves identity and lets existing ownership checks reject unrelated sessions. Add regression coverage for prompt routing and lifecycle transitions, and bump the OpenCode integration version to 11. --- .../assets/opencode/herdr-agent-state.js | 14 ++++-- .../assets/opencode/herdr-agent-state.test.ts | 39 +++++++++++++--- .../assets/opencode/herdr-tui-session.js | 2 +- src/integration/mod.rs | 2 +- src/terminal/state.rs | 54 ++++++++++++++++++++++ 5 files changed, 98 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 2840c221..45032335 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -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=opencode -// HERDR_INTEGRATION_VERSION=10 +// HERDR_INTEGRATION_VERSION=11 import net from "node:net"; @@ -13,8 +13,8 @@ let requestChain = Promise.resolve(); let reportedRootSessionID; // Track child sessions so their events cannot replace the pane's root session. -// Their user prompts still project state without attaching the child session id. -const childSessions = new Set(); +// User prompts carry the root id to preserve its identity and cross-talk guard. +const childSessions = new Map(); const CHILD_EVENT_STATES = new Map([ ["permission.asked", "blocked"], ["question.asked", "blocked"], @@ -141,12 +141,16 @@ export const HerdrAgentStatePlugin = async () => { const info = properties.info; if (info?.id && info.parentID) { - childSessions.add(info.id); + childSessions.set(info.id, info.parentID); } if (sessionID && childSessions.has(sessionID)) { const state = CHILD_EVENT_STATES.get(type); if (state) { - await reportState(state); + let rootSessionID = sessionID; + while (childSessions.has(rootSessionID)) { + rootSessionID = childSessions.get(rootSessionID); + } + await reportState(state, rootSessionID); } return; } diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts index 6f8ee590..a3eea848 100644 --- a/src/integration/assets/opencode/herdr-agent-state.test.ts +++ b/src/integration/assets/opencode/herdr-agent-state.test.ts @@ -167,7 +167,6 @@ test("reports child prompts without replacing the root session", async () => { event: { type: "session.created", properties: { - sessionID: "child-session", info: { id: "child-session", parentID: "root-session" }, }, }, @@ -188,11 +187,39 @@ test("reports child prompts without replacing the root session", async () => { "working", ]); expect(requests.map(requestSessionID)).toEqual([ - undefined, - undefined, - undefined, - undefined, - undefined, + "root-session", + "root-session", + "root-session", + "root-session", + "root-session", + ]); +}); + +test("routes nested child prompts to their own root, not the last active root", async () => { + const plugin = await loadPlugin(); + for (const info of [ + { id: "child-session", parentID: "root-session" }, + { id: "nested-session", parentID: "child-session" }, + ]) { + await plugin.event({ event: { type: "session.created", properties: { info } } }); + } + await plugin["chat.message"]({ sessionID: "other-root" }); + await plugin.event({ + event: { type: "permission.asked", properties: { sessionID: "nested-session" } }, + }); + await plugin.event({ + event: { type: "permission.replied", properties: { sessionID: "nested-session" } }, + }); + await plugin.event({ + event: { type: "session.idle", properties: { sessionID: "nested-session" } }, + }); + await plugin["chat.message"]({ sessionID: "nested-session" }); + + expect(requests.map(requestState)).toEqual(["working", "blocked", "working"]); + expect(requests.map(requestSessionID)).toEqual([ + "other-root", + "root-session", + "root-session", ]); }); diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js index b5be5bbc..f4f08d57 100644 --- a/src/integration/assets/opencode/herdr-tui-session.js +++ b/src/integration/assets/opencode/herdr-tui-session.js @@ -1,7 +1,7 @@ // installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // HERDR_INTEGRATION_ID=opencode-tui -// HERDR_INTEGRATION_VERSION=10 +// HERDR_INTEGRATION_VERSION=11 import net from "node:net"; diff --git a/src/integration/mod.rs b/src/integration/mod.rs index fd0839f4..3f7176b3 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -170,7 +170,7 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st const OPENCODE_TUI_PLUGIN_INSTALL_NAME: &str = "herdr-tui-session.js"; const OPENCODE_TUI_PLUGIN_SPEC: &str = "./herdr-tui-session.js"; const OPENCODE_TUI_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-tui-session.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 10; +const OPENCODE_INTEGRATION_VERSION: u32 = 11; 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 = 4; diff --git a/src/terminal/state.rs b/src/terminal/state.rs index ee170198..dc28af10 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -4785,6 +4785,60 @@ mod tests { .contains_key("herdr:opencode")); } + #[test] + fn opencode_child_prompt_reports_with_root_id_preserve_lifecycle_authority() { + let mut terminal = test_terminal(); + let root = crate::agent_resume::AgentSessionRef::id("opencode-root").unwrap(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + root.clone(), + ); + + // The plugin projects child permission/question events onto their root. + for (seq, state) in [ + (20, AgentState::Working), + (21, AgentState::Blocked), + (22, AgentState::Working), + (23, AgentState::Idle), + ] { + let mutation = terminal + .set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + state, + None, + Some(root.clone()), + Some(seq), + ) + .expect("root-scoped lifecycle report should be accepted"); + assert!(!mutation.session_ref_changed); + assert_eq!(terminal.state, state); + assert_eq!( + terminal + .hook_authority + .as_ref() + .unwrap() + .session_ref + .as_ref(), + Some(&root) + ); + } + + let foreign_child_prompt = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + None, + crate::agent_resume::AgentSessionRef::id("opencode-other-root"), + Some(24), + ); + assert!(foreign_child_prompt.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + } + #[test] fn opencode_tui_selection_reanchors_full_lifecycle_authority() { let mut terminal = test_terminal(); -- cgit v1.2.3