diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 0634ac78f..3e3a9e89b 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -18,6 +18,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 +- OpenCode panes now track the root conversation selected in their own TUI for native restore without adopting activity from attached clients. (#2450) - Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514) - `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452) - macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet) diff --git a/justfile b/justfile index f7a0e2e7b..242d40ba1 100644 --- a/justfile +++ b/justfile @@ -75,6 +75,7 @@ website-build: integration-assets-test: bun test src/integration/assets/herdr-agent-state.test.ts bun test src/integration/assets/opencode/herdr-agent-state.test.ts + bun test src/integration/assets/opencode/herdr-tui-session.test.ts # Run plugin marketplace Worker tests plugin-marketplace-test: diff --git a/src/agent_resume.rs b/src/agent_resume.rs index ffbf43474..e2fc47e9b 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -71,7 +71,7 @@ pub fn session_ref_from_report( pub fn normalize_session_start_source(value: Option) -> Option { match value.as_deref().map(str::trim) { - Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork")) => { + Some(source @ ("startup" | "resume" | "clear" | "compact" | "new" | "fork" | "select")) => { Some(source.to_string()) } _ => None, @@ -608,6 +608,10 @@ mod tests { normalize_session_start_source(Some("fork".into())), Some("fork".into()) ); + assert_eq!( + normalize_session_start_source(Some("select".into())), + Some("select".into()) + ); assert_eq!( normalize_session_start_source(Some(" resume ".into())), Some("resume".into()) diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 236cebe78..27620590a 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -47,6 +47,13 @@ fn integration_status(args: &[String]) -> std::io::Result { crate::integration::IntegrationStatusKind::Current => { format!("current ({version})") } + crate::integration::IntegrationStatusKind::Outdated + if status + .installed_version + .is_some_and(|installed| installed >= status.expected_version) => + { + format!("needs repair ({version})") + } crate::integration::IntegrationStatusKind::Outdated => { format!("outdated ({version} < v{})", status.expected_version) } diff --git a/src/integration/actions.rs b/src/integration/actions.rs index 3cb737744..00bf8c8f4 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -144,10 +144,20 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re } crate::api::schema::IntegrationTarget::Opencode => { let installed = install_opencode()?; - vec![format!( - "installed opencode integration plugin to {}", - installed.plugin_path.display() - )] + vec![ + format!( + "installed opencode integration plugin to {}", + installed.plugin_path.display() + ), + format!( + "installed opencode tui integration plugin to {}", + installed.tui_plugin_path.display() + ), + format!( + "ensured opencode tui plugin config at {}", + installed.tui_config_path.display() + ), + ] } crate::api::schema::IntegrationTarget::Kilo => { let installed = install_kilo()?; @@ -451,17 +461,35 @@ pub(crate) fn uninstall_target( } crate::api::schema::IntegrationTarget::Opencode => { let result = uninstall_opencode()?; - if result.removed_plugin { - vec![format!( + let mut messages = vec![if result.removed_plugin { + format!( "removed opencode integration plugin at {}", result.plugin_path.display() - )] + ) } else { - vec![format!( + format!( "no opencode integration plugin found at {}", result.plugin_path.display() - )] + ) + }]; + messages.push(if result.removed_tui_plugin { + format!( + "removed opencode tui integration plugin at {}", + result.tui_plugin_path.display() + ) + } else { + format!( + "no opencode tui integration plugin found at {}", + result.tui_plugin_path.display() + ) + }); + if result.updated_tui_config { + messages.push(format!( + "removed herdr opencode plugin entry from {}", + result.tui_config_path.display() + )); } + messages } crate::api::schema::IntegrationTarget::Kilo => { let result = uninstall_kilo()?; diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 762650504..2840c221a 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=9 +// HERDR_INTEGRATION_VERSION=10 import net from "node:net"; @@ -102,15 +102,11 @@ function requestOnce(method, params) { }); } -function reportSession(sessionID, sessionStartSource) { +function reportSession(sessionID) { if (!sessionID) { return Promise.resolve(); } - const params = { agent_session_id: sessionID }; - if (sessionStartSource) { - params.session_start_source = sessionStartSource; - } - return request("pane.report_agent_session", params); + return request("pane.report_agent_session", { agent_session_id: sessionID }); } function reportState(state, sessionID) { @@ -157,10 +153,9 @@ export const HerdrAgentStatePlugin = async () => { switch (type) { case "session.created": - // A root session.created is a genuine new-session start (subagent - // creates are dropped above). Signal it so herdr replaces the pane's - // prior session id instead of treating the change as cross-talk. - await reportSession(sessionID, "new"); + // Creation is server-global, so an attached client may own it. The + // TUI plugin separately reports the root selected in this pane. + reportedRootSessionID = sessionID; break; case "session.updated": if (sessionID && sessionID !== reportedRootSessionID) { diff --git a/src/integration/assets/opencode/herdr-agent-state.test.ts b/src/integration/assets/opencode/herdr-agent-state.test.ts index be21fd714..6f8ee5900 100644 --- a/src/integration/assets/opencode/herdr-agent-state.test.ts +++ b/src/integration/assets/opencode/herdr-agent-state.test.ts @@ -114,6 +114,37 @@ test("suppresses redundant same-session updates", async () => { expect(requests.map(requestSessionID)).toEqual(["root-session", "replacement-session"]); }); +test("does not classify server activity in another root session as a selection", async () => { + const plugin = await loadPlugin(); + + await plugin["chat.message"]({ sessionID: "visible-session" }); + await plugin["chat.message"]({ sessionID: "attached-client-session" }); + + expect(requests.map(requestMethod)).toEqual([ + "pane.report_agent", + "pane.report_agent", + ]); + expect(requests.map(requestSessionID)).toEqual([ + "visible-session", + "attached-client-session", + ]); +}); + +test("does not classify server-global root creation as a local selection", async () => { + const plugin = await loadPlugin(); + + await plugin.event({ + event: { type: "session.created", properties: { sessionID: "attached-session" } }, + }); + await plugin.event({ + event: { type: "session.updated", properties: { sessionID: "attached-session" } }, + }); + await plugin["chat.message"]({ sessionID: "attached-session" }); + + expect(requests.map(requestMethod)).toEqual(["pane.report_agent"]); + expect(requests.map(requestSessionID)).toEqual(["attached-session"]); +}); + test("reports retry status as working", async () => { const plugin = await loadPlugin(); diff --git a/src/integration/assets/opencode/herdr-tui-session.js b/src/integration/assets/opencode/herdr-tui-session.js new file mode 100644 index 000000000..b5be5bbc8 --- /dev/null +++ b/src/integration/assets/opencode/herdr-tui-session.js @@ -0,0 +1,113 @@ +// installed by herdr +// managed by herdr; reinstalling or updating the integration overwrites this file. +// HERDR_INTEGRATION_ID=opencode-tui +// HERDR_INTEGRATION_VERSION=10 + +import net from "node:net"; + +const SOURCE = "herdr:opencode"; +const AGENT = "opencode"; +const ROUTE_POLL_INTERVAL_MS = 100; +const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000]; + +function requestOnce(sessionID) { + const paneId = process.env.HERDR_PANE_ID; + const socketPath = process.env.HERDR_SOCKET_PATH; + if (!paneId || !socketPath) { + return Promise.resolve(); + } + + const socketEndpoint = + process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath; + const request = { + id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0")}`, + method: "pane.report_agent_session", + params: { + pane_id: paneId, + source: SOURCE, + agent: AGENT, + agent_session_id: sessionID, + session_start_source: "select", + }, + }; + + return new Promise((resolve) => { + const client = net.createConnection(socketEndpoint, () => { + client.write(`${JSON.stringify(request)}\n`); + }); + const finish = () => { + client.destroy(); + resolve(); + }; + + client.setTimeout(500, finish); + client.on("data", finish); + client.on("error", finish); + client.on("end", finish); + client.on("close", resolve); + }); +} + +export default { + id: "herdr.opencode.session-selection", + tui: async (api) => { + if ( + process.env.HERDR_ENV !== "1" || + !process.env.HERDR_SOCKET_PATH || + !process.env.HERDR_PANE_ID + ) { + return; + } + + let selectedSessionID; + let retryIndex = 0; + let nextReportAt = 0; + let reportPending = false; + const syncSelectedSession = async () => { + const route = api.route.current; + const sessionID = route?.name === "session" ? route.params?.sessionID : undefined; + const session = + typeof sessionID === "string" && sessionID + ? api.state.session.get(sessionID) + : undefined; + if (!session || session.parentID) { + selectedSessionID = undefined; + retryIndex = 0; + nextReportAt = 0; + return; + } + if (sessionID !== selectedSessionID) { + selectedSessionID = sessionID; + retryIndex = 0; + nextReportAt = 0; + } + if (reportPending || Date.now() < nextReportAt) { + return; + } + + const reportingSessionID = sessionID; + reportPending = true; + try { + await requestOnce(reportingSessionID); + } catch { + // Best-effort reporting retries below while the selected route remains active. + } finally { + reportPending = false; + } + if (selectedSessionID !== reportingSessionID) { + retryIndex = 0; + nextReportAt = 0; + return; + } + const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex]; + retryIndex += 1; + nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay; + }; + + await syncSelectedSession(); + const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS); + api.lifecycle.onDispose(() => clearInterval(routePoll)); + }, +}; diff --git a/src/integration/assets/opencode/herdr-tui-session.test.ts b/src/integration/assets/opencode/herdr-tui-session.test.ts new file mode 100644 index 000000000..1bcb1f81e --- /dev/null +++ b/src/integration/assets/opencode/herdr-tui-session.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + +const requests: unknown[] = []; +const activeDisposers: Array<() => void> = []; +const requestWaiters: Array<() => void> = []; +let importCounter = 0; + +mock.module("node:net", () => ({ + default: { + createConnection(_path: string, onConnect: () => void) { + const handlers = new Map void>(); + const client = { + write(input: string) { + requests.push(JSON.parse(input.trim())); + requestWaiters.shift()?.(); + queueMicrotask(() => client.emit("data")); + }, + setTimeout() {}, + on(event: string, handler: () => void) { + handlers.set(event, handler); + }, + destroy() {}, + emit(event: string) { + handlers.get(event)?.(); + }, + }; + queueMicrotask(onConnect); + return client; + }, + }, +})); + +beforeEach(() => { + requests.length = 0; + requestWaiters.length = 0; + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = "test.sock"; + process.env.HERDR_PANE_ID = "test:p1"; +}); + +afterEach(() => { + for (const dispose of activeDisposers.splice(0)) { + dispose(); + } +}); + +async function loadPlugin() { + importCounter += 1; + const module = await import(`./herdr-tui-session.js?test=${importCounter}`); + return module.default; +} + +function fakeApi() { + const sessions = new Map(); + let current: { name: string; params?: { sessionID: string } } = { name: "home" }; + let dispose: (() => void) | undefined; + activeDisposers.push(() => dispose?.()); + + return { + api: { + route: { + get current() { + return current; + }, + }, + state: { + session: { + get(sessionID: string) { + return sessions.get(sessionID); + }, + }, + }, + lifecycle: { + onDispose(handler: () => void) { + dispose = handler; + return () => {}; + }, + }, + }, + addSession(session: { id: string; parentID?: string }) { + sessions.set(session.id, session); + }, + select(sessionID: string) { + current = { name: "session", params: { sessionID } }; + }, + dispose() { + dispose?.(); + }, + }; +} + +function waitForNextRequest(): Promise { + return new Promise((resolve) => requestWaiters.push(resolve)); +} + +test("reports a root session when only the local route changes", async () => { + const plugin = await loadPlugin(); + const tui = fakeApi(); + tui.addSession({ id: "session-a" }); + await plugin.tui(tui.api); + + const dispatched = waitForNextRequest(); + tui.select("session-a"); + await dispatched; + + expect(requests).toHaveLength(1); + expect(requestParam(requests[0], "agent_session_id")).toBe("session-a"); + expect(requestParam(requests[0], "session_start_source")).toBe("select"); + expect(requestParam(requests[0], "seq")).toBeUndefined(); +}); + +test("retries an initial selection while Herdr detects the process", async () => { + const plugin = await loadPlugin(); + const tui = fakeApi(); + tui.addSession({ id: "session-a" }); + tui.select("session-a"); + + await plugin.tui(tui.api); + await new Promise((resolve) => setTimeout(resolve, 125)); + + expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([ + "session-a", + "session-a", + ]); +}); + +test("does not report root sessions not selected by this TUI", async () => { + const plugin = await loadPlugin(); + const tui = fakeApi(); + tui.addSession({ id: "session-a" }); + tui.addSession({ id: "session-b" }); + tui.select("session-a"); + await plugin.tui(tui.api); + + await new Promise((resolve) => setTimeout(resolve, 125)); + + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe( + true, + ); +}); + +test("does not replace the root session with a selected child session", async () => { + const plugin = await loadPlugin(); + const tui = fakeApi(); + tui.addSession({ id: "root-session" }); + tui.addSession({ id: "child-session", parentID: "root-session" }); + tui.select("root-session"); + await plugin.tui(tui.api); + expect(requests).toHaveLength(1); + + tui.select("child-session"); + await new Promise((resolve) => setTimeout(resolve, 125)); + + expect(requests).toHaveLength(1); + expect(requestParam(requests[0], "agent_session_id")).toBe("root-session"); +}); + +test("stops route polling when the TUI plugin is disposed", async () => { + const plugin = await loadPlugin(); + const tui = fakeApi(); + tui.addSession({ id: "session-a" }); + await plugin.tui(tui.api); + tui.dispose(); + tui.select("session-a"); + + await new Promise((resolve) => setTimeout(resolve, 125)); + + expect(requests).toHaveLength(0); +}); + +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 { + return typeof value === "object" && value !== null; +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 89d4d34d8..9630b578a 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -4,6 +4,7 @@ mod command; mod config_edit; mod env; mod file_ops; +mod opencode_config; mod registry; mod targets; mod types; @@ -166,7 +167,10 @@ 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 = 9; +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 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/integration/opencode_config.rs b/src/integration/opencode_config.rs new file mode 100644 index 000000000..57431f937 --- /dev/null +++ b/src/integration/opencode_config.rs @@ -0,0 +1,298 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use jsonc_parser::cst::{CstInputValue, CstRootNode}; +use jsonc_parser::ParseOptions; +use serde_json::Value; + +const TUI_CONFIG_NAME: &str = "tui.jsonc"; + +pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf { + config_dir.join(TUI_CONFIG_NAME) +} + +pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> { + let config_path = tui_config_path(config_dir); + if !config_path.is_file() { + return Ok(()); + } + + let content = fs::read_to_string(&config_path)?; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + if object + .get("plugin") + .is_some_and(|property| property.array_value().is_none()) + { + return Err(invalid_plugin_list(&config_path)); + } + Ok(()) +} + +pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result { + let config_path = tui_config_path(config_dir); + let content = if config_path.is_file() { + fs::read_to_string(&config_path)? + } else { + "{}\n".to_string() + }; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + + match object.get("plugin") { + Some(property) => { + let plugins = property + .array_value() + .ok_or_else(|| invalid_plugin_list(&config_path))?; + if plugins.elements().iter().any(|entry| { + entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + }) { + return Ok(config_path); + } + plugins.append(CstInputValue::String(plugin_spec.to_string())); + } + None => { + object.append( + "plugin", + CstInputValue::Array(vec![CstInputValue::String(plugin_spec.to_string())]), + ); + } + } + + fs::write(&config_path, root.to_string())?; + Ok(config_path) +} + +pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result { + let config_path = tui_config_path(config_dir); + if !config_path.is_file() { + return Ok(false); + } + + let content = fs::read_to_string(&config_path)?; + let root = parse_root(&content, &config_path)?; + let object = root_object(&root, &config_path)?; + let Some(property) = object.get("plugin") else { + return Ok(false); + }; + let plugins = property + .array_value() + .ok_or_else(|| invalid_plugin_list(&config_path))?; + let mut removed = false; + for entry in plugins.elements() { + if entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + { + entry.remove(); + removed = true; + } + } + if !removed { + return Ok(false); + } + if plugins.elements().is_empty() { + property.remove(); + } + + fs::write(&config_path, root.to_string())?; + Ok(true) +} + +pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool { + let config_path = tui_config_path(config_dir); + let Ok(content) = fs::read_to_string(&config_path) else { + return false; + }; + let Ok(root) = parse_root(&content, &config_path) else { + return false; + }; + let Ok(object) = root_object(&root, &config_path) else { + return false; + }; + object + .get("plugin") + .and_then(|property| property.array_value()) + .is_some_and(|plugins| { + plugins.elements().iter().any(|entry| { + entry + .to_serde_value() + .is_some_and(|entry| plugin_entry_matches(&entry, plugin_spec)) + }) + }) +} + +fn parse_root(content: &str, path: &Path) -> io::Result { + CstRootNode::parse(content, &jsonc_parse_options()).map_err(|err| { + io::Error::other(format!( + "failed to parse OpenCode TUI config at {}: {err}", + path.display() + )) + }) +} + +fn root_object(root: &CstRootNode, path: &Path) -> io::Result { + root.value() + .and_then(|value| value.as_object()) + .ok_or_else(|| invalid_root(path)) +} + +fn jsonc_parse_options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_loose_object_property_names: false, + allow_trailing_commas: true, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + } +} + +fn plugin_entry_matches(entry: &Value, plugin_spec: &str) -> bool { + entry.as_str() == Some(plugin_spec) + || entry + .as_array() + .and_then(|parts| parts.first()) + .and_then(Value::as_str) + == Some(plugin_spec) +} + +fn invalid_root(path: &Path) -> io::Error { + io::Error::other(format!( + "OpenCode TUI config at {} must be a JSON object", + path.display() + )) +} + +fn invalid_plugin_list(path: &Path) -> io::Error { + io::Error::other(format!( + "OpenCode TUI config plugin list at {} must be an array", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn unique_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "herdr-opencode-config-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock should be after epoch") + .as_nanos() + )); + fs::create_dir_all(&dir).expect("temporary config directory should be created"); + dir + } + + fn parse_config(path: &Path) -> Value { + let content = fs::read_to_string(path).unwrap(); + parse_root(&content, path) + .unwrap() + .value() + .and_then(|value| value.to_serde_value()) + .unwrap() + } + + #[test] + fn add_and_remove_tui_plugin_preserves_jsonc_config() { + let dir = unique_dir(); + let config_path = dir.join(TUI_CONFIG_NAME); + fs::write( + &config_path, + concat!( + "{\n", + " // Keep this comment.\n", + " \"theme\": \"system\",\n", + " \"plugin\": [\"example\", [\"configured\", {\"enabled\": true}]],\n", + "}\n", + ), + ) + .unwrap(); + + add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + let installed_content = fs::read_to_string(&config_path).unwrap(); + assert!(installed_content.contains("// Keep this comment.")); + let installed = parse_config(&config_path); + assert_eq!(installed["theme"], "system"); + assert_eq!( + installed["plugin"], + json!([ + "example", + ["configured", {"enabled": true}], + "./herdr-tui-state.js" + ]) + ); + + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + let removed_content = fs::read_to_string(&config_path).unwrap(); + assert!(removed_content.contains("// Keep this comment.")); + let removed = parse_config(&config_path); + assert_eq!(removed["theme"], "system"); + assert_eq!( + removed["plugin"], + json!(["example", ["configured", {"enabled": true}]]) + ); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn managed_jsonc_leaves_opencode_migration_target_absent() { + let dir = unique_dir(); + let legacy_config_path = dir.join("opencode.json"); + let legacy_config = "{\n \"theme\": \"system\"\n}\n"; + fs::write(&legacy_config_path, legacy_config).unwrap(); + + let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + + assert_eq!(config_path, dir.join("tui.jsonc")); + assert!(!dir.join("tui.json").exists()); + assert_eq!( + fs::read_to_string(legacy_config_path).unwrap(), + legacy_config + ); + assert_eq!( + parse_config(&config_path), + json!({ "plugin": ["./herdr-tui-state.js"] }) + ); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn remove_tui_plugin_leaves_empty_managed_config() { + let dir = unique_dir(); + let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(); + + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + assert!(config_path.is_file()); + assert_eq!(parse_config(&config_path), json!({})); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn configured_tui_plugin_accepts_option_tuple() { + let dir = unique_dir(); + fs::write( + dir.join(TUI_CONFIG_NAME), + r#"{"plugin":[["./herdr-tui-state.js",{"enabled":true}]]}"#, + ) + .unwrap(); + + assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js")); + assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap()); + + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 569494a2f..b7bb3bf20 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -405,6 +405,22 @@ fn grok_hook_config_is_valid(hook_path: &Path) -> bool { .is_some_and(|config| config == super::targets::grok_hook_config(hook_path)) } +fn opencode_tui_integration_is_valid(plugin_path: &Path, expected_version: u32) -> bool { + let Some(config_dir) = plugin_path.parent().and_then(Path::parent) else { + return false; + }; + let tui_plugin_path = config_dir.join(super::OPENCODE_TUI_PLUGIN_INSTALL_NAME); + let tui_plugin_current = fs::read_to_string(tui_plugin_path) + .ok() + .and_then(|content| parse_integration_version(&content)) + .is_some_and(|version| version >= expected_version); + tui_plugin_current + && super::opencode_config::tui_plugin_is_configured( + config_dir, + super::OPENCODE_TUI_PLUGIN_SPEC, + ) +} + pub(crate) fn integration_status_at( target: crate::api::schema::IntegrationTarget, path: PathBuf, @@ -439,6 +455,12 @@ pub(crate) fn integration_status_at( { state = super::IntegrationStatusKind::Outdated; } + if target == crate::api::schema::IntegrationTarget::Opencode + && state == super::IntegrationStatusKind::Current + && !opencode_tui_integration_is_valid(&path, expected_version) + { + state = super::IntegrationStatusKind::Outdated; + } super::IntegrationStatus { target, diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 0cf2f9332..375d27139 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -25,6 +25,9 @@ use super::env::{ use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, }; +use super::opencode_config::{ + add_tui_plugin, remove_tui_plugin, tui_config_path, validate_tui_plugin_config, +}; use super::types::{ AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths, @@ -49,9 +52,9 @@ use super::{ KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, - OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, - QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, - QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, + OPENCODE_PLUGIN_INSTALL_NAME, OPENCODE_TUI_PLUGIN_ASSET, OPENCODE_TUI_PLUGIN_INSTALL_NAME, + OPENCODE_TUI_PLUGIN_SPEC, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, + QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS, }; fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> { @@ -452,13 +455,21 @@ pub(crate) fn install_opencode() -> io::Result { ))); } + validate_tui_plugin_config(&dir)?; let plugins_dir = dir.join("plugins"); fs::create_dir_all(&plugins_dir)?; let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME); fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET)?; + let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME); + fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET)?; + let tui_config_path = add_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC)?; - Ok(OpenCodeInstallPaths { plugin_path }) + Ok(OpenCodeInstallPaths { + plugin_path, + tui_plugin_path, + tui_config_path, + }) } pub(crate) fn install_kilo() -> io::Result { @@ -801,14 +812,38 @@ pub(crate) fn uninstall_droid() -> io::Result { } pub(crate) fn uninstall_opencode() -> io::Result { - let plugin_path = opencode_dir()? - .join("plugins") - .join(OPENCODE_PLUGIN_INSTALL_NAME); - let removed_plugin = remove_file_if_exists(&plugin_path)?; + let dir = opencode_dir()?; + let tui_config_path = tui_config_path(&dir); + let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME); + let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME); + let mut errors = Vec::new(); + let updated_tui_config = + remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| { + errors.push(err.to_string()); + false + }); + let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| { + errors.push(format!("failed to remove {}: {err}", plugin_path.display())); + false + }); + let removed_tui_plugin = remove_file_if_exists(&tui_plugin_path).unwrap_or_else(|err| { + errors.push(format!( + "failed to remove {}: {err}", + tui_plugin_path.display() + )); + false + }); + if !errors.is_empty() { + return Err(io::Error::other(errors.join("; "))); + } Ok(OpenCodeUninstallResult { plugin_path, + tui_plugin_path, + tui_config_path, removed_plugin, + removed_tui_plugin, + updated_tui_config, }) } diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 0081fcdb1..6eaf7a393 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2201,7 +2201,7 @@ fn install_droid_errors_when_config_dir_missing() { } #[test] -fn install_opencode_writes_plugin_to_plugins_dir() { +fn install_opencode_writes_server_and_tui_plugins() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -2210,7 +2210,6 @@ fn install_opencode_writes_plugin_to_plugins_dir() { std::env::set_var("HOME", &home); let installed = install_opencode().unwrap(); - let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap(); assert_eq!( installed.plugin_path, @@ -2218,30 +2217,126 @@ fn install_opencode_writes_plugin_to_plugins_dir() { .join("plugins") .join(OPENCODE_PLUGIN_INSTALL_NAME) ); - assert_eq!(plugin_content, OPENCODE_PLUGIN_ASSET); + assert_eq!( + fs::read_to_string(&installed.plugin_path).unwrap(), + OPENCODE_PLUGIN_ASSET + ); + assert_eq!( + installed.tui_plugin_path, + opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME) + ); + assert_eq!( + fs::read_to_string(&installed.tui_plugin_path).unwrap(), + OPENCODE_TUI_PLUGIN_ASSET + ); + assert_eq!(installed.tui_config_path, opencode_dir.join("tui.jsonc")); + let tui_config: Value = + serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap(); + assert_eq!(tui_config["plugin"], json!([OPENCODE_TUI_PLUGIN_SPEC])); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] -fn uninstall_opencode_removes_plugin_when_present() { +fn opencode_status_requires_the_tui_plugin_and_config_entry() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); - let opencode_dir = home.join(".config/opencode/plugins"); + let opencode_dir = home.join(".config/opencode"); fs::create_dir_all(&opencode_dir).unwrap(); - fs::write( - opencode_dir.join(OPENCODE_PLUGIN_INSTALL_NAME), - OPENCODE_PLUGIN_ASSET, - ) - .unwrap(); std::env::set_var("HOME", &home); + let installed = install_opencode().unwrap(); + let status = || { + integration_status_at( + crate::api::schema::IntegrationTarget::Opencode, + installed.plugin_path.clone(), + OPENCODE_INTEGRATION_VERSION, + ) + .state + }; + + assert_eq!(status(), IntegrationStatusKind::Current); + fs::remove_file(&installed.tui_plugin_path).unwrap(); + assert_eq!(status(), IntegrationStatusKind::Outdated); + fs::write(&installed.tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap(); + super::opencode_config::remove_tui_plugin(&opencode_dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap(); + assert_eq!(status(), IntegrationStatusKind::Outdated); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let opencode_dir = home.join(".config/opencode"); + fs::create_dir_all(&opencode_dir).unwrap(); + std::env::set_var("HOME", &home); + let installed = install_opencode().unwrap(); let result = uninstall_opencode().unwrap(); assert!(result.removed_plugin); + assert!(result.removed_tui_plugin); + assert!(result.updated_tui_config); assert!(!result.plugin_path.exists()); + assert!(!result.tui_plugin_path.exists()); + assert!(result.tui_config_path.exists()); + let tui_config: Value = + serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap(); + assert_eq!(tui_config, json!({})); + assert_eq!(installed.plugin_path, result.plugin_path); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_opencode_invalid_tui_config_does_not_write_plugins() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let opencode_dir = home.join(".config/opencode"); + fs::create_dir_all(&opencode_dir).unwrap(); + fs::write(opencode_dir.join("tui.jsonc"), r#"{"plugin":{}}"#).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_opencode().unwrap_err().to_string(); + + assert!(err.contains("plugin list")); + assert!(!opencode_dir + .join("plugins") + .join(OPENCODE_PLUGIN_INSTALL_NAME) + .exists()); + assert!(!opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let opencode_dir = home.join(".config/opencode"); + let plugins_dir = opencode_dir.join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + let plugin_path = plugins_dir.join(OPENCODE_PLUGIN_INSTALL_NAME); + let tui_plugin_path = opencode_dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap(); + fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap(); + fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap(); + std::env::set_var("HOME", &home); + + let err = uninstall_opencode().unwrap_err().to_string(); + + assert!(err.contains("failed to parse OpenCode TUI config")); + assert!(!plugin_path.exists()); + assert!(!tui_plugin_path.exists()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); diff --git a/src/integration/types.rs b/src/integration/types.rs index 09a235b04..c5c242674 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -42,6 +42,8 @@ pub(crate) struct DroidInstallPaths { #[derive(Debug)] pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, + pub tui_plugin_path: PathBuf, + pub tui_config_path: PathBuf, } #[derive(Debug)] @@ -225,7 +227,11 @@ pub(crate) struct DroidUninstallResult { #[derive(Debug)] pub(crate) struct OpenCodeUninstallResult { pub plugin_path: PathBuf, + pub tui_plugin_path: PathBuf, + pub tui_config_path: PathBuf, pub removed_plugin: bool, + pub removed_tui_plugin: bool, + pub updated_tui_config: bool, } #[derive(Debug)] diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 5ed5adc48..56064a940 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -844,7 +844,7 @@ impl TerminalState { let process_present = known_agent.is_some() && self.detected_agent == known_agent && self.recent_agent_process_exit.is_none(); - let session_anchored = self + let anchored_session_ref = self .hook_authority .as_ref() .filter(|authority| authority.source == source && authority.agent_label == agent_label) @@ -854,12 +854,20 @@ impl TerminalState { .as_ref() .filter(|session| session.source == source && session.agent == agent_label) .map(|session| &session.session_ref) - }) - .is_some_and(|anchored| { - session_ref - .as_ref() - .is_none_or(|incoming| incoming == anchored) }); + let session_anchored = anchored_session_ref.is_some_and(|anchored| { + session_ref + .as_ref() + .is_none_or(|incoming| incoming == anchored) + }); + let opencode_cross_talk = (source, agent_label) == ("herdr:opencode", "opencode") + && process_present + && anchored_session_ref + .zip(session_ref.as_ref()) + .is_some_and(|(anchored, incoming)| anchored != incoming); + if opencode_cross_talk { + return FullLifecycleHookReportRoute::Ignore; + } if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) { if suppressed.agent_label != agent_label { return FullLifecycleHookReportRoute::Ignore; @@ -1284,7 +1292,7 @@ impl TerminalState { Some("startup" | "clear" | "resume" | "compact") ) | ("herdr:mastracode", "mastracode", Some("startup")) | ("herdr:hermes", "hermes", Some("startup" | "new" | "resume")) - | ("herdr:opencode", "opencode", Some("new")) + | ("herdr:opencode", "opencode", Some("select")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( "herdr:omp", @@ -1298,10 +1306,20 @@ impl TerminalState { fn session_start_source_is_recognized(session_start_source: Option<&str>) -> bool { matches!( session_start_source, - Some("startup" | "clear" | "resume" | "compact" | "new" | "fork") + Some("startup" | "clear" | "resume" | "compact" | "new" | "fork" | "select") ) } + fn is_unsequenced_opencode_selection( + source: &str, + agent_label: &str, + session_start_source: Option<&str>, + seq: Option, + ) -> bool { + (source, agent_label, session_start_source, seq) + == ("herdr:opencode", "opencode", Some("select"), None) + } + pub fn set_persisted_agent_session( &mut self, session: crate::agent_resume::PersistedAgentSession, @@ -1346,7 +1364,48 @@ impl TerminalState { && authority.agent_label == agent_label && authority.session_ref.is_some() }) || self.persisted_agent_session_matches(&source, &agent_label); - if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) { + let unsequenced_selection = Self::is_unsequenced_opencode_selection( + &source, + &agent_label, + session_start_source.as_deref(), + seq, + ); + let selection_can_reconcile = unsequenced_selection && process_present; + if selection_can_reconcile { + self.suppressed_full_lifecycle_hook_reports.remove(&source); + } else if full_lifecycle_source && unsequenced_selection { + let previous_session_ref = self + .hook_authority + .as_ref() + .filter(|authority| { + authority.source == source && authority.agent_label == agent_label + }) + .and_then(|authority| authority.session_ref.clone()) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .filter(|session| session.source == source && session.agent == agent_label) + .map(|session| session.session_ref.clone()) + }); + let suppressed = self + .suppressed_full_lifecycle_hook_reports + .entry(source) + .or_insert_with(|| SuppressedFullLifecycleHookReport { + agent_label, + session_ref: previous_session_ref, + observed_at: Instant::now(), + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }); + suppressed.replacement_session_ref = Some(session_ref); + suppressed.pending_replacement_report = None; + return None; + } + if full_lifecycle_source + && !selection_can_reconcile + && (!process_present || generation_gated || !session_anchored) + { if !Self::session_start_source_is_recognized(session_start_source.as_deref()) { return None; } @@ -1408,7 +1467,7 @@ impl TerminalState { } return None; } - if !self.accept_hook_report(&source, seq) { + if !unsequenced_selection && !self.accept_hook_report(&source, seq) { return None; } if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { @@ -4426,37 +4485,272 @@ mod tests { } #[test] - fn opencode_new_session_ref_replaces_existing_session_ref() { + fn opencode_server_new_does_not_replace_existing_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), - crate::agent_resume::AgentSessionRef::id("opencode-old"), + crate::agent_resume::AgentSessionRef::id("opencode-visible"), + None, + Some("select".into()), + ) + .expect("local selection should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-attached-client"), + Some(21), + Some("new".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-visible") + ); + } + + #[test] + fn opencode_server_resume_does_not_replace_existing_session_ref() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-visible"), + None, + Some("select".into()), + ) + .expect("local selection should be accepted"); + + let mutation = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-attached-client"), + Some(21), + Some("resume".into()), + ); + + assert!(mutation.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-visible") + ); + } + + #[test] + fn opencode_tui_selection_anchors_after_process_detection() { + let mut terminal = test_terminal(); + let startup_selection = terminal.set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-startup-selection"), + None, + Some("select".into()), + ); + assert!(startup_selection.is_none()); + assert_eq!( + terminal + .suppressed_full_lifecycle_hook_reports + .get("herdr:opencode") + .and_then(|suppressed| suppressed.replacement_session_ref.as_ref()) + .map(|session| session.value.as_str()), + Some("opencode-startup-selection") + ); + + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-startup-selection") + ); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + + terminal.suppressed_full_lifecycle_hook_reports.insert( + "herdr:opencode".into(), + SuppressedFullLifecycleHookReport { + agent_label: "opencode".into(), + session_ref: None, + observed_at: Instant::now(), + reason: FullLifecycleHookSuppressionReason::ProcessExit, + replacement_session_ref: None, + pending_replacement_report: None, + }, + ); + let selected = terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id("opencode-reselected"), + None, + Some("select".into()), + ) + .expect("local TUI selection should reconcile generation suppression"); + + assert!(selected.session_ref_changed); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-reselected") + ); + assert!(!terminal + .hook_report_sequences + .contains_key("herdr:opencode")); + } + + #[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(); + let selected_session = + crate::agent_resume::AgentSessionRef::id("opencode-selected-older").unwrap(); + anchor_full_lifecycle_session( + &mut terminal, + Agent::OpenCode, + "herdr:opencode", + "opencode", + old_session.clone(), + ); + terminal + .set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + Some(old_session.clone()), Some(20), - Some("new".into()), ) - .expect("initial session should be accepted"); + .expect("initial session should own lifecycle state"); + let attached_session = + crate::agent_resume::AgentSessionRef::id("opencode-attached-client").unwrap(); + let attached = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Working, + None, + Some(attached_session.clone()), + Some(21), + ); + assert!(attached.is_none()); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); - let mutation = terminal + let selected = terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), - crate::agent_resume::AgentSessionRef::id("opencode-new"), + Some(selected_session.clone()), + None, + Some("select".into()), + ) + .expect("selected session should replace the previous session"); + + assert!(selected.session_ref_changed); + assert!(terminal.hook_authority.is_none()); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); + assert_eq!( + terminal.hook_report_sequences.get("herdr:opencode"), + Some(&20) + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&selected_session) + ); + + terminal + .set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Working, + None, + Some(selected_session.clone()), Some(21), - Some("new".into()), ) - .expect("new should replace the session"); + .expect("selected session should regain lifecycle authority"); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()), + Some(&selected_session) + ); - assert!(mutation.session_ref_changed); + let late_old_session = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Idle, + None, + Some(old_session), + Some(22), + ); + assert!(late_old_session.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()), + Some(&selected_session) + ); + + let late_attached_session = terminal.set_hook_authority_with_session_ref( + "herdr:opencode".into(), + "opencode".into(), + AgentState::Blocked, + None, + Some(attached_session), + Some(23), + ); + assert!(late_attached_session.is_none()); + assert_eq!(terminal.state, AgentState::Working); + + let final_session = + crate::agent_resume::AgentSessionRef::id("opencode-final-selection").unwrap(); + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + Some(final_session.clone()), + None, + Some("select".into()), + ) + .expect("another local selection should remain authoritative"); assert_eq!( terminal .persisted_agent_session .as_ref() - .map(|session| session.session_ref.value.as_str()), - Some("opencode-new") + .map(|session| &session.session_ref), + Some(&final_session) ); + assert!(!terminal + .suppressed_full_lifecycle_hook_reports + .contains_key("herdr:opencode")); } #[test] @@ -4501,14 +4795,14 @@ mod tests { terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); assert!(terminal.reconcile_managed_agent_at(now, false)); - for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] { + for session in ["opencode-old", "opencode-new"] { terminal .set_agent_session_ref_for_session_start( "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id(session), - Some(sequence), - Some("new".into()), + None, + Some("select".into()), ) .expect("managed session should be accepted"); } @@ -4533,10 +4827,10 @@ mod tests { "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-old"), - Some(20), - Some("new".into()), + None, + Some("select".into()), ) - .expect("initial session should be accepted"); + .expect("local selection should be accepted"); // session.updated reports carry no session_start_source, so a different // id must not displace the established session (cross-talk guard). @@ -4793,10 +5087,10 @@ mod tests { "herdr:opencode".into(), "opencode".into(), crate::agent_resume::AgentSessionRef::id("opencode-new-session"), - Some(23), - Some("new".into()), + None, + Some("select".into()), ) - .expect("fresh root session"); + .expect("fresh local selection"); let fresh_session = terminal.set_hook_authority_with_session_ref( "herdr:opencode".into(), "opencode".into(),