diff options
| author | Mark Jaquith <mark@jaquith.me> | 2026-09-05 17:08:19 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 00:08:19 +0300 |
| commit | ec05328a4413e38ebdcb84365938345e9cf47431 (patch) | |
| tree | 3f280531f2c81d00d2204ca7e7fb0d3b161072a6 /src | |
| parent | 6f57f2f62c5d2591647262caf15bc3c962b9c9f8 (diff) | |
fix: prevent OpenCode child prompts from leaving status blocked (#3669)
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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.js | 14 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-agent-state.test.ts | 39 | ||||
| -rw-r--r-- | src/integration/assets/opencode/herdr-tui-session.js | 2 | ||||
| -rw-r--r-- | src/integration/mod.rs | 2 | ||||
| -rw-r--r-- | src/terminal/state.rs | 54 |
5 files changed, 98 insertions, 13 deletions
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 @@ -4786,6 +4786,60 @@ mod tests { } #[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(); let old_session = crate::agent_resume::AgentSessionRef::id("opencode-newer").unwrap(); |
