fix: track opencode tui session selection (#2455)

refs #2450

Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
akbash
2026-08-10 14:57:44 +03:00
committed by GitHub
co-authored by Ogulcan Celik
parent 2e8cc9060d
commit ccccda54c2
16 changed files with 1190 additions and 75 deletions
+1
View File
@@ -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)
+1
View File
@@ -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:
+5 -1
View File
@@ -71,7 +71,7 @@ pub fn session_ref_from_report(
pub fn normalize_session_start_source(value: Option<String>) -> Option<String> {
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())
+7
View File
@@ -47,6 +47,13 @@ fn integration_status(args: &[String]) -> std::io::Result<i32> {
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)
}
+37 -9
View File
@@ -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()?;
@@ -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) {
@@ -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();
@@ -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));
},
};
@@ -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<string, () => 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<string, { id: string; parentID?: string }>();
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<void> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}
+5 -1
View File
@@ -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;
+298
View File
@@ -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<PathBuf> {
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<bool> {
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> {
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<jsonc_parser::cst::CstObject> {
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();
}
}
+22
View File
@@ -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,
+43 -8
View File
@@ -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<OpenCodeInstallPaths> {
)));
}
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<KiloInstallPaths> {
@@ -801,14 +812,38 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
}
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
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,
})
}
+105 -10
View File
@@ -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);
+6
View File
@@ -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)]
+329 -35
View File
@@ -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<u64>,
) -> 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,39 +4485,274 @@ 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"),
Some(20),
Some("new".into()),
crate::agent_resume::AgentSessionRef::id("opencode-visible"),
None,
Some("select".into()),
)
.expect("initial session should be accepted");
.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-new"),
Some(21),
Some("new".into()),
)
.expect("new should replace the session");
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.session_ref_changed);
assert!(mutation.is_none());
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.map(|session| session.session_ref.value.as_str()),
Some("opencode-new")
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),
)
.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 selected = terminal
.set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
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),
)
.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)
);
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),
Some(&final_session)
);
assert!(!terminal
.suppressed_full_lifecycle_hook_reports
.contains_key("herdr:opencode"));
}
#[test]
fn pi_session_replacement_clears_the_previous_sessions_alias() {
let mut terminal = test_terminal();
@@ -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(),