use std::net::SocketAddr;
use std::path::Path;
use axum::body::Bytes;
use axum::extract::{ConnectInfo, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Json;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::scheduler;
use crate::state::SharedState;
use crate::tmux;
use crate::transport;
const MAX_DESCRIPTION_LEN: usize = 200;
const NPUB_DISPLAY_LEN: usize = 16;
const CONNECT_TIMEOUT_SECS: u64 = 10;
const MAX_TASK_RUNS_RETURNED: usize = 50;
pub(crate) fn normalize_optional_string(input: Option<String>) -> Option<String> {
input
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn validate_backend_name(
state: &SharedState,
backend: Option<&str>,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let Some(name) = backend else {
return Ok(());
};
if state.backends.get(name).is_some() {
return Ok(());
}
Err((
StatusCode::BAD_REQUEST,
Json(json!({
"error": state.backends.unknown_backend_message(name),
"valid_backends": state.backends.names(),
})),
))
}
pub(crate) fn extract_project_description(project_dir: &str) -> Option<String> {
let dir = Path::new(project_dir);
if let Ok(contents) = std::fs::read_to_string(dir.join("Cargo.toml")) {
for line in contents.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("description") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let val = rest.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
}
if let Ok(contents) = std::fs::read_to_string(dir.join("package.json")) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(desc) = json["description"].as_str() {
if !desc.is_empty() {
return Some(desc.to_string());
}
}
}
}
if let Ok(contents) = std::fs::read_to_string(dir.join("README.md")) {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let truncated = if trimmed.len() > MAX_DESCRIPTION_LEN {
format!("{}...", &trimmed[..MAX_DESCRIPTION_LEN])
} else {
trimmed.to_string()
};
return Some(truncated);
}
}
None
}
pub async fn get_session(
State(state): State<SharedState>,
axum::extract::Path(name): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let proto = state.protocol.read().await;
match proto.sessions.get(&name) {
Some(s) => {
let stale = s.metadata.is_stale();
(
StatusCode::OK,
Json(json!({
"id": s.id,
"pane": s.pane,
"origin": s.origin.label(),
"session_incarnation": s.metadata.session_incarnation.to_string(),
"parent_session": s.metadata.parent_session,
"vim_mode": s.metadata.vim_mode,
"project_dir": s.metadata.project_dir,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"networked": s.metadata.networked,
"worktree": s.metadata.worktree,
"model": s.metadata.model,
"effort": s.metadata.effort,
"last_metadata_update": s.metadata.last_metadata_update,
"stale": stale,
"backend_session_id": s.metadata.backend_session_id,
"backend": s.metadata.backend,
"reminder": s.metadata.reminder,
"prompt": s.metadata.prompt,
"iteration": s.metadata.iteration,
"iteration_log": s.metadata.iteration_log,
"last_iteration_at": s.metadata.last_iteration_at,
"worktree_present": s.metadata.worktree_present,
"fresh_context_after_active_secs": s.metadata.fresh_context_after_active_secs,
"active_context_accumulated_secs": s.metadata.active_context_accumulated_secs,
"active_context_segment_open": s.metadata.active_context_segment_started_at.is_some(),
"active_context_restart_due": s.metadata.active_context_restart_due,
"active_context_accounting_provisional": s.metadata.active_context_accounting_provisional,
})),
)
}
None => (
StatusCode::NOT_FOUND,
Json(json!({"error": format!("session '{}' not found", name)})),
),
}
}
pub async fn status(State(state): State<SharedState>) -> Json<serde_json::Value> {
let proto = state.protocol.read().await;
let nodes = state.nodes.read().await;
let transports = state.transports().await;
let sessions_list: Vec<_> = proto
.sessions
.values()
.map(|s| {
let stale = s.metadata.is_stale();
json!({
"id": s.id,
"pane": s.pane,
"origin": s.origin.label(),
"session_incarnation": s.metadata.session_incarnation.to_string(),
"parent_session": s.metadata.parent_session,
"vim_mode": s.metadata.vim_mode,
"project_dir": s.metadata.project_dir,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"networked": s.metadata.networked,
"worktree": s.metadata.worktree,
"model": s.metadata.model,
"effort": s.metadata.effort,
"last_metadata_update": s.metadata.last_metadata_update,
"stale": stale,
"backend_session_id": s.metadata.backend_session_id,
"backend": s.metadata.backend,
"reminder": s.metadata.reminder,
"prompt": s.metadata.prompt,
"iteration": s.metadata.iteration,
"iteration_log": s.metadata.iteration_log,
"last_iteration_at": s.metadata.last_iteration_at,
"worktree_present": s.metadata.worktree_present,
"fresh_context_after_active_secs": s.metadata.fresh_context_after_active_secs,
"active_context_accumulated_secs": s.metadata.active_context_accumulated_secs,
"active_context_segment_open": s.metadata.active_context_segment_started_at.is_some(),
"active_context_restart_due": s.metadata.active_context_restart_due,
"active_context_accounting_provisional": s.metadata.active_context_accounting_provisional,
})
})
.collect();
let quarantined_list: Vec<_> = proto
.lifecycle_leases
.iter()
.filter_map(|(id, lease)| {
lease.sweep_unconfirmed.as_ref().map(|reason| {
json!({
"id": id,
"session_incarnation": lease.owner.incarnation.to_string(),
"phase": format!("{:?}", lease.phase),
"backend": lease.backend,
"backend_session_id": lease.backend_session_id,
"reason": reason,
"recover_with": format!("ouija recover-lease {id}"),
})
})
})
.collect();
drop(proto);
let nodes_list: Vec<_> = nodes
.values()
.map(|p| {
json!({
"name": p.name,
"daemon_id": p.daemon_id,
})
})
.collect();
let transports_list: Vec<_> = transports
.values()
.map(|t| {
json!({
"name": t.transport_name(),
"ready": t.is_ready(),
"endpoint_id": t.endpoint_id(),
})
})
.collect();
let first_transport = transports.values().next();
let compat_transport = first_transport.map(|t| t.transport_name());
let compat_endpoint_id = first_transport.and_then(|t| t.endpoint_id());
let assistant_panes: Vec<_> = state
.cached_assistant_panes()
.await
.into_iter()
.map(|p| json!({ "pane_id": p.pane_id, "session": p.session_name }))
.collect();
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"daemon": state.config.name,
"daemon_id": state.config.npub,
"port": state.config.port,
"transports": transports_list,
"transport": compat_transport,
"endpoint_id": compat_endpoint_id,
"sessions": sessions_list,
"quarantined_sessions": quarantined_list,
"nodes": nodes_list,
"assistant_panes": assistant_panes,
}))
}
#[derive(Debug, Deserialize, Default)]
pub struct TicketQuery {
#[serde(default, deserialize_with = "deserialize_string_or_seq")]
relay: Vec<String>,
}
fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct StringOrSeq;
impl<'de> de::Visitor<'de> for StringOrSeq {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a string or sequence of strings")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Vec<String>, E> {
Ok(vec![v.to_string()])
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<String>, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element()? {
v.push(s);
}
Ok(v)
}
}
deserializer.deserialize_any(StringOrSeq)
}
pub async fn ticket(
State(state): State<SharedState>,
Query(query): Query<TicketQuery>,
) -> (StatusCode, Json<serde_json::Value>) {
let t = if !query.relay.is_empty() {
match crate::nostr_transport::ensure_active(&state, query.relay).await {
Ok(t) => t,
Err(e) => {
let msg = format!("failed to start nostr transport: {e}");
tracing::error!("{msg}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": msg })),
);
}
}
} else {
let Some(t) = state.transport_by_name("nostr").await else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport is not active" })),
);
};
t
};
match t.ticket_string().await {
Some(ticket) => (
StatusCode::OK,
Json(json!({
"ticket": ticket,
"endpoint_id": t.endpoint_id(),
"transport": "nostr",
})),
),
None => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport not ready" })),
),
}
}
#[derive(Debug, Deserialize, Default)]
pub struct RegenerateQuery {
confirm: Option<bool>,
}
pub async fn regenerate_ticket(
State(state): State<SharedState>,
Query(query): Query<RegenerateQuery>,
) -> (StatusCode, Json<serde_json::Value>) {
let Some(t) = state.transport_by_name("nostr").await else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "nostr transport is not active" })),
);
};
if query.confirm != Some(true) {
return (
StatusCode::OK,
Json(json!({
"warning": "This will destroy your nostr identity (nsec). All nodes must re-connect. Add ?confirm=true to proceed.",
"transport": "nostr",
})),
);
}
match t
.regenerate(&state.config.config_dir, &state.config.data_dir)
.await
{
Ok(ticket) => (
StatusCode::OK,
Json(json!({ "ticket": ticket, "transport": "nostr" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
#[derive(Debug, Deserialize)]
pub struct ConnectBody {
ticket: String,
name: Option<String>,
}
pub async fn connect(
State(state): State<SharedState>,
Json(body): Json<ConnectBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let nprofile_part = body
.ticket
.split_once('#')
.map_or(body.ticket.as_str(), |(left, _)| left);
if !nprofile_part.starts_with("nprofile1") {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "ticket must be an nprofile1 string" })),
);
}
tracing::info!(
"connect request received (ticket len={})",
body.ticket.len()
);
let peer_npub = extract_npub(&body.ticket);
if let Some(ref npub) = peer_npub {
let node_name = body
.name
.as_deref()
.unwrap_or(&npub[..NPUB_DISPLAY_LEN.min(npub.len())]);
if let Err(existing) = state.try_add_node(npub, node_name) {
let msg = format!("already connected to this daemon as '{existing}'");
tracing::info!("connect rejected: {msg}");
return (StatusCode::CONFLICT, Json(json!({ "error": msg })));
}
}
let t = if let Some(t) = state.transport_by_name("nostr").await {
t
} else {
let relays = extract_nprofile_relays(&body.ticket);
match crate::nostr_transport::ensure_active(&state, relays).await {
Ok(t) => t,
Err(e) => {
let msg = format!("failed to start nostr transport: {e}");
tracing::error!("{msg}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": msg })),
);
}
}
};
let connect_fut = t.connect(&body.ticket, state.clone(), true);
match tokio::time::timeout(
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
connect_fut,
)
.await
{
Err(_) => {
tracing::warn!("connect timed out after 10s waiting for peer");
return (
StatusCode::GATEWAY_TIMEOUT,
Json(json!({ "error": "connect timed out waiting for peer" })),
);
}
Ok(Err(e)) => {
tracing::error!("connect failed: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("connect failed: {e}") })),
);
}
Ok(Ok(())) => {}
}
if let Err(e) = crate::persistence::add_connection(
&state.config.data_dir,
&body.ticket,
body.name.as_deref(),
peer_npub.as_deref(),
) {
tracing::warn!("failed to persist connection: {e}");
}
tracing::info!("node connected successfully via nostr");
(
StatusCode::OK,
Json(json!({ "status": "connected", "transport": "nostr" })),
)
}
fn strip_ticket_secret(ticket: &str) -> &str {
ticket.split_once('#').map_or(ticket, |(left, _)| left)
}
fn extract_nprofile_relays(ticket: &str) -> Vec<String> {
use nostr_sdk::prelude::*;
Nip19Profile::from_bech32(strip_ticket_secret(ticket))
.map(|p| p.relays.into_iter().map(|r| r.to_string()).collect())
.unwrap_or_default()
}
pub fn extract_npub(ticket: &str) -> Option<String> {
use nostr_sdk::prelude::*;
Nip19Profile::from_bech32(strip_ticket_secret(ticket))
.ok()
.and_then(|p| p.public_key.to_bech32().ok())
}
#[derive(Debug, Deserialize)]
pub struct RegisterBody {
id: String,
pane: Option<String>,
#[serde(default)]
vim_mode: bool,
project_dir: Option<String>,
role: Option<String>,
bulletin: Option<String>,
#[serde(default)]
networked: Option<bool>,
#[serde(alias = "claude_session_id")]
backend_session_id: Option<String>,
#[serde(default)]
backend: Option<String>,
#[serde(default)]
reminder: Option<String>,
}
fn parse_tcp_inode_for_local(tcp_table: &str, needle: &str) -> Option<u64> {
for line in tcp_table.lines().skip(1) {
let cols: Vec<&str> = line.split_whitespace().collect();
if cols.len() >= 10 && cols[1] == needle {
return cols[9].parse().ok();
}
}
None
}
fn needle_for_loopback_peer(peer: SocketAddr) -> Option<String> {
let std::net::IpAddr::V4(v4) = peer.ip() else {
return None;
};
let o = v4.octets();
Some(format!(
"{:02X}{:02X}{:02X}{:02X}:{:04X}",
o[3],
o[2],
o[1],
o[0],
peer.port()
))
}
#[cfg(target_os = "linux")]
fn resolve_loopback_peer(peer: SocketAddr) -> Option<String> {
let needle = needle_for_loopback_peer(peer)?;
let tcp_table = std::fs::read_to_string("/proc/net/tcp").ok()?;
let inode = parse_tcp_inode_for_local(&tcp_table, &needle)?;
let socket_target = format!("socket:[{inode}]");
for entry in std::fs::read_dir("/proc").ok()?.flatten() {
let name = entry.file_name();
let pid = match name.to_str() {
Some(s) if s.chars().all(|c| c.is_ascii_digit()) => s,
_ => continue,
};
let fd_dir = entry.path().join("fd");
let Ok(fds) = std::fs::read_dir(&fd_dir) else {
continue;
};
for fd in fds.flatten() {
if let Ok(link) = std::fs::read_link(fd.path())
&& link.to_str() == Some(socket_target.as_str())
{
let cmdline = std::fs::read_to_string(entry.path().join("cmdline"))
.unwrap_or_default()
.replace('\0', " ")
.trim_end()
.to_string();
return Some(format!("pid={pid} cmd={cmdline:?}"));
}
}
}
None
}
#[cfg(not(target_os = "linux"))]
fn resolve_loopback_peer(_peer: SocketAddr) -> Option<String> {
None
}
pub async fn register(
State(state): State<SharedState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body_bytes: Bytes,
) -> (StatusCode, Json<serde_json::Value>) {
let user_agent = headers
.get(header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("-");
let raw_body = String::from_utf8_lossy(&body_bytes);
let caller = resolve_loopback_peer(peer).unwrap_or_else(|| "pid=unknown".to_string());
tracing::info!(
target: "ouija::api::register",
peer = %peer,
user_agent = %user_agent,
caller = %caller,
"/api/register: raw_body={}",
raw_body,
);
let body: RegisterBody = match serde_json::from_slice(&body_bytes) {
Ok(b) => b,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("invalid JSON: {e}") })),
);
}
};
if body.id.contains('/') {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "session ID must not contain '/'" })),
);
}
let project_description = body
.project_dir
.as_deref()
.and_then(extract_project_description);
let metadata = crate::state::SessionMetadata {
vim_mode: body.vim_mode,
project_dir: body.project_dir,
role: body.role,
bulletin: body.bulletin,
networked: body.networked.unwrap_or(true),
backend_session_id: body.backend_session_id,
backend: body.backend,
project_description,
reminder: body.reminder,
..Default::default()
};
if let Some(ref p) = body.pane {
let names = state.backends.all_process_names();
let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
if !crate::tmux::pane_alive(p, &refs) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("pane {p} does not exist") })),
);
}
}
let backend = match metadata.backend {
Some(ref b) => Some(b.clone()),
None => match body.pane {
Some(ref p) => state.detect_backend_in_pane(p).await,
None => None,
},
};
let proto_meta = crate::daemon_protocol::SessionMeta {
project_dir: metadata.project_dir.clone(),
role: metadata.role.clone(),
bulletin: metadata.bulletin.clone(),
networked: metadata.networked,
worktree: metadata.worktree,
vim_mode: metadata.vim_mode,
backend,
backend_session_id: metadata.backend_session_id.clone(),
reminder: metadata.reminder.clone(),
..Default::default()
};
let event = match body.pane.clone() {
Some(pane) => crate::daemon_protocol::Event::RegisterIfPaneUnbound {
id: body.id.clone(),
pane,
expected_backend_session_id: proto_meta.backend_session_id.clone(),
expected_orphaned_marker_owner: None,
metadata: proto_meta,
},
None => crate::daemon_protocol::Event::Register {
id: body.id.clone(),
pane: None,
metadata: proto_meta,
},
};
let effects = state.apply_and_execute(event).await;
let (session_id, _replaced) = match effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::RegisterOk {
session_id,
replaced,
..
} => Some((session_id.clone(), replaced.clone())),
_ => None,
}) {
Some(ok) => ok,
None => {
if let Some((session_id, reason)) = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::RegisterFailed { session_id, reason } => {
Some((session_id.clone(), reason.clone()))
}
_ => None,
}) {
return (
StatusCode::CONFLICT,
Json(json!({
"error": reason,
"session_id": session_id,
})),
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "unexpected register result" })),
);
}
};
(
StatusCode::OK,
Json(json!({
"registered": session_id,
"pane": body.pane,
})),
)
}
#[derive(Debug, Deserialize)]
pub struct SendBody {
from: String,
to: String,
message: String,
#[serde(default)]
expects_reply: bool,
#[serde(default)]
responds_to: Option<u64>,
#[serde(default)]
done: bool,
#[serde(default)]
sender_ctx: Option<crate::daemon_protocol::SenderContext>,
}
pub async fn send_msg(
State(state): State<SharedState>,
Json(body): Json<SendBody>,
) -> (StatusCode, Json<serde_json::Value>) {
if body.from == body.to {
let suffix = format!("/{}", body.to);
let prefix = format!("{}/", body.to);
let proto = state.protocol.read().await;
let suggestions: Vec<&str> = proto
.sessions
.keys()
.filter(|k| k.ends_with(&suffix) || k.starts_with(&prefix))
.map(|k| k.as_str())
.collect();
let hint = if suggestions.is_empty() {
"If you meant a remote session, use the full node-prefixed name (e.g. 'node/session'). GET /api/status to see all available targets.".to_string()
} else {
format!(
"Did you mean one of these remote sessions? {} — GET /api/status to check.",
suggestions.join(", ")
)
};
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("cannot send a message to yourself. {hint}") })),
);
}
match &body.sender_ctx {
Some(ctx) => {
let proto = state.protocol.read().await;
if let Err(reason) =
crate::daemon_protocol::validate_sender_claim(&proto, &body.from, ctx)
{
return (StatusCode::FORBIDDEN, Json(json!({ "error": reason })));
}
}
None => {
tracing::warn!(
from = %body.from,
to = %body.to,
"send accepted without sender context (accepted-residual policy); \
sender claim unverified"
);
}
}
let restart_in_progress = state
.protocol
.read()
.await
.lifecycle_leases
.get(&body.to)
.is_some_and(|lease| lease.phase == crate::daemon_protocol::LifecyclePhase::Restarting);
if restart_in_progress {
return (
StatusCode::CONFLICT,
Json(json!({ "error": format!("restart is in progress for session '{}'", body.to) })),
);
}
let owed_msg_id = if !body.expects_reply && body.responds_to.is_none() {
state
.protocol
.read()
.await
.pending_replies
.get(&body.from)
.and_then(|pending| {
pending
.iter()
.find(|entry| entry.from == body.to)
.map(|entry| entry.msg_id)
})
} else {
None
};
let from = body.from.clone();
let to = body.to.clone();
let (effects, rollback) = {
let mut proto = state.protocol.write().await;
let mut rollback = FailedSendRollback::capture(&proto, &from, body.responds_to, body.done);
let effects = proto.apply(crate::daemon_protocol::Event::Send {
from: body.from,
to: body.to,
message: body.message,
expects_reply: body.expects_reply,
responds_to: body.responds_to,
done: body.done,
});
rollback.capture_after_send(&proto);
rollback.reserve_sender_state_after_send(&mut proto);
(effects, rollback)
};
if let Some((reason, renamed_to)) = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::SendFailed {
reason, renamed_to, ..
} => Some((reason.clone(), renamed_to.clone())),
_ => None,
}) {
rollback_failed_delivery(&state, &effects, rollback).await;
let mut body = json!({ "error": reason });
if let Some(new_id) = renamed_to {
body["renamed_to"] = json!(new_id);
}
return (StatusCode::NOT_FOUND, Json(body));
}
let delivery_outcome = match execute_send_effects_for_api(&state, &effects).await {
Ok(outcome) => outcome,
Err(e) => {
rollback_failed_delivery(&state, &effects, rollback).await;
let method = effects.iter().find_map(|effect| match effect {
crate::daemon_protocol::Effect::SendDelivered { method, .. } => {
Some(method.clone())
}
_ => None,
});
let mut body = json!({ "error": e.to_string() });
if let Some(method) = method {
body["method"] = json!(method);
}
return (StatusCode::BAD_GATEWAY, Json(body));
}
};
match &delivery_outcome {
crate::state::DeliveryOutcome::Accepted => {
finalize_successful_delivery(&state, rollback).await;
}
crate::state::DeliveryOutcome::Rejected(reason) => {
rollback_failed_delivery(&state, &effects, rollback).await;
let method = effects.iter().find_map(|effect| match effect {
crate::daemon_protocol::Effect::SendDelivered { method, .. } => {
Some(method.clone())
}
_ => None,
});
let mut body = json!({ "error": reason });
if let Some(method) = method {
body["method"] = json!(method);
}
return (StatusCode::BAD_GATEWAY, Json(body));
}
crate::state::DeliveryOutcome::Ambiguous(_) | crate::state::DeliveryOutcome::Queued(_) => {
finalize_successful_delivery(&state, rollback).await;
}
}
if let Some((method, msg_id)) = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::SendDelivered { method, msg_id, .. } => {
Some((method.clone(), *msg_id))
}
_ => None,
}) {
let status = if matches!(
delivery_outcome,
crate::state::DeliveryOutcome::Ambiguous(_)
) {
"unknown"
} else if matches!(delivery_outcome, crate::state::DeliveryOutcome::Queued(_)) {
"queued"
} else if method == "http" {
"accepted"
} else {
"delivered"
};
let mut payload = json!({
"status": status,
"method": method,
"msg_id": msg_id,
});
if let crate::state::DeliveryOutcome::Queued(reason)
| crate::state::DeliveryOutcome::Ambiguous(reason) = &delivery_outcome
{
payload["reason"] = json!(reason);
payload["guidance"] = json!(UNCONFIRMED_DELIVERY_GUIDANCE);
}
if let Some(owed) = owed_msg_id {
let warning = format!(
"you still owe '{to}' a reply to msg #{owed}; a plain tell does not clear it — \
use: ouija reply {to} {owed} --message-file /path/to/reply.txt"
);
tracing::warn!(%from, %to, owed_msg_id = owed, "plain tell sent while a reply is owed");
payload["warning"] = json!(warning);
}
(StatusCode::OK, Json(payload))
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "unexpected send result" })),
)
}
}
struct FailedSendRollback {
sender_id: String,
pending_reply_before_send: Option<crate::daemon_protocol::PendingReplyEntry>,
pending_reply_after_send: Option<Option<crate::daemon_protocol::PendingReplyEntry>>,
sender_reminder: Option<Option<String>>,
sender_reminder_after_send: Option<Option<String>>,
sender_state_reserved: bool,
done: bool,
}
impl FailedSendRollback {
fn capture(
proto: &crate::daemon_protocol::DaemonState,
sender_id: &str,
responds_to: Option<u64>,
done: bool,
) -> Self {
let pending_reply_before_send = responds_to.and_then(|msg_id| {
proto
.pending_replies
.get(sender_id)
.and_then(|pending| pending.iter().find(|entry| entry.msg_id == msg_id).cloned())
});
Self {
sender_id: sender_id.to_string(),
pending_reply_before_send,
pending_reply_after_send: None,
sender_reminder: done.then(|| {
proto
.sessions
.get(sender_id)
.and_then(|session| session.metadata.reminder.clone())
}),
sender_reminder_after_send: None,
sender_state_reserved: false,
done,
}
}
fn capture_after_send(&mut self, proto: &crate::daemon_protocol::DaemonState) {
if let Some(before) = &self.pending_reply_before_send {
self.pending_reply_after_send = Some(
proto
.pending_replies
.get(&self.sender_id)
.and_then(|pending| {
pending
.iter()
.find(|entry| entry.msg_id == before.msg_id)
.cloned()
}),
);
}
if self.done {
self.sender_reminder_after_send = Some(
proto
.sessions
.get(&self.sender_id)
.and_then(|session| session.metadata.reminder.clone()),
);
}
}
fn reserve_sender_state_after_send(&mut self, proto: &mut crate::daemon_protocol::DaemonState) {
if !self.done {
return;
}
if let Some(entry) = self.pending_reply_before_send.clone()
&& self.pending_reply_after_send == Some(None)
{
proto
.pending_replies
.entry(self.sender_id.clone())
.or_default()
.push(entry);
self.sender_state_reserved = true;
}
if self.sender_reminder.is_some()
&& self.sender_reminder_after_send == Some(None)
&& let Some(session) = proto.sessions.get_mut(&self.sender_id)
{
session.metadata.reminder = self.sender_reminder.clone().flatten();
self.sender_state_reserved = true;
}
}
fn sender_state_reserved(&self) -> bool {
self.sender_state_reserved
}
}
async fn rollback_failed_delivery(
state: &SharedState,
effects: &[crate::daemon_protocol::Effect],
rollback: FailedSendRollback,
) {
clear_pending_reply_for_failed_delivery(state, effects).await;
if rollback.sender_state_reserved() {
return;
}
let mut proto = state.protocol.write().await;
if let Some(entry) = rollback.pending_reply_before_send {
let current_entry = proto
.pending_replies
.get(&rollback.sender_id)
.and_then(|pending| {
pending
.iter()
.find(|pending| pending.msg_id == entry.msg_id)
.cloned()
});
if rollback.pending_reply_after_send.as_ref() == Some(¤t_entry) {
let pending = proto
.pending_replies
.entry(rollback.sender_id.clone())
.or_default();
if let Some(existing) = pending
.iter_mut()
.find(|pending| pending.msg_id == entry.msg_id)
{
*existing = entry;
} else {
pending.push(entry);
}
}
}
if rollback.done {
let current_reminder = proto
.sessions
.get(&rollback.sender_id)
.and_then(|session| session.metadata.reminder.clone());
if rollback.sender_reminder_after_send.as_ref() == Some(¤t_reminder)
&& let Some(session) = proto.sessions.get_mut(&rollback.sender_id)
{
session.metadata.reminder = rollback.sender_reminder.flatten();
}
}
}
async fn finalize_successful_delivery(state: &SharedState, rollback: FailedSendRollback) {
if !rollback.done {
return;
}
let mut proto = state.protocol.write().await;
if let Some(entry) = rollback.pending_reply_before_send {
if let Some(pending) = proto.pending_replies.get_mut(&rollback.sender_id) {
pending.retain(|pending| pending.msg_id != entry.msg_id);
if pending.is_empty() {
proto.pending_replies.remove(&rollback.sender_id);
}
}
}
if rollback.sender_reminder.is_some()
&& let Some(session) = proto.sessions.get_mut(&rollback.sender_id)
{
session.metadata.reminder = None;
}
}
async fn clear_pending_reply_for_failed_delivery(
state: &SharedState,
effects: &[crate::daemon_protocol::Effect],
) {
let Some((to, msg_id, from)) = effects.iter().find_map(|effect| match effect {
crate::daemon_protocol::Effect::SendDelivered {
from, to, msg_id, ..
} => Some((to.clone(), *msg_id, from.clone())),
_ => None,
}) else {
return;
};
let mut proto = state.protocol.write().await;
let Some(pending) = proto.pending_replies.get_mut(&to) else {
return;
};
pending.retain(|entry| entry.msg_id != msg_id || entry.from != from);
if pending.is_empty() {
proto.pending_replies.remove(&to);
}
}
async fn execute_send_effects_for_api(
state: &SharedState,
effects: &[crate::daemon_protocol::Effect],
) -> anyhow::Result<crate::state::DeliveryOutcome> {
use crate::daemon_protocol::Effect;
let recorded_method = effects.iter().find_map(|effect| match effect {
Effect::SendDelivered { method, .. } => Some(method.as_str()),
_ => None,
});
let recorded_http_delivery = effects.iter().find_map(|effect| match effect {
Effect::SendDelivered { http_delivery, .. } => http_delivery.as_ref(),
_ => None,
});
let recorded_msg_id = effects.iter().find_map(|effect| match effect {
Effect::SendDelivered { msg_id, .. } => Some(*msg_id),
_ => None,
});
let logged = crate::state::logged_message_ref(state, effects);
let mut outcome = crate::state::DeliveryOutcome::Accepted;
for effect in effects {
match effect {
Effect::Broadcast(msg) => {
crate::transport::broadcast(state, msg).await;
}
Effect::InjectMessage {
session_id,
pane,
message,
vim_mode,
delivery_method,
pending_reply_msg_id,
..
} => {
outcome = combine_delivery_outcome(
outcome,
crate::state::deliver_inject_message_effect(
state,
crate::state::InjectDeliveryRequest {
session_id,
pane,
message,
vim_mode: *vim_mode,
delivery_method: delivery_method.as_deref(),
recorded_method,
msg_id: recorded_msg_id.or(*pending_reply_msg_id),
logged: logged.clone(),
},
)
.await,
);
}
Effect::DeliverHttpMessage {
session_id: _,
message,
http_delivery,
..
} => {
let delivery = Some(http_delivery)
.or(recorded_http_delivery)
.ok_or_else(|| {
anyhow::anyhow!(
"http delivery skipped: no recorded backend_session_id on send"
)
})?;
outcome = combine_delivery_outcome(
outcome,
deliver_http_message_outcome(state, delivery, message).await,
);
}
Effect::SendToHuman { npub, message } => {
let _ = crate::nostr_transport::send_plain_dm(state, npub, message).await;
}
Effect::LogMessage {
from,
to,
message,
delivered,
transport,
} => {
let delivered = if matches!(
outcome,
crate::state::DeliveryOutcome::Rejected(_)
| crate::state::DeliveryOutcome::Ambiguous(_)
| crate::state::DeliveryOutcome::Queued(_)
) {
false
} else {
*delivered
};
let id = logged
.as_ref()
.map_or_else(|| state.next_log_id(), |logged| logged.id);
state
.log_message_with_id(
id,
from.clone(),
to.clone(),
message.clone(),
delivered,
transport,
)
.await;
}
Effect::SendDelivered { .. } | Effect::SendFailed { .. } => {}
_ => {
tracing::debug!(?effect, "unexpected send effect in API executor");
}
}
}
Ok(outcome)
}
fn combine_delivery_outcome(
left: crate::state::DeliveryOutcome,
right: crate::state::DeliveryOutcome,
) -> crate::state::DeliveryOutcome {
match (left, right) {
(crate::state::DeliveryOutcome::Rejected(reason), _)
| (_, crate::state::DeliveryOutcome::Rejected(reason)) => {
crate::state::DeliveryOutcome::Rejected(reason)
}
(crate::state::DeliveryOutcome::Ambiguous(reason), _)
| (_, crate::state::DeliveryOutcome::Ambiguous(reason)) => {
crate::state::DeliveryOutcome::Ambiguous(reason)
}
(crate::state::DeliveryOutcome::Queued(reason), _)
| (_, crate::state::DeliveryOutcome::Queued(reason)) => {
crate::state::DeliveryOutcome::Queued(reason)
}
(crate::state::DeliveryOutcome::Accepted, crate::state::DeliveryOutcome::Accepted) => {
crate::state::DeliveryOutcome::Accepted
}
}
}
async fn deliver_http_message_outcome(
state: &SharedState,
delivery: &crate::daemon_protocol::HttpDeliverySnapshot,
message: &str,
) -> crate::state::DeliveryOutcome {
tmux::deliver_via_http(
state,
&delivery.backend_session_id,
delivery.project_dir.as_deref(),
message,
delivery.model.as_deref(),
delivery.effort.as_deref(),
)
.await
.map(|()| crate::state::DeliveryOutcome::Accepted)
.unwrap_or_else(|decision| match decision {
crate::nostr_transport::PromptAsyncFallbackDecision::Ambiguous => {
crate::state::DeliveryOutcome::Ambiguous(format!(
"prompt_async request failed: {decision:?}"
))
}
crate::nostr_transport::PromptAsyncFallbackDecision::DefiniteNonAcceptance => {
crate::state::DeliveryOutcome::Rejected(format!(
"prompt_async request failed: {decision:?}"
))
}
})
}
#[derive(Debug, Deserialize)]
pub struct RenameBody {
old_id: String,
new_id: String,
#[serde(default)]
sender_ctx: Option<crate::daemon_protocol::SenderContext>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LocalClaimRequest {
pub requested_id: String,
pub caller: crate::state::LocalClaimEvidence,
}
pub async fn claim_local_identity(
State(state): State<SharedState>,
Json(body): Json<LocalClaimRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
match state
.claim_local_identity(&body.requested_id, &body.caller)
.await
{
crate::state::LocalClaimOutcome::Claimed(owner) => (
StatusCode::OK,
Json(json!({
"outcome": "claimed",
"session_id": owner.session_id,
"session_incarnation": owner.incarnation.to_string(),
})),
),
crate::state::LocalClaimOutcome::Current(owner) => (
StatusCode::OK,
Json(json!({
"outcome": "current",
"session_id": owner.session_id,
"session_incarnation": owner.incarnation.to_string(),
})),
),
crate::state::LocalClaimOutcome::Recovered(owner) => (
StatusCode::OK,
Json(json!({
"outcome": "recovered",
"session_id": owner.session_id,
"session_incarnation": owner.incarnation.to_string(),
})),
),
crate::state::LocalClaimOutcome::InvalidId {
requested,
canonical,
} => (
StatusCode::BAD_REQUEST,
Json(json!({
"outcome": "invalid_id",
"requested_id": requested,
"canonical_id": canonical,
"error": "requested ID must already be in canonical Ouija form",
})),
),
crate::state::LocalClaimOutcome::DestinationLive { id } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "destination_live",
"session_id": id,
"error": "requested ID is already live",
})),
),
crate::state::LocalClaimOutcome::AlreadyRegistered { id } => (
StatusCode::FORBIDDEN,
Json(json!({
"outcome": "already_registered",
"session_id": id,
"error": "backend identity already owns a different Local public ID; claim is not rename",
})),
),
crate::state::LocalClaimOutcome::EvidenceConflict(reason) => (
StatusCode::BAD_REQUEST,
Json(json!({
"outcome": "evidence_conflict",
"error": reason,
})),
),
crate::state::LocalClaimOutcome::ResourceConflict(reason) => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "resource_conflict",
"error": reason,
})),
),
crate::state::LocalClaimOutcome::PersistenceFailed(reason) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"outcome": "persistence_failed",
"error": reason,
})),
),
}
}
fn dormant_backend_fingerprint(backend: &str, backend_session_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(backend.as_bytes());
hasher.update([0]);
hasher.update(backend_session_id.as_bytes());
format!("sha256:{:x}", hasher.finalize())
}
fn dormant_identity_json(dormant: &crate::daemon_protocol::DormantSession) -> serde_json::Value {
let metadata = &dormant.metadata;
json!({
"id": dormant.id,
"prior_incarnation": dormant.prior_owner.incarnation.to_string(),
"backend": metadata.backend,
"backend_session_fingerprint": dormant_backend_fingerprint(
metadata.backend.as_deref().unwrap_or_default(),
metadata.backend_session_id.as_deref().unwrap_or_default(),
),
"project_dir": metadata.project_dir,
"canonical_project_identity": dormant.canonical_project_identity,
"dormant_at": dormant.dormant_at,
"source": dormant.source,
"fresh_context_after_active_secs": metadata.fresh_context_after_active_secs,
"active_context_accumulated_secs": metadata.active_context_accumulated_secs,
"active_context_segment_open": false,
"active_context_restart_due": metadata.active_context_restart_due,
"routable": false,
})
}
pub async fn dormant_identities(
State(state): State<SharedState>,
) -> (StatusCode, Json<serde_json::Value>) {
let protocol = state.protocol.read().await;
let rows = protocol
.dormant_sessions
.values()
.map(dormant_identity_json)
.collect::<Vec<_>>();
(StatusCode::OK, Json(json!({ "dormant_sessions": rows })))
}
pub async fn dormant_identity(
State(state): State<SharedState>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let protocol = state.protocol.read().await;
match protocol.dormant_sessions.get(&id) {
Some(dormant) => (
StatusCode::OK,
Json(json!({ "dormant_session": dormant_identity_json(dormant) })),
),
None => (
StatusCode::NOT_FOUND,
Json(json!({
"outcome": "dormant_not_found",
"session_id": id,
"error": "dormant identity not found",
})),
),
}
}
pub async fn resume_dormant_identity(
State(state): State<SharedState>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let disposition = match state.reserve_dormant_resume(&id).await {
Ok(disposition) => disposition,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "outcome": "persistence_failed", "error": error.to_string() })),
);
}
};
let (owner, dormant) = match disposition {
crate::daemon_protocol::DormantResumeDisposition::Reserved { owner, dormant } => {
(owner, dormant)
}
crate::daemon_protocol::DormantResumeDisposition::NotFound => {
return (
StatusCode::NOT_FOUND,
Json(json!({ "outcome": "dormant_not_found", "session": id })),
);
}
crate::daemon_protocol::DormantResumeDisposition::Live(owner) => {
return (
StatusCode::CONFLICT,
Json(json!({
"outcome": "session_live",
"session": id,
"incarnation": owner.incarnation.to_string(),
})),
);
}
crate::daemon_protocol::DormantResumeDisposition::InProgress(owner) => {
return (
StatusCode::ACCEPTED,
Json(json!({
"status": "start_in_progress",
"session": id,
"incarnation": owner.incarnation.to_string(),
})),
);
}
crate::daemon_protocol::DormantResumeDisposition::Invalid(error) => {
return (
StatusCode::CONFLICT,
Json(json!({ "outcome": "dormant_invalid", "session": id, "error": error })),
);
}
};
let ticket = owner.clone();
state.start_outcomes.record(
&ticket,
crate::start_outcome::StartOutcomeStatus::InProgress,
"resuming dormant session".into(),
);
let state2 = state.clone();
tokio::spawn(async move {
let (detail, outcome) =
crate::nostr_transport::resume_dormant_session(&state2, &owner, &dormant).await;
state2
.start_outcomes
.record(&owner, start_outcome_for_restart(outcome), detail);
});
(
StatusCode::ACCEPTED,
Json(json!({
"session": id,
"status": "starting",
"incarnation": ticket.incarnation.to_string(),
})),
)
}
pub async fn rename(
State(state): State<SharedState>,
Json(body): Json<RenameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
if let Some(ctx) = &body.sender_ctx {
let proto = state.protocol.read().await;
if let Err(reason) =
crate::daemon_protocol::validate_sender_claim(&proto, &body.old_id, ctx)
{
return (StatusCode::FORBIDDEN, Json(json!({ "error": reason })));
}
} else {
tracing::warn!(
old_id = %body.old_id,
new_id = %body.new_id,
"rename accepted without sender context (legacy compatibility); \
Local session claim unverified"
);
}
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::Rename {
old_id: body.old_id.clone(),
new_id: body.new_id.clone(),
})
.await;
if effects
.iter()
.any(|e| matches!(e, crate::daemon_protocol::Effect::RenameOk { .. }))
{
(
StatusCode::OK,
Json(json!({ "renamed": body.old_id, "to": body.new_id })),
)
} else {
let failure = effects.iter().find_map(|e| match e {
crate::daemon_protocol::Effect::RenameFailed { kind, reason } => {
Some((*kind, reason.clone()))
}
_ => None,
});
match failure {
Some((crate::daemon_protocol::RenameFailureKind::InvalidDestination, reason)) => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": reason })))
}
Some((crate::daemon_protocol::RenameFailureKind::SourceMissing, reason)) => {
(StatusCode::NOT_FOUND, Json(json!({ "error": reason })))
}
Some((crate::daemon_protocol::RenameFailureKind::SourceNotLocal, reason)) => {
(StatusCode::FORBIDDEN, Json(json!({ "error": reason })))
}
Some((_, reason)) => (StatusCode::CONFLICT, Json(json!({ "error": reason }))),
None => (
StatusCode::NOT_FOUND,
Json(json!({
"error": format!("session '{}' not found", body.old_id),
})),
),
}
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveBody {
id: String,
#[serde(default)]
keep_worktree: Option<bool>,
}
pub async fn remove(
State(state): State<SharedState>,
Json(body): Json<RemoveBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let effects = match state
.remove_session(body.id.clone(), body.keep_worktree.unwrap_or(false))
.await
{
crate::state::SessionRemoveOutcome::Applied(effects) => effects,
crate::state::SessionRemoveOutcome::PersistenceFailed => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"outcome": "persistence_failed",
"error": "the dormant identity was preserved because durable persistence failed",
})),
);
}
};
if effects
.iter()
.any(|e| matches!(e, crate::daemon_protocol::Effect::RemoveOk { .. }))
{
(StatusCode::OK, Json(json!({ "removed": body.id })))
} else if effects.iter().any(
|e| matches!(e, crate::daemon_protocol::Effect::DormantForgotten { id } if id == &body.id),
) {
(
StatusCode::OK,
Json(json!({
"forgotten_dormant": body.id,
"worktree_preserved": true,
})),
)
} else {
let reason = effects
.iter()
.find_map(|e| match e {
crate::daemon_protocol::Effect::RemoveFailed { reason, .. } => Some(reason.clone()),
_ => None,
})
.unwrap_or_else(|| format!("session '{}' not found", body.id));
(StatusCode::NOT_FOUND, Json(json!({ "error": reason })))
}
}
#[derive(Debug, Deserialize)]
pub struct SessionUpdateBody {
id: String,
networked: Option<bool>,
role: Option<String>,
project_dir: Option<String>,
bulletin: Option<String>,
}
pub async fn update_session(
State(state): State<SharedState>,
Json(body): Json<SessionUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
{
let proto = state.protocol.read().await;
let Some(session) = proto.sessions.get(&body.id) else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("session '{}' not found", body.id) })),
);
};
if matches!(session.origin, crate::daemon_protocol::Origin::Remote(_)) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "cannot update remote session" })),
);
}
}
state
.apply_and_execute(crate::daemon_protocol::Event::UpdateMetadata {
id: body.id.clone(),
role: body.role,
bulletin: body.bulletin,
project_dir: body.project_dir,
networked: body.networked,
})
.await;
let proto = state.protocol.read().await;
let response = if let Some(s) = proto.sessions.get(&body.id) {
json!({
"updated": s.id,
"networked": s.metadata.networked,
"role": s.metadata.role,
"bulletin": s.metadata.bulletin,
"project_dir": s.metadata.project_dir,
})
} else {
json!({ "updated": body.id })
};
(StatusCode::OK, Json(response))
}
#[derive(Debug, Deserialize)]
pub struct InjectBody {
pane: String,
message: String,
#[serde(default)]
vim_mode: bool,
}
struct ForcePaneInjectTarget {
session_id: Option<String>,
inject_config: crate::backend::InjectConfig,
tui_pattern: Option<String>,
}
async fn resolve_force_pane_inject_target(
state: &crate::state::AppState,
pane: &str,
detected_backend: Option<String>,
) -> ForcePaneInjectTarget {
let registered = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.find(|s| s.pane.as_deref() == Some(pane))
.map(|s| (s.id.clone(), s.metadata.backend.clone()))
};
let (session_id, registered_backend_name) = match registered {
Some((session_id, backend_name)) => (Some(session_id), backend_name),
None => (None, None),
};
let backend_name = match registered_backend_name {
Some(name) => Some(name),
None => match detected_backend {
Some(name) => Some(name),
None => state.detect_backend_in_pane(pane).await,
},
};
let backend = state.backend_or_default(backend_name.as_deref()).await;
ForcePaneInjectTarget {
session_id,
inject_config: backend.inject_config(),
tui_pattern: backend.tui_ready_pattern().map(String::from),
}
}
pub async fn inject(
State(state): State<SharedState>,
Json(body): Json<InjectBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let target = resolve_force_pane_inject_target(&state, &body.pane, None).await;
if let Some(session_id) = target.session_id.as_deref() {
tracing::debug!(session = %session_id, pane = %body.pane, "force-pane inject resolved registered session backend");
}
match tmux::locked_inject_raw_tmux_with_config_and_evidence(
&state,
&body.pane,
&body.message,
body.vim_mode,
target.inject_config,
target.tui_pattern,
None,
)
.await
{
Ok(verification) => (StatusCode::OK, Json(inject_response_body(&verification))),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
fn inject_response_body(verification: &crate::tmux::InjectVerification) -> serde_json::Value {
match verification {
crate::tmux::InjectVerification::Confirmed => {
json!({ "status": "injected", "delivery": "tmux" })
}
crate::tmux::InjectVerification::Unconfirmed(reason) => json!({
"status": "unknown",
"delivery": "tmux",
"reason": reason,
"guidance": UNCONFIRMED_DELIVERY_GUIDANCE,
}),
}
}
const UNCONFIRMED_DELIVERY_GUIDANCE: &str = "the paste was accepted but the text was not observed in the pane; this is usually a \
rendering miss, not a lost message. Do not re-send: wait for the recipient's reply, and \
ask a human if it never comes.";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompactBody {
#[serde(default)]
pub continuation: Option<String>,
}
pub async fn compact(
State(state): State<SharedState>,
axum::extract::Path(session_id): axum::extract::Path<String>,
Json(body): Json<CompactBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = compact_inner(&state, session_id, body).await;
(status, Json(value))
}
fn compact_success_body(continuation_delivered: bool, error: Option<String>) -> serde_json::Value {
let mut body = json!({
"status": "ok",
"compacted": true,
"continuation_delivered": continuation_delivered,
});
if let Some(err) = error {
body["error"] = json!(err);
}
body
}
async fn compact_inner(
state: &std::sync::Arc<crate::state::AppState>,
session_id: String,
body: CompactBody,
) -> (StatusCode, serde_json::Value) {
let continuation = body.continuation.and_then(|s| {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let lookup = {
let proto = state.protocol.read().await;
match proto.sessions.get(&session_id) {
Some(s) => SessionLookup {
pane: s.pane.clone(),
origin: s.origin.clone(),
backend_session_id: s.metadata.backend_session_id.clone(),
project_dir: s.metadata.project_dir.clone(),
backend_name: s.metadata.backend.clone(),
model: s.metadata.model.clone(),
effort: s.metadata.effort.clone(),
strong_opencode_binding: s.metadata.is_strong_opencode_binding(),
},
None => {
return (
StatusCode::NOT_FOUND,
json!({"error": format!("session '{}' not found", session_id)}),
);
}
}
};
if !matches!(lookup.origin, crate::daemon_protocol::Origin::Local) {
return (
StatusCode::BAD_REQUEST,
json!({"error": "compact is only supported for local sessions"}),
);
}
let backend = state
.backend_or_default(lookup.backend_name.as_deref())
.await;
match backend.delivery_mode() {
crate::backend::DeliveryMode::TuiInjection => {
let Some(pane) = lookup.pane else {
return (
StatusCode::BAD_REQUEST,
json!({"error": "session has no pane (remote sessions cannot be compacted)"}),
);
};
let Some(compact_cmd) = backend.compact_command().map(str::to_string) else {
return (
StatusCode::BAD_REQUEST,
json!({"error": format!("backend '{}' does not support compact", backend.name())}),
);
};
let parked = if let Some(ref text) = continuation {
let acquired = state
.try_set_pending_compact_continuation(&session_id, text.clone())
.await;
if !acquired {
return (
StatusCode::CONFLICT,
json!({"error": "another compact continuation is already pending for this session"}),
);
}
true
} else {
false
};
if let Err(e) =
tmux::locked_inject(state, &session_id, &pane, &compact_cmd, false).await
{
if parked {
if state
.drain_agent_compact_continuation(&session_id)
.await
.is_none()
{
tracing::warn!(
session = %session_id,
"rollback drain returned None after successful try-set; slot may be orphaned",
);
}
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
json!({"error": e.to_string()}),
);
}
(StatusCode::OK, compact_success_body(false, None))
}
crate::backend::DeliveryMode::HttpApi { .. } => {
let Some(backend_session_id) = lookup.backend_session_id else {
return (
StatusCode::BAD_REQUEST,
json!({
"error": format!(
"session has no backend_session_id (backend '{}' not attached)",
backend.name()
)
}),
);
};
if !lookup.strong_opencode_binding {
return (
StatusCode::BAD_REQUEST,
json!({
"error": "opencode compact requires a strong managed backend binding; weak/adopted sessions cannot safely use HTTP-only summarize"
}),
);
}
let Some(_compact_guard) =
state.try_acquire_compact_in_progress(&format!("opencode:{backend_session_id}"))
else {
return (
StatusCode::CONFLICT,
json!({"error": "another compact operation is already in progress for this session"}),
);
};
let Some((provider_id, model_id)) = resolve_opencode_compact_model(
state,
lookup.model.as_deref(),
lookup.project_dir.as_deref(),
)
.await
else {
return (
StatusCode::BAD_REQUEST,
json!({
"error": "cannot resolve provider/model for summarize: session has no parseable `model` \
(expected \"providerID/modelID\") and /config is unreachable \
or has no top-level `model`. Configure the session with \
`ouija spawn-session --model <p/m>` or set OpenCode's default model."
}),
);
};
let port = state.opencode_serve_port();
let summarize_url =
format!("http://127.0.0.1:{port}/session/{backend_session_id}/summarize");
let summarize_body = json!({
"providerID": provider_id,
"modelID": model_id,
});
let mut summarize_req = state
.http_client
.post(&summarize_url)
.json(&summarize_body)
.timeout(std::time::Duration::from_secs(300));
if let Some(dir) = lookup.project_dir.as_deref() {
summarize_req = summarize_req.header("x-opencode-directory", dir);
}
match summarize_req.send().await {
Ok(r) if r.status().is_success() => {}
Ok(r) => {
let status = r.status();
let text = r.text().await.unwrap_or_default();
return (
StatusCode::BAD_GATEWAY,
json!({"error": format!("opencode /summarize returned {status}: {text}")}),
);
}
Err(e) => {
return (
StatusCode::BAD_GATEWAY,
json!({"error": format!("opencode /summarize request failed: {e}")}),
);
}
}
let continuation_delivered = if let Some(continuation) = continuation {
match tmux::deliver_via_http(
state,
&backend_session_id,
lookup.project_dir.as_deref(),
&continuation,
lookup.model.as_deref(),
lookup.effort.as_deref(),
)
.await
{
Ok(()) => true,
Err(decision) => {
tracing::warn!(
session = %session_id,
?decision,
"continuation delivery failed after successful summarize"
);
return (
StatusCode::OK,
compact_success_body(
false,
Some(format!(
"opencode continuation delivery failed: {decision:?}"
)),
),
);
}
}
} else {
false
};
(
StatusCode::OK,
compact_success_body(continuation_delivered, None),
)
}
}
}
struct SessionLookup {
pane: Option<String>,
origin: crate::daemon_protocol::Origin,
backend_session_id: Option<String>,
project_dir: Option<String>,
backend_name: Option<String>,
model: Option<String>,
effort: Option<String>,
strong_opencode_binding: bool,
}
pub async fn nodes(State(state): State<SharedState>) -> Json<serde_json::Value> {
let connected = state.nodes.read().await;
let self_entry = json!({
"name": state.config.name,
"npub": state.config.npub,
"status": "self",
"transport": null,
"since": null,
});
let mut entries: Vec<serde_json::Value> = vec![self_entry];
for p in connected.values() {
entries.push(json!({
"name": p.name,
"npub": p.daemon_id,
"status": "connected",
"transport": null,
"since": p.connected_at.format("%H:%M:%S").to_string(),
}));
}
let connected_names: std::collections::HashSet<&str> =
connected.values().map(|p| p.name.as_str()).collect();
if let Ok(conns) = crate::persistence::load_connections(&state.config.data_dir) {
for conn in &conns {
if let Some(name) = &conn.node_name
&& connected_names.contains(name.as_str())
{
continue;
}
entries.push(json!({
"name": conn.node_name,
"npub": conn.daemon_npub,
"status": "saved",
"transport": "nostr",
"since": conn.connected_at.format("%Y-%m-%d").to_string(),
}));
}
}
Json(json!({ "nodes": entries }))
}
#[derive(Debug, Deserialize)]
pub struct DisconnectNodeBody {
daemon_id: String,
}
pub async fn disconnect_node(
State(state): State<SharedState>,
Json(body): Json<DisconnectNodeBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let removed = state.disconnect_node(&body.daemon_id).await;
(
StatusCode::OK,
Json(json!({
"disconnected": body.daemon_id,
"sessions_removed": removed,
})),
)
}
pub async fn get_settings(State(state): State<SharedState>) -> Json<serde_json::Value> {
let settings = state.settings.read().await;
Json(json!({
"default_backend": settings.default_backend,
"auto_register": settings.auto_register,
"claude_permission_mode": settings.claude_permission_mode,
"codex_home": settings.codex_home,
"codex_model_routes": settings.codex_model_routes,
}))
}
#[derive(Debug, Deserialize)]
pub struct SettingsUpdateBody {
default_backend: Option<String>,
auto_register: Option<bool>,
projects_dir: Option<String>,
idle_timeout_secs: Option<u64>,
reaper_interval_secs: Option<u64>,
max_local_sessions: Option<u64>,
claude_permission_mode: Option<String>,
codex_home: Option<String>,
codex_model_routes:
Option<std::collections::HashMap<String, crate::persistence::CodexModelRoute>>,
}
pub async fn update_settings(
State(state): State<SharedState>,
Json(body): Json<SettingsUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut settings = state.settings.write().await;
let mut updated = settings.clone();
if let Some(v) = body.default_backend {
if let Err(message) = state.backends.get_required(&v) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": message })));
}
updated.default_backend = v;
}
if let Some(v) = body.auto_register {
updated.auto_register = v;
}
let projects_dir_changed = body.projects_dir.is_some();
if let Some(v) = body.projects_dir {
updated.projects_dir = Some(v);
}
if let Some(v) = body.idle_timeout_secs {
updated.idle_timeout_secs = v;
}
if let Some(v) = body.reaper_interval_secs {
updated.reaper_interval_secs = v;
}
if let Some(v) = body.max_local_sessions {
updated.max_local_sessions = v;
}
if let Some(v) = body.claude_permission_mode {
let trimmed = v.trim();
updated.claude_permission_mode = if trimmed.is_empty() || trimmed == "default" {
None
} else {
Some(trimmed.to_string())
};
}
let codex_home_to_install = if let Some(v) = body.codex_home {
let trimmed = v.trim();
updated.codex_home = if trimmed.is_empty() || trimmed == "default" {
None
} else {
Some(trimmed.to_string())
};
updated.codex_home.clone()
} else {
None
};
let mut codex_route_homes_to_install = Vec::new();
if let Some(routes) = body.codex_model_routes {
updated.codex_model_routes = routes;
codex_route_homes_to_install.extend(
updated
.codex_model_routes
.values()
.filter_map(|route| route.codex_home.clone()),
);
}
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &updated) {
tracing::warn!("failed to save settings: {e}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
*settings = updated;
drop(settings);
if projects_dir_changed {
let s = state.clone();
tokio::spawn(async move {
crate::project_index::refresh_index(&s).await;
});
}
if let Some(home) = codex_home_to_install {
crate::backend::codex::install_configured_home(Some(&home));
}
for home in codex_route_homes_to_install {
crate::backend::codex::install_configured_home(Some(&home));
}
let settings = state.settings.read().await;
(
StatusCode::OK,
Json(json!({
"status": "saved",
"settings": {
"default_backend": settings.default_backend,
"auto_register": settings.auto_register,
"projects_dir": settings.projects_dir,
"claude_permission_mode": settings.claude_permission_mode,
"codex_home": settings.codex_home,
"codex_model_routes": settings.codex_model_routes,
}
})),
)
}
pub async fn bulk_update_sessions(
State(state): State<SharedState>,
Json(body): Json<BulkSessionUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut count = 0;
{
let mut proto = state.protocol.write().await;
for session in proto.sessions.values_mut() {
if matches!(session.origin, crate::daemon_protocol::Origin::Local) {
if let Some(v) = body.networked {
if session.metadata.networked != v {
session.metadata.networked = v;
count += 1;
}
}
}
}
}
if count > 0 {
transport::broadcast_local_sessions(&state).await;
}
(StatusCode::OK, Json(json!({ "updated": count })))
}
#[derive(Debug, Deserialize)]
pub struct BulkSessionUpdateBody {
networked: Option<bool>,
}
pub async fn get_relays(State(state): State<SharedState>) -> Json<serde_json::Value> {
let relays = crate::nostr_transport::load_relays(&state.config.data_dir);
Json(json!({ "relays": relays }))
}
#[derive(Debug, Deserialize)]
pub struct RelaysUpdateBody {
relays: Vec<String>,
}
pub async fn update_relays(
State(state): State<SharedState>,
Json(body): Json<RelaysUpdateBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let relays: Vec<String> = body
.relays
.into_iter()
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
if let Err(e) = crate::nostr_transport::save_relays(&state.config.data_dir, &relays) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
(
StatusCode::OK,
Json(json!({ "status": "saved", "relays": relays })),
)
}
pub async fn list_tasks(State(state): State<SharedState>) -> Json<serde_json::Value> {
let tasks = state.scheduled_tasks.read().await;
let mut list: Vec<&scheduler::ScheduledTask> = tasks.values().collect();
list.sort_by_key(|t| &t.created_at);
let entries: Vec<serde_json::Value> = list
.iter()
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"cron": t.cron,
"target_session": t.target_session,
"enabled": t.enabled,
"next_run": t.next_run,
"last_run": t.last_run,
"last_status": t.last_status,
"run_count": t.run_count,
"project_dir": t.project_dir,
"backend": t.backend,
"model": t.model,
"effort": t.effort,
"once": t.once,
"backend_session_id": t.backend_session_id,
"on_fire": t.on_fire,
})
})
.collect();
Json(json!({ "tasks": entries }))
}
#[derive(Debug, Deserialize)]
pub struct CreateTaskBody {
name: String,
cron: String,
target_session: Option<String>,
message: Option<String>,
prompt: Option<String>,
reminder: Option<String>,
project_dir: Option<String>,
backend: Option<String>,
model: Option<String>,
effort: Option<String>,
#[serde(default)]
once: Option<bool>,
#[serde(alias = "claude_session_id")]
backend_session_id: Option<String>,
#[serde(default)]
on_fire: Option<crate::scheduler::OnFire>,
}
pub async fn create_task(
State(state): State<SharedState>,
Json(body): Json<CreateTaskBody>,
) -> (StatusCode, Json<serde_json::Value>) {
if let Err(error) = crate::daemon_protocol::validate_spawn_reminder(body.reminder.as_deref()) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
if let Err(e) = scheduler::validate_cron(&body.cron) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("invalid cron: {e}") })),
);
}
let target_session = normalize_optional_string(body.target_session);
let on_fire = body.on_fire.unwrap_or_default();
if matches!(on_fire, crate::scheduler::OnFire::InjectOnly) && target_session.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "inject_only tasks require an explicit target_session"
})),
);
}
let mut task = scheduler::new_task(
body.name,
body.cron,
target_session,
body.prompt.or(body.message),
body.reminder,
body.once.unwrap_or(false),
body.backend_session_id,
on_fire,
);
task.project_dir = body.project_dir;
task.backend = normalize_optional_string(body.backend);
if let Err(response) = validate_backend_name(&state, task.backend.as_deref()) {
return response;
}
task.model = normalize_optional_string(body.model);
task.effort = normalize_optional_string(body.effort);
let id = task.id.clone();
state.add_task(task).await;
(StatusCode::OK, Json(json!({ "created": id })))
}
#[derive(Debug, Deserialize)]
pub struct TaskIdBody {
id: String,
}
pub async fn delete_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
match state.remove_task(&body.id).await {
Some(_) => (StatusCode::OK, Json(json!({ "deleted": body.id }))),
None => (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
),
}
}
pub async fn enable_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
drop(tasks);
state
.update_task(&body.id, |t| {
t.enabled = true;
t.next_run = scheduler::compute_next_run(&t.cron);
})
.await;
(StatusCode::OK, Json(json!({ "enabled": body.id })))
}
pub async fn disable_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
drop(tasks);
state
.update_task(&body.id, |t| {
t.enabled = false;
t.next_run = None;
})
.await;
(StatusCode::OK, Json(json!({ "disabled": body.id })))
}
pub async fn trigger_task(
State(state): State<SharedState>,
Json(body): Json<TaskIdBody>,
) -> (StatusCode, Json<serde_json::Value>) {
{
let tasks = state.scheduled_tasks.read().await;
if !tasks.contains_key(&body.id) {
return (
StatusCode::NOT_FOUND,
Json(json!({ "error": format!("task '{}' not found", body.id) })),
);
}
}
scheduler::execute_task(&state, &body.id).await;
(StatusCode::OK, Json(json!({ "triggered": body.id })))
}
#[derive(Debug, Deserialize, Default)]
pub struct TaskRunsQuery {
task: Option<String>,
}
pub async fn list_task_runs(
State(state): State<SharedState>,
Query(query): Query<TaskRunsQuery>,
) -> Json<serde_json::Value> {
let runs = state.task_runs.read().await;
let entries: Vec<serde_json::Value> = runs
.iter()
.rev()
.filter(|r| query.task.as_ref().is_none_or(|id| r.task_id == *id))
.take(MAX_TASK_RUNS_RETURNED)
.map(|r| {
json!({
"task_id": r.task_id,
"task_name": r.task_name,
"timestamp": r.timestamp,
"status": r.status,
"error": r.error,
"session_name": r.session_name,
"revived_pane": r.revived_pane,
})
})
.collect();
Json(json!({ "runs": entries }))
}
#[derive(Debug, Deserialize)]
pub struct AddHumanBody {
pub npub: String,
pub name: String,
pub default_session: Option<String>,
}
pub async fn add_human(
State(state): State<SharedState>,
Json(body): Json<AddHumanBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let name = body.name.trim().to_string();
if name.is_empty() || name.contains('/') {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "invalid name" })),
);
}
{
let proto = state.protocol.read().await;
if proto
.sessions
.get(&name)
.is_some_and(|s| !matches!(s.origin, crate::daemon_protocol::Origin::Human(_)))
{
return (
StatusCode::CONFLICT,
Json(json!({ "error": "name conflicts with existing session" })),
);
}
}
let mut settings = state.settings.write().await;
if settings.human_sessions.iter().any(|h| h.name == name) {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "human session already exists" })),
);
}
let human = crate::persistence::HumanSession {
npub: body.npub.clone(),
name: name.clone(),
default_session: body.default_session,
welcomed: false,
};
settings.human_sessions.push(human);
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &settings) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
drop(settings);
{
let mut proto = state.protocol.write().await;
proto.sessions.entry(name.clone()).or_insert_with(|| {
crate::daemon_protocol::SessionEntry {
id: name.clone(),
pane: None,
origin: crate::daemon_protocol::Origin::Human(body.npub.clone()),
metadata: crate::daemon_protocol::SessionMeta {
role: Some("human".to_string()),
networked: false,
..Default::default()
},
..Default::default()
}
});
}
(
StatusCode::OK,
Json(json!({ "status": "added", "name": name })),
)
}
#[derive(Debug, Deserialize)]
pub struct RemoveHumanBody {
pub name: String,
}
pub async fn remove_human(
State(state): State<SharedState>,
Json(body): Json<RemoveHumanBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut settings = state.settings.write().await;
let before = settings.human_sessions.len();
settings.human_sessions.retain(|h| h.name != body.name);
if settings.human_sessions.len() == before {
return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" })));
}
if let Err(e) = crate::persistence::save_settings(&state.config.config_dir, &settings) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": format!("failed to save: {e}") })),
);
}
drop(settings);
{
let mut proto = state.protocol.write().await;
if proto
.sessions
.get(&body.name)
.is_some_and(|s| matches!(s.origin, crate::daemon_protocol::Origin::Human(_)))
{
proto.sessions.remove(&body.name);
}
}
(StatusCode::OK, Json(json!({ "status": "removed" })))
}
pub async fn list_humans(State(state): State<SharedState>) -> Json<serde_json::Value> {
let settings = state.settings.read().await;
let humans: Vec<serde_json::Value> = settings
.human_sessions
.iter()
.map(|h| {
json!({
"name": h.name,
"npub": h.npub,
"default_session": h.default_session,
})
})
.collect();
Json(json!({ "humans": humans }))
}
#[derive(Debug, Deserialize)]
pub struct SessionNameBody {
name: String,
#[serde(default)]
fresh: Option<bool>,
#[serde(default)]
fresh_context_after_active_secs: Option<u64>,
#[serde(default)]
worktree: Option<bool>,
#[serde(default)]
project_dir: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
from: Option<String>,
#[serde(default)]
backend: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
effort: Option<String>,
#[serde(default)]
reminder: Option<String>,
#[serde(default)]
parent_session: Option<String>,
#[serde(default)]
no_parent_session: Option<bool>,
#[serde(default)]
idle_policy: Option<crate::daemon_protocol::IdlePolicy>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
base_branch: Option<String>,
#[serde(default)]
delete_worktree: Option<bool>,
#[serde(default)]
keep_worktree: Option<bool>,
#[serde(default)]
force_reset: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct RestartSessionBody {
#[serde(flatten)]
session: SessionNameBody,
#[serde(default)]
suppress_stored_prompt: bool,
#[serde(default)]
one_shot_prompt: Option<String>,
}
fn restart_drops_destructive_intent(body: &SessionNameBody) -> Option<String> {
let mut dropped: Vec<&str> = Vec::new();
if body.force_reset == Some(true) {
dropped.push("force_reset=true");
}
if body.base_branch.is_some() {
dropped.push("base_branch");
}
if dropped.is_empty() {
return None;
}
Some(format!(
"session '{}' is already registered, routing to restart_session \
which cannot act on {}; destructive intent silently dropped. \
File a ticket for a sync reset endpoint if this is load-bearing.",
body.name,
dropped.join(", ")
))
}
fn validate_start_lifecycle(body: &mut SessionNameBody) -> Result<(), String> {
body.parent_session = normalize_optional_string(body.parent_session.take());
crate::daemon_protocol::validate_spawn_lifecycle(
body.parent_session.as_deref(),
body.no_parent_session.unwrap_or(false),
body.idle_policy.as_ref(),
)?;
crate::daemon_protocol::validate_spawn_reminder(body.reminder.as_deref())
}
fn validate_fresh_context_after_active_secs(seconds: Option<u64>) -> Result<(), String> {
if seconds == Some(0) {
return Err("fresh_context_after_active_secs must be greater than zero".into());
}
Ok(())
}
fn validate_fresh_context_restart_request(body: &SessionNameBody) -> Result<(), String> {
validate_fresh_context_after_active_secs(body.fresh_context_after_active_secs)?;
if body.fresh_context_after_active_secs.is_some() && body.fresh != Some(true) {
return Err("fresh_context_after_active_secs requires fresh=true (CLI: --fresh)".into());
}
Ok(())
}
pub async fn kill_session(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let delete_worktree = body.delete_worktree.unwrap_or(false) && body.keep_worktree != Some(true);
let result = if delete_worktree {
crate::nostr_transport::kill_session_delete_worktree(&state, &body.name).await
} else {
crate::nostr_transport::kill_session(&state, &body.name).await
};
(
StatusCode::OK,
Json(json!({
"result": result.message,
"outcome": result.outcome,
})),
)
}
pub async fn recover_lease(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let result = crate::nostr_transport::recover_lease(&state, &body.name).await;
let code = match result.outcome {
crate::nostr_transport::KillOutcome::Recovered => StatusCode::OK,
_ => StatusCode::CONFLICT,
};
(
code,
Json(json!({
"result": result.message,
"outcome": result.outcome,
})),
)
}
pub async fn prune_stale_sessions(
State(state): State<SharedState>,
Json(body): Json<PruneStaleBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let stale_sessions: Vec<(crate::daemon_protocol::ResourceOwner, String)> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.metadata.worktree_present == Some(false)
})
.filter_map(|s| {
s.metadata
.project_dir
.as_ref()
.map(|d| (s.owner(), d.clone()))
})
.collect()
};
if !body.confirm {
return (
StatusCode::OK,
Json(
json!({ "dry_run": true, "would_prune": stale_sessions.iter().map(|(owner, _)| owner.session_id.clone()).collect::<Vec<_>>() }),
),
);
}
let mut pruned = Vec::new();
let mut errors = Vec::new();
let mut already_gone = Vec::new();
let input_ids: Vec<String> = stale_sessions
.iter()
.map(|(owner, _)| owner.session_id.clone())
.collect();
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::PruneStale {
sessions: stale_sessions,
})
.await;
let pruned_set: std::collections::HashSet<String> = effects
.iter()
.filter_map(|e| match e {
crate::daemon_protocol::Effect::RemoveOk { id } => Some(id.clone()),
_ => None,
})
.collect();
let already_gone_set: std::collections::HashSet<String> = effects
.iter()
.filter_map(|e| match e {
crate::daemon_protocol::Effect::RemoveFailed { id, kind, .. }
if *kind == crate::daemon_protocol::RemoveFailureKind::NotFound =>
{
Some(id.clone())
}
_ => None,
})
.collect();
for id in input_ids {
if pruned_set.contains(&id) {
pruned.push(id);
} else if already_gone_set.contains(&id) {
tracing::debug!("session {} vanished between snapshot and prune", id);
already_gone.push(id);
} else {
tracing::warn!(
"failed to prune session {} (no longer stale or guard tripped)",
id
);
errors.push(id);
}
}
let response = if errors.is_empty() && already_gone.is_empty() {
json!({ "dry_run": false, "pruned": pruned })
} else {
let mut obj = serde_json::Map::new();
obj.insert("dry_run".into(), serde_json::Value::Bool(false));
obj.insert(
"pruned".into(),
serde_json::Value::Array(pruned.into_iter().map(serde_json::Value::String).collect()),
);
if !errors.is_empty() {
obj.insert(
"errors".into(),
serde_json::Value::Array(
errors.into_iter().map(serde_json::Value::String).collect(),
),
);
}
if !already_gone.is_empty() {
obj.insert(
"already_gone".into(),
serde_json::Value::Array(
already_gone
.into_iter()
.map(serde_json::Value::String)
.collect(),
),
);
}
serde_json::Value::Object(obj)
};
(StatusCode::OK, Json(response))
}
#[derive(serde::Deserialize)]
pub struct PruneStaleBody {
#[serde(default)]
confirm: bool,
}
pub async fn start_session(
State(state): State<SharedState>,
Json(body): Json<SessionNameBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let mut body = body;
body.model = normalize_optional_string(body.model);
body.effort = normalize_optional_string(body.effort);
body.backend = normalize_optional_string(body.backend);
if let Err(error) =
validate_fresh_context_after_active_secs(body.fresh_context_after_active_secs)
{
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
if let Err(error) = validate_start_lifecycle(&mut body) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
if let Err(response) = validate_backend_name(&state, body.backend.as_deref()) {
return response;
}
let disposition =
match crate::nostr_transport::reserve_start_for_launch(&state, &body.name).await {
Ok(disposition) => disposition,
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!(
"failed to reserve session '{}' for launch: {error}",
body.name
)
})),
);
}
};
let existing_metadata = match &disposition {
crate::daemon_protocol::StartDisposition::Existing(owner) => state
.protocol
.read()
.await
.sessions
.get(&owner.session_id)
.map(|session| session.metadata.clone()),
_ => None,
};
let (existing_owner, reserved_owner) = match disposition {
crate::daemon_protocol::StartDisposition::Reserved(owner) => (None, Some(owner)),
crate::daemon_protocol::StartDisposition::Existing(owner) => {
if let Err(error) = validate_fresh_context_restart_request(&body) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
match state.claim_existing_start(&owner).await {
Ok(crate::daemon_protocol::LifecycleMutationOutcome::Applied) => {
(Some(owner), None)
}
Ok(_) => {
return (
StatusCode::ACCEPTED,
Json(json!({
"session": body.name,
"status": "start_in_progress",
"incarnation": owner.incarnation.to_string()
})),
);
}
Err(error) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"error": format!(
"failed to claim existing session '{}' for restart: {error}",
body.name
)
})),
);
}
}
}
crate::daemon_protocol::StartDisposition::InProgress(owner) => {
return (
StatusCode::ACCEPTED,
Json(json!({
"session": body.name,
"status": "start_in_progress",
"incarnation": owner.incarnation.to_string()
})),
);
}
};
let ticket_owner = reserved_owner
.clone()
.or_else(|| existing_owner.clone())
.expect("start disposition yields exactly one claimed owner");
let name = body.name.clone();
let inference_backend = state
.backend_or_default(
body.backend.as_deref().or(existing_metadata
.as_ref()
.and_then(|metadata| metadata.backend.as_deref())),
)
.await;
let effective_base_prompt = body.prompt.clone().or_else(|| {
existing_metadata
.as_ref()
.and_then(|metadata| metadata.prompt.clone())
});
let observe_first_turn =
effective_base_prompt.is_some() && inference_backend.name() == "opencode";
let expected_prompt = effective_base_prompt.as_deref().map(|prompt| {
let metadata = crate::daemon_protocol::SessionMeta {
reminder: body.reminder.clone().or_else(|| {
existing_metadata
.as_ref()
.and_then(|metadata| metadata.reminder.clone())
}),
parent_session: body.parent_session.clone().or_else(|| {
existing_metadata
.as_ref()
.and_then(|metadata| metadata.parent_session.clone())
}),
idle_policy: body.idle_policy.clone().or_else(|| {
existing_metadata
.as_ref()
.and_then(|metadata| metadata.idle_policy.clone())
}),
..Default::default()
};
metadata.effective_reminder(&body.name, None).map_or_else(
|| prompt.to_string(),
|reminder| format!("{prompt}\n\n{reminder}"),
)
});
let state2 = state.clone();
if observe_first_turn {
state.start_outcomes.record(
&ticket_owner,
crate::start_outcome::StartOutcomeStatus::InProgress,
"waiting for first assistant turn".into(),
);
}
tokio::spawn(async move {
if let Some(existing_owner) = existing_owner {
tracing::info!(
"session '{}' exists, restarting with fresh context",
body.name
);
if let Some(msg) = restart_drops_destructive_intent(&body) {
tracing::warn!("{msg}");
}
let (result, prompt_msg_id, restart_outcome) =
crate::nostr_transport::restart_session_for_start_with_active_context_policy(
&state2,
&existing_owner,
&body.name,
true, body.fresh_context_after_active_secs,
None,
body.prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
crate::nostr_transport::ParentSessionOverride::from_request(
body.parent_session.as_deref(),
body.no_parent_session.unwrap_or(false),
),
body.idle_policy.clone(),
)
.await;
let (status, detail) = if restart_outcome
== crate::nostr_transport::RestartOutcome::Restarted
&& observe_first_turn
{
let target_owner = current_exact_or_newer_owner(&state2, &existing_owner).await;
observe_opencode_first_turn(
&state2,
&target_owner,
prompt_msg_id,
expected_prompt.as_deref(),
body.model.as_deref(),
)
.await
} else {
(start_outcome_for_restart(restart_outcome), result.clone())
};
state2
.start_outcomes
.record(&existing_owner, status, detail);
tracing::info!(
"async session restart complete: {}, result: {result}",
body.name
);
return;
}
let (result, prompt_msg_id) =
crate::nostr_transport::start_session_with_active_context_policy(
&state2,
&body.name,
body.worktree,
body.project_dir.as_deref(),
body.prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
body.parent_session.as_deref(),
body.idle_policy.clone(),
body.branch.as_deref(),
body.base_branch.as_deref(),
body.force_reset.unwrap_or(false),
body.fresh_context_after_active_secs,
reserved_owner.clone(),
)
.await;
if let Some(owner) = reserved_owner.as_ref() {
let launch_status = classify_completed_start(&state2, owner).await;
let (status, detail) = if launch_status
== crate::start_outcome::StartOutcomeStatus::Started
&& observe_first_turn
{
observe_opencode_first_turn(
&state2,
owner,
prompt_msg_id,
expected_prompt.as_deref(),
body.model.as_deref(),
)
.await
} else {
(launch_status, result.clone())
};
state2.start_outcomes.record(owner, status, detail);
}
tracing::info!(
"async session start complete: {}, result: {result}",
body.name
);
});
(
StatusCode::ACCEPTED,
Json(json!({
"session": name,
"status": "starting",
"incarnation": ticket_owner.incarnation.to_string()
})),
)
}
fn start_outcome_for_restart(
outcome: crate::nostr_transport::RestartOutcome,
) -> crate::start_outcome::StartOutcomeStatus {
match outcome {
crate::nostr_transport::RestartOutcome::Restarted => {
crate::start_outcome::StartOutcomeStatus::Started
}
crate::nostr_transport::RestartOutcome::Failed => {
crate::start_outcome::StartOutcomeStatus::Failed
}
crate::nostr_transport::RestartOutcome::Superseded => {
crate::start_outcome::StartOutcomeStatus::Superseded
}
crate::nostr_transport::RestartOutcome::InProgress => {
crate::start_outcome::StartOutcomeStatus::InProgress
}
}
}
const OPENCODE_FIRST_TURN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4 * 60);
async fn current_exact_or_newer_owner(
state: &SharedState,
incumbent: &crate::daemon_protocol::ResourceOwner,
) -> crate::daemon_protocol::ResourceOwner {
state
.protocol
.read()
.await
.sessions
.get(&incumbent.session_id)
.map(|session| session.owner())
.unwrap_or_else(|| incumbent.clone())
}
async fn observe_opencode_first_turn(
state: &SharedState,
owner: &crate::daemon_protocol::ResourceOwner,
prompt_msg_id: Option<u64>,
expected_prompt: Option<&str>,
requested_model: Option<&str>,
) -> (crate::start_outcome::StartOutcomeStatus, String) {
const TIMEOUT: std::time::Duration = OPENCODE_FIRST_TURN_TIMEOUT;
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250);
let started = std::time::Instant::now();
loop {
let session = {
let protocol = state.protocol.read().await;
let Some(session) = protocol.sessions.get(&owner.session_id) else {
return (
crate::start_outcome::StartOutcomeStatus::Failed,
format!(
"session '{}' disappeared before its first assistant turn completed",
owner.session_id
),
);
};
if session.owner() != *owner {
return (
crate::start_outcome::StartOutcomeStatus::Superseded,
format!(
"session '{}' was superseded before its first assistant turn completed",
owner.session_id
),
);
}
(
session.metadata.backend_session_id.clone(),
session.metadata.project_dir.clone(),
)
};
let (Some(backend_session_id), Some(project_dir)) = session else {
return (
crate::start_outcome::StartOutcomeStatus::Failed,
format!(
"session '{}' has no OpenCode identity for first-turn observation",
owner.session_id
),
);
};
let url = format!(
"http://127.0.0.1:{}/session/{backend_session_id}/message",
state.opencode_serve_port()
);
match state
.http_client
.get(url)
.header("x-opencode-directory", project_dir)
.timeout(std::time::Duration::from_secs(5))
.send()
.await
{
Ok(response) if response.status().is_success() => match response.json().await {
Ok(messages) => {
if let Some(observation) = classify_opencode_first_turn(
&messages,
prompt_msg_id,
expected_prompt,
requested_model,
) {
return observation;
}
}
Err(error) => {
tracing::warn!(%error, session = %owner.session_id, "could not decode OpenCode messages while observing first turn");
}
},
Ok(response) => {
tracing::warn!(status = %response.status(), session = %owner.session_id, "OpenCode message polling returned an error");
}
Err(error) => {
tracing::warn!(%error, session = %owner.session_id, "OpenCode message polling failed");
}
}
if started.elapsed() >= TIMEOUT {
return (
crate::start_outcome::StartOutcomeStatus::Failed,
format!(
"timed out waiting for session '{}' first assistant turn",
owner.session_id
),
);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
fn classify_opencode_first_turn(
messages: &serde_json::Value,
prompt_msg_id: Option<u64>,
expected_prompt: Option<&str>,
requested_model: Option<&str>,
) -> Option<(crate::start_outcome::StartOutcomeStatus, String)> {
let messages = messages.as_array()?;
let correlation = prompt_msg_id.map(|id| format!("id=\"{id}\""));
let user = messages.iter().rev().find(|message| {
message["info"]["role"] == "user"
&& message["parts"].as_array().is_some_and(|parts| {
parts.iter().any(|part| {
if part["type"] != "text" {
return false;
}
part["text"].as_str().is_some_and(|text| {
correlation.as_ref().map_or_else(
|| expected_prompt == Some(text),
|needle| text.contains(needle),
)
})
})
})
})?;
let user_id = user["info"]["id"].as_str()?;
let assistant = messages.iter().find(|message| {
message["info"]["role"] == "assistant"
&& message["info"]["parentID"].as_str() == Some(user_id)
})?;
let info = &assistant["info"];
if !info["error"].is_null() {
return Some((
crate::start_outcome::StartOutcomeStatus::Failed,
format!("OpenCode first turn failed: {}", info["error"]),
));
}
let completed = !info["time"]["completed"].is_null() || !info["finish"].is_null();
if !completed {
return None;
}
if let Some(requested) = requested_model.and_then(parse_opencode_model) {
let actual = (
info["providerID"].as_str().unwrap_or_default(),
info["modelID"].as_str().unwrap_or_default(),
);
if actual != (requested.0.as_str(), requested.1.as_str()) {
return Some((
crate::start_outcome::StartOutcomeStatus::Failed,
format!(
"OpenCode first turn used model {}/{}, expected {}/{}",
actual.0, actual.1, requested.0, requested.1
),
));
}
}
Some((
crate::start_outcome::StartOutcomeStatus::Started,
"OpenCode first assistant turn completed".into(),
))
}
async fn classify_completed_start(
state: &SharedState,
owner: &crate::daemon_protocol::ResourceOwner,
) -> crate::start_outcome::StartOutcomeStatus {
let observed_newer = {
let protocol = state.protocol.read().await;
if protocol
.sessions
.get(&owner.session_id)
.is_some_and(|session| &session.owner() == owner)
{
return crate::start_outcome::StartOutcomeStatus::Started;
}
newer_incarnation_present(&protocol, owner)
};
if observed_newer {
crate::start_outcome::StartOutcomeStatus::Superseded
} else {
crate::start_outcome::StartOutcomeStatus::Failed
}
}
fn newer_incarnation_present(
protocol: &crate::daemon_protocol::DaemonState,
owner: &crate::daemon_protocol::ResourceOwner,
) -> bool {
let session_newer = protocol
.sessions
.get(&owner.session_id)
.is_some_and(|session| session.owner().incarnation > owner.incarnation);
let lease_newer = protocol
.lifecycle_leases
.get(&owner.session_id)
.is_some_and(|lease| {
lease.owner.incarnation > owner.incarnation
|| lease
.restart_target_owner
.as_ref()
.is_some_and(|target| target.incarnation > owner.incarnation)
});
session_newer || lease_newer
}
#[derive(Debug, Deserialize)]
pub struct StartStatusQuery {
pub session: String,
pub incarnation: String,
}
pub async fn start_status(
State(state): State<SharedState>,
Query(query): Query<StartStatusQuery>,
) -> (StatusCode, Json<serde_json::Value>) {
let Ok(incarnation) = query.incarnation.trim().parse::<u64>() else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("invalid incarnation '{}': expected a decimal u64", query.incarnation)
})),
);
};
let owner = crate::daemon_protocol::ResourceOwner {
session_id: query.session.clone(),
incarnation: crate::daemon_protocol::SessionIncarnation(incarnation),
};
let status = resolve_start_status(&state, &owner).await;
let mut payload = json!({
"session": owner.session_id,
"incarnation": owner.incarnation.to_string(),
"status": status.as_str(),
"terminal": status.is_terminal(),
});
if let Some(detail) = status.detail() {
payload["detail"] = json!(detail);
}
(StatusCode::OK, Json(payload))
}
pub(crate) async fn resolve_start_status(
state: &SharedState,
owner: &crate::daemon_protocol::ResourceOwner,
) -> crate::start_outcome::StartStatus {
if let Some(recorded) = state.start_outcomes.get(owner) {
return recorded;
}
let protocol = state.protocol.read().await;
if protocol
.sessions
.get(&owner.session_id)
.is_some_and(|session| &session.owner() == owner)
{
return crate::start_outcome::StartStatus::Started {
detail: format!(
"session '{}' is registered at incarnation {}",
owner.session_id, owner.incarnation
),
};
}
if protocol
.lifecycle_leases
.get(&owner.session_id)
.is_some_and(|lease| {
&lease.owner == owner || lease.restart_target_owner.as_ref() == Some(owner)
})
{
return crate::start_outcome::StartStatus::InProgress;
}
if newer_incarnation_present(&protocol, owner) {
return crate::start_outcome::StartStatus::Superseded {
detail: format!(
"session '{}' is now held by a newer incarnation",
owner.session_id
),
};
}
crate::start_outcome::StartStatus::Unknown
}
pub async fn restart_session(
State(state): State<SharedState>,
Json(body): Json<RestartSessionBody>,
) -> (StatusCode, Json<serde_json::Value>) {
let RestartSessionBody {
mut session,
suppress_stored_prompt,
one_shot_prompt,
} = body;
let body = &mut session;
body.model = normalize_optional_string(body.model.take());
body.effort = normalize_optional_string(body.effort.take());
body.backend = normalize_optional_string(body.backend.take());
if let Err(error) = validate_fresh_context_restart_request(body) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
if let Err(error) = crate::daemon_protocol::validate_spawn_reminder(body.reminder.as_deref()) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": error })));
}
if let Err(response) = validate_backend_name(&state, body.backend.as_deref()) {
return response;
}
if let Some(msg) = restart_drops_destructive_intent(body) {
tracing::warn!("{msg}");
}
let fresh = body.fresh.unwrap_or(false);
let (result, _prompt_msg_id, outcome) =
crate::nostr_transport::restart_session_with_prompt_controls(
&state,
&body.name,
fresh,
body.fresh_context_after_active_secs,
None,
body.prompt.as_deref(),
suppress_stored_prompt,
one_shot_prompt.as_deref(),
body.from.as_deref(),
None, body.backend.as_deref(),
body.model.as_deref(),
body.effort.as_deref(),
body.reminder.as_deref(),
crate::nostr_transport::ParentSessionOverride::PreservePrevious,
None, )
.await;
(
StatusCode::OK,
Json(json!({
"result": result,
"outcome": restart_outcome_str(outcome),
})),
)
}
fn restart_outcome_str(outcome: crate::nostr_transport::RestartOutcome) -> &'static str {
match outcome {
crate::nostr_transport::RestartOutcome::Restarted => "restarted",
crate::nostr_transport::RestartOutcome::Failed => "failed",
crate::nostr_transport::RestartOutcome::Superseded => "superseded",
crate::nostr_transport::RestartOutcome::InProgress => "in_progress",
}
}
pub async fn get_block_interactive(
State(_state): State<SharedState>,
axum::extract::Path(_pane): axum::extract::Path<String>,
) -> Json<serde_json::Value> {
Json(json!({ "block_interactive": false }))
}
pub async fn clear_block_interactive(
State(_state): State<SharedState>,
axum::extract::Path(_pane): axum::extract::Path<String>,
) -> StatusCode {
StatusCode::OK
}
fn resolve_pane_to_session(
proto: &crate::daemon_protocol::DaemonState,
raw: &str,
) -> Option<String> {
let suffix = raw.strip_prefix('%').unwrap_or(raw);
let pane_id = format!("%{suffix}");
proto
.sessions
.values()
.find(|s| s.pane.as_deref() == Some(&pane_id))
.map(|s| s.id.clone())
}
pub async fn get_pending_replies(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = get_pending_replies_inner(&state, pane).await;
(status, Json(value))
}
async fn get_pending_replies_inner(
state: &SharedState,
pane: String,
) -> (StatusCode, serde_json::Value) {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
let Some(id) = session_id else {
return (
StatusCode::NOT_FOUND,
json!({ "error": format!("pane '{pane}' is not registered") }),
);
};
let replies = state.query_agent_pending_replies(&id).await;
let list: Vec<_> = replies
.iter()
.map(|r| json!({ "msg_id": r.msg_id, "from": r.from, "message": r.message, "received_at": r.received_at }))
.collect();
(
StatusCode::OK,
json!({ "pending_replies": list, "count": list.len() }),
)
}
pub async fn delete_pending_reply(
State(state): State<SharedState>,
axum::extract::Path((pane, from)): axum::extract::Path<(String, String)>,
) -> (StatusCode, Json<serde_json::Value>) {
let (status, value) = delete_pending_reply_inner(&state, pane, from).await;
(status, Json(value))
}
async fn delete_pending_reply_inner(
state: &SharedState,
pane: String,
from: String,
) -> (StatusCode, serde_json::Value) {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
let Some(id) = session_id else {
return (
StatusCode::NOT_FOUND,
json!({ "error": format!("pane '{pane}' is not registered") }),
);
};
let cleared = {
let mut proto = state.protocol.write().await;
proto.clear_pending_reply_from(&id, &from)
};
(StatusCode::OK, json!({ "cleared": cleared }))
}
pub async fn session_stopped(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> StatusCode {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
if let Some(id) = session_id {
state
.notify_agent(&id, crate::session_agent::SessionMsg::Stopped)
.await;
}
StatusCode::OK
}
pub async fn session_active(
State(state): State<SharedState>,
axum::extract::Path(pane): axum::extract::Path<String>,
) -> StatusCode {
let session_id = {
let proto = state.protocol.read().await;
resolve_pane_to_session(&proto, &pane)
};
if let Some(id) = session_id {
state
.notify_agent(&id, crate::session_agent::SessionMsg::Active)
.await;
}
StatusCode::OK
}
async fn deliver_pending_prompt(state: &SharedState, session_name: &str) -> bool {
let pending = state.pending_prompts.lock().unwrap().remove(session_name);
let Some(pending) = pending else {
tracing::info!(
session = session_name,
"opencode pending prompt: none queued at readiness"
);
return false;
};
let pane_id = pending.pane_id.clone();
let prompt = pending.prompt.clone();
let (owner_matches, pane_still_registered, backend_session_matches, http_delivery) = {
let proto = state.protocol.read().await;
match proto.sessions.get(session_name) {
Some(session) => (
pending
.owner
.as_ref()
.is_none_or(|owner| session.owner() == *owner),
session.pane.as_deref() == Some(pane_id.as_str()),
pending
.backend_session_id
.as_deref()
.is_none_or(|expected| {
session.metadata.backend_session_id.as_deref() == Some(expected)
}),
session
.metadata
.is_strong_opencode_binding()
.then(|| session.metadata.http_delivery_snapshot())
.flatten(),
),
None => (false, false, false, None),
}
};
if !owner_matches {
tracing::info!(
"readiness prompt delivery discarded for {session_name}: queued incarnation was superseded"
);
return false;
}
if !pane_still_registered {
restore_pending_prompt_if_absent(state, session_name, pending);
tracing::warn!(
"readiness prompt delivery skipped for {session_name}: queued pane is no longer registered to the session"
);
return false;
}
if !backend_session_matches {
restore_pending_prompt_if_absent(state, session_name, pending);
tracing::warn!(
"readiness prompt delivery skipped for {session_name}: queued OpenCode backend session is no longer current"
);
return false;
}
let used_http_delivery = http_delivery.is_some();
let result = match http_delivery {
Some(delivery) => {
match deliver_pending_prompt_http_outcome(state, &pending, &delivery, &prompt).await {
crate::state::DeliveryOutcome::Accepted => Ok(()),
crate::state::DeliveryOutcome::Ambiguous(_)
| crate::state::DeliveryOutcome::Queued(_) => {
tracing::warn!(
"readiness prompt HTTP delivery failed ambiguously for {session_name}; not retrying via raw tmux"
);
return false;
}
crate::state::DeliveryOutcome::Rejected(reason) => Err(anyhow::anyhow!(reason)),
}
}
None => {
deliver_pending_prompt_via_raw_tmux(
state,
session_name,
&pane_id,
&prompt,
pending.backend_session_id.as_deref(),
pending.owner.as_ref(),
)
.await
}
};
if result.is_err()
&& let Some(owner) = pending.owner.as_ref()
{
let owner_is_current = state
.protocol
.read()
.await
.sessions
.get(session_name)
.is_some_and(|session| session.owner() == *owner);
if !owner_is_current {
tracing::info!(
"readiness prompt delivery discarded for {session_name}: queued incarnation was superseded during delivery"
);
return false;
}
}
match result {
Ok(()) => {
tracing::info!("delivered queued prompt to {session_name} via readiness signal");
true
}
Err(e) if used_http_delivery => {
tracing::warn!(
"readiness prompt HTTP delivery failed for {session_name}, trying raw tmux fallback: {e}"
);
match deliver_pending_prompt_via_raw_tmux(
state,
session_name,
&pane_id,
&prompt,
pending.backend_session_id.as_deref(),
pending.owner.as_ref(),
)
.await
{
Ok(()) => {
tracing::info!(
"delivered queued prompt to {session_name} via raw tmux fallback"
);
true
}
Err(fallback_error) => {
restore_pending_prompt_if_absent(state, session_name, pending.clone());
schedule_pending_prompt_retry(state, session_name, pending);
tracing::warn!(
"readiness prompt fallback failed for {session_name}: {fallback_error}"
);
false
}
}
}
Err(e) => {
restore_pending_prompt_if_absent(state, session_name, pending.clone());
schedule_pending_prompt_retry(state, session_name, pending);
tracing::warn!("readiness prompt raw tmux delivery failed for {session_name}: {e}");
false
}
}
}
async fn deliver_pending_prompt_http_outcome(
state: &SharedState,
pending: &crate::state::PendingPrompt,
delivery: &crate::daemon_protocol::HttpDeliverySnapshot,
prompt: &str,
) -> crate::state::DeliveryOutcome {
let (Some(owner), Some(backend_session_id)) = (
pending.owner.as_ref(),
pending.backend_session_id.as_deref(),
) else {
return deliver_http_message_outcome(state, delivery, prompt).await;
};
state
.with_owned_backend_claim(owner, backend_session_id, || async {
deliver_http_message_outcome(state, delivery, prompt).await
})
.await
.unwrap_or_else(|| {
crate::state::DeliveryOutcome::Rejected(
"queued restart incarnation was superseded before readiness delivery".into(),
)
})
}
#[cfg(test)]
const PENDING_PROMPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(10);
#[cfg(not(test))]
const PENDING_PROMPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(10);
const PENDING_PROMPT_MAX_RETRIES: u8 = 3;
fn schedule_pending_prompt_retry(
state: &SharedState,
session_name: &str,
pending_prompt: crate::state::PendingPrompt,
) {
schedule_pending_prompt_retry_attempt(state, session_name, pending_prompt, 1);
}
fn schedule_pending_prompt_retry_attempt(
state: &SharedState,
session_name: &str,
pending_prompt: crate::state::PendingPrompt,
attempt: u8,
) {
let state = state.clone();
let session_name = session_name.to_string();
tokio::spawn(async move {
tokio::time::sleep(PENDING_PROMPT_RETRY_DELAY).await;
let pending = reserve_pending_prompt_if_matches(
&state,
&session_name,
&pending_prompt.pane_id,
&pending_prompt.prompt,
pending_prompt.backend_session_id.as_deref(),
);
let Some(pending) = pending else {
return;
};
match deliver_pending_prompt_via_raw_tmux(
&state,
&session_name,
&pending.pane_id,
&pending.prompt,
pending.backend_session_id.as_deref(),
pending.owner.as_ref(),
)
.await
{
Ok(()) => {}
Err(error) => {
restore_pending_prompt_if_absent(&state, &session_name, pending.clone());
if attempt < PENDING_PROMPT_MAX_RETRIES {
schedule_pending_prompt_retry_attempt(
&state,
&session_name,
pending,
attempt + 1,
);
}
tracing::warn!(
"readiness prompt retry fallback attempt {attempt}/{PENDING_PROMPT_MAX_RETRIES} failed for {session_name}: {error}"
)
}
}
});
}
fn reserve_pending_prompt_if_matches(
state: &SharedState,
session_name: &str,
pane_id: &str,
prompt: &str,
backend_session_id: Option<&str>,
) -> Option<crate::state::PendingPrompt> {
let mut pending = state.pending_prompts.lock().unwrap();
if pending.get(session_name).is_some_and(|pending| {
pending.pane_id == pane_id
&& pending.prompt == prompt
&& pending.backend_session_id.as_deref() == backend_session_id
}) {
return pending.remove(session_name);
}
None
}
async fn deliver_pending_prompt_via_raw_tmux(
state: &SharedState,
session_name: &str,
pane_id: &str,
prompt: &str,
expected_backend_session_id: Option<&str>,
expected_owner: Option<&crate::daemon_protocol::ResourceOwner>,
) -> anyhow::Result<()> {
if let Some(owner) = expected_owner {
return state
.with_owned_pane_claim(owner, pane_id, || async {
deliver_pending_prompt_via_raw_tmux_unchecked(
state,
session_name,
pane_id,
prompt,
expected_backend_session_id,
)
.await
})
.await
.unwrap_or_else(|| {
Err(anyhow::anyhow!(
"readiness prompt fallback skipped: queued incarnation is no longer current for session {session_name}"
))
});
}
deliver_pending_prompt_via_raw_tmux_unchecked(
state,
session_name,
pane_id,
prompt,
expected_backend_session_id,
)
.await
}
async fn deliver_pending_prompt_via_raw_tmux_unchecked(
state: &SharedState,
session_name: &str,
pane_id: &str,
prompt: &str,
expected_backend_session_id: Option<&str>,
) -> anyhow::Result<()> {
let (pane_still_registered, backend_session_matches) = {
let proto = state.protocol.read().await;
match proto.sessions.get(session_name) {
Some(session) => (
session.pane.as_deref() == Some(pane_id),
expected_backend_session_id.is_none_or(|expected| {
session.metadata.backend_session_id.as_deref() == Some(expected)
}),
),
None => (false, false),
}
};
if !pane_still_registered {
anyhow::bail!(
"readiness prompt fallback skipped: pane {pane_id} is no longer registered to session {session_name}"
);
}
if !backend_session_matches {
anyhow::bail!(
"readiness prompt fallback skipped: queued OpenCode backend session is no longer current for session {session_name}"
);
}
ensure_pending_prompt_pane_is_live(state, session_name, pane_id).await?;
crate::tmux::locked_inject_raw_tmux(state, session_name, pane_id, prompt, false).await
}
async fn ensure_pending_prompt_pane_is_live(
state: &SharedState,
session_name: &str,
pane_id: &str,
) -> anyhow::Result<()> {
let pane_live = if cfg!(test) {
state
.list_assistant_panes()
.await
.iter()
.any(|pane| pane.pane_id == pane_id)
} else {
let process_names: Vec<String> = state
.backend_for_session(session_name)
.await
.process_names()
.iter()
.map(|name| name.to_string())
.collect();
let pane_id = pane_id.to_string();
tokio::task::spawn_blocking(move || {
let refs: Vec<&str> = process_names.iter().map(|name| name.as_str()).collect();
crate::tmux::pane_alive(&pane_id, &refs)
})
.await
.unwrap_or(false)
};
if !pane_live {
anyhow::bail!(
"readiness prompt fallback skipped: pane {pane_id} is not running the session backend"
);
}
Ok(())
}
fn restore_pending_prompt_if_absent(
state: &SharedState,
session_name: &str,
pending_prompt: crate::state::PendingPrompt,
) {
let mut pending = state.pending_prompts.lock().unwrap();
pending
.entry(session_name.to_string())
.or_insert(pending_prompt);
}
pub async fn session_ready(
State(state): State<SharedState>,
axum::extract::Path(session_id): axum::extract::Path<String>,
) -> Json<serde_json::Value> {
let delivered = deliver_pending_prompt(&state, &session_id).await;
Json(json!({"delivered": delivered}))
}
#[derive(Clone, Debug, Deserialize)]
pub struct BackendIdentityRequest {
pub backend: String,
pub session_id: String,
}
impl BackendIdentityRequest {
fn into_identity(self) -> Result<crate::backend::BackendSessionIdentity, String> {
let backend = self.backend.trim();
if backend.is_empty() {
return Err("backend identity requires a non-empty backend".into());
}
if let Some(error) =
crate::daemon_protocol::validate_backend_session_id_boundary(&self.session_id)
{
return Err(error);
}
if self.session_id.is_empty() {
return Err("backend identity requires a non-empty session_id".into());
}
Ok(crate::backend::BackendSessionIdentity {
backend: backend.into(),
session_id: self.session_id,
})
}
}
pub async fn resolve_backend_identity(
State(state): State<SharedState>,
Json(body): Json<BackendIdentityRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
let identity = match body.into_identity() {
Ok(identity) => identity,
Err(error) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": error }))),
};
let resolution = state
.protocol
.read()
.await
.resolve_backend_identity(&identity);
match resolution {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => (
StatusCode::OK,
Json(json!({ "outcome": "resolved", "session_id": session_id })),
),
crate::daemon_protocol::BackendIdentityResolution::NotFound => (
StatusCode::NOT_FOUND,
Json(json!({
"outcome": "not_found",
"error": "no Local session has this complete backend identity"
})),
),
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "ambiguous",
"error": "multiple Local sessions have this backend identity; repair state before retrying"
})),
),
crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "incomplete_legacy",
"error": "legacy backend metadata is incomplete; request an explicit fresh managed repair"
})),
),
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct BackendIdentityBindRequest {
pub target_session_id: String,
pub identity: BackendIdentityRequest,
#[serde(default)]
pub launch_credential: Option<String>,
}
pub async fn bind_backend_identity(
State(state): State<SharedState>,
Json(body): Json<BackendIdentityBindRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
let identity = match body.identity.into_identity() {
Ok(identity) => identity,
Err(error) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": error }))),
};
let launch_credential = body
.launch_credential
.as_deref()
.map(str::trim)
.filter(|credential| !credential.is_empty());
let Some(launch_credential) = launch_credential else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"outcome": "missing_launch_proof",
"error": "binding a backend identity requires the managed launch credential"
})),
);
};
let result = state
.bind_backend_identity(
&body.target_session_id,
&identity,
Some(launch_credential),
None,
)
.await;
match result.outcome {
crate::daemon_protocol::BackendIdentityBindOutcome::Bound { session_id } => (
StatusCode::OK,
Json(json!({ "outcome": "bound", "session_id": session_id })),
),
crate::daemon_protocol::BackendIdentityBindOutcome::AlreadyBound { session_id } => (
StatusCode::OK,
Json(json!({ "outcome": "already_bound", "session_id": session_id })),
),
crate::daemon_protocol::BackendIdentityBindOutcome::TargetNotFound => (
StatusCode::NOT_FOUND,
Json(json!({
"outcome": "target_not_found",
"error": "named Local session no longer exists; start a fresh managed session"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::TargetNotLocal => (
StatusCode::FORBIDDEN,
Json(json!({
"outcome": "target_not_local",
"error": "only a Local session can accept a backend identity"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::TargetIncompleteLegacy { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "target_incomplete_legacy",
"error": "target has incomplete legacy backend metadata; request a fresh managed repair"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::TargetBackendMismatch { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "target_backend_mismatch",
"error": "managed launch backend does not match the named session"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::TargetAlreadyBound { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "target_already_bound",
"error": "target is already bound to a different backend identity; use an explicit fresh managed repair"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::LifecycleInProgress { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "lifecycle_in_progress",
"error": "target session has a lifecycle operation in progress"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::CredentialExpired => (
StatusCode::GONE,
Json(json!({
"outcome": "credential_expired",
"error": "managed launch credential expired; request a fresh managed repair"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::InvalidCredential => (
StatusCode::FORBIDDEN,
Json(json!({
"outcome": "invalid_launch_proof",
"error": "managed launch credential was rejected"
})),
),
crate::daemon_protocol::BackendIdentityBindOutcome::IdentityBoundToOther { .. } => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "identity_bound_to_other",
"error": "backend identity is already bound to another Local session"
})),
),
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct BackendIdentityRecoveryRequest {
pub target_session_id: String,
pub identity: BackendIdentityRequest,
#[serde(default)]
pub caller: crate::state::BackendRecoveryCallerEvidence,
}
pub async fn recover_backend_identity(
State(state): State<SharedState>,
Json(body): Json<BackendIdentityRecoveryRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
let identity = match body.identity.into_identity() {
Ok(identity) => identity,
Err(error) => return (StatusCode::BAD_REQUEST, Json(json!({ "error": error }))),
};
if let Err(response) = validate_backend_name(&state, Some(&identity.backend)) {
return response;
}
let outcome = state
.recover_backend_identity(&body.target_session_id, &identity, &body.caller)
.await;
use crate::state::BackendIdentityRecoveryOutcome as Outcome;
match outcome {
Outcome::Recovered(owner) => (
StatusCode::OK,
Json(json!({
"outcome": "recovered",
"session_id": owner.session_id,
"session_incarnation": owner.incarnation.to_string(),
})),
),
Outcome::TargetNotFound => (
StatusCode::NOT_FOUND,
Json(json!({
"outcome": "target_not_found",
"error": "the exact named session no longer exists"
})),
),
Outcome::TargetNotLocal => (
StatusCode::FORBIDDEN,
Json(json!({
"outcome": "target_not_local",
"error": "backend recovery is restricted to an exact Local session"
})),
),
Outcome::TargetNotBlank => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "target_not_blank",
"error": "the target no longer has a blank backend binding"
})),
),
Outcome::TargetMissingPane | Outcome::TargetMissingProject => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "target_missing_evidence",
"error": "the target lacks required pane or project ownership evidence"
})),
),
Outcome::LifecycleInProgress => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "lifecycle_in_progress",
"error": "the target has a lifecycle operation in progress"
})),
),
Outcome::IdentityConflict => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "identity_conflict",
"error": "the backend identity is already claimed by another Local session"
})),
),
Outcome::PositiveEvidenceMismatch => (
StatusCode::FORBIDDEN,
Json(json!({
"outcome": "caller_evidence_mismatch",
"error": "positive caller identity evidence conflicts with the exact target"
})),
),
Outcome::PaneNotLive
| Outcome::PaneOwnerMismatch
| Outcome::PaneProjectMismatch
| Outcome::PaneBackendMismatch => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "live_evidence_mismatch",
"error": "the live pane does not corroborate the exact target ownership snapshot"
})),
),
Outcome::Superseded => (
StatusCode::CONFLICT,
Json(json!({
"outcome": "superseded",
"error": "the target ownership snapshot changed during recovery"
})),
),
Outcome::PersistenceFailed => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({
"outcome": "persistence_failed",
"error": "recovery was rolled back because durable persistence failed"
})),
),
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct BackendIdentityRepairRequest {
pub session_id: String,
pub backend: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RepairRestartOutcome {
RestartFailed,
SessionStartPending,
Bound,
Inconclusive,
Superseded,
}
fn terminalize_failed_repair_reservation(
session: &mut crate::daemon_protocol::SessionEntry,
expected: &crate::daemon_protocol::BackendRepairReservation,
) -> bool {
let matches_failed_launch = session
.metadata
.backend_repair_reservation
.as_ref()
.is_some_and(|actual| {
actual.original_incarnation == expected.original_incarnation
&& actual.restart_generation == expected.restart_generation
&& matches!(
actual.phase,
crate::daemon_protocol::BackendRepairPhase::PreStage
| crate::daemon_protocol::BackendRepairPhase::Staged
)
});
if matches_failed_launch {
session.metadata.backend_repair_reservation = None;
}
matches_failed_launch
}
fn classify_repair_restart_outcome(
session: Option<&mut crate::daemon_protocol::SessionEntry>,
reservation: &crate::daemon_protocol::BackendRepairReservation,
restart_outcome: crate::nostr_transport::RestartOutcome,
) -> RepairRestartOutcome {
match session {
None => RepairRestartOutcome::Superseded,
Some(session) if restart_outcome == crate::nostr_transport::RestartOutcome::Failed => {
if terminalize_failed_repair_reservation(session, reservation) {
RepairRestartOutcome::RestartFailed
} else if session.metadata.backend_repair_reservation.is_none()
&& session.metadata.backend.is_some()
&& session.metadata.backend_session_id.is_some()
{
RepairRestartOutcome::Bound
} else {
RepairRestartOutcome::Superseded
}
}
Some(session) if session.metadata.restart_generation != reservation.restart_generation => {
RepairRestartOutcome::Superseded
}
Some(session)
if session.metadata.backend_repair_reservation.is_none()
&& session.metadata.backend.is_some()
&& session.metadata.backend_session_id.is_some() =>
{
RepairRestartOutcome::Bound
}
Some(session)
if session.metadata.backend_repair_reservation.as_ref() != Some(reservation) =>
{
RepairRestartOutcome::Superseded
}
Some(session) if session.metadata.session_start_credential.is_some() => {
RepairRestartOutcome::SessionStartPending
}
Some(_) => RepairRestartOutcome::Inconclusive,
}
}
pub async fn repair_backend_identity(
State(state): State<SharedState>,
Json(body): Json<BackendIdentityRepairRequest>,
) -> (StatusCode, Json<serde_json::Value>) {
let backend = body.backend.trim().to_string();
if backend.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "repair requires a backend name" })),
);
}
if let Err(response) = validate_backend_name(&state, Some(&backend)) {
return response;
}
let reservation = {
let mut protocol = state.protocol.write().await;
match protocol.sessions.get_mut(&body.session_id) {
None => Err(("target_not_found", "named Local session no longer exists")),
Some(session) if !matches!(session.origin, crate::daemon_protocol::Origin::Local) => {
Err(("target_not_local", "only a Local session can be repaired"))
}
Some(session)
if session.metadata.backend.is_some()
== session.metadata.backend_session_id.is_some() =>
{
Err((
"target_not_incomplete_legacy",
"repair is only for incomplete legacy backend metadata",
))
}
Some(session)
if session
.metadata
.backend
.as_deref()
.is_some_and(|recorded| recorded != backend) =>
{
Err((
"target_backend_mismatch",
"requested backend does not match the legacy session metadata",
))
}
Some(session) if session.metadata.backend_repair_reservation.is_some() => Err((
"repair_in_progress",
"a fresh managed repair is already in progress for this session",
)),
Some(session) => {
let reservation = crate::daemon_protocol::BackendRepairReservation {
original_incarnation: session.metadata.session_incarnation,
restart_generation: session.metadata.restart_generation.saturating_add(1),
phase: crate::daemon_protocol::BackendRepairPhase::PreStage,
};
session.metadata.backend_repair_reservation = Some(reservation.clone());
Ok(reservation)
}
}
};
let reservation = match reservation {
Ok(reservation) => reservation,
Err((outcome, error)) => {
let status = match outcome {
"target_not_found" => StatusCode::NOT_FOUND,
"target_not_local" => StatusCode::FORBIDDEN,
_ => StatusCode::CONFLICT,
};
return (status, Json(json!({ "outcome": outcome, "error": error })));
}
};
let reservation_for_restart = reservation.clone();
let state_for_restart = state.clone();
let session_id = body.session_id.clone();
let backend_for_restart = backend.clone();
tokio::spawn(async move {
let (result, _, restart_outcome) = crate::nostr_transport::restart_session(
&state_for_restart,
&session_id,
true,
Some(reservation_for_restart.clone()),
None,
None,
None,
Some(&backend_for_restart),
None,
None,
None,
crate::nostr_transport::ParentSessionOverride::PreservePrevious,
None,
)
.await;
let mut protocol = state_for_restart.protocol.write().await;
let repair_outcome = classify_repair_restart_outcome(
protocol.sessions.get_mut(&session_id),
&reservation_for_restart,
restart_outcome,
);
tracing::info!(session = %session_id, backend = %backend_for_restart, %result, ?restart_outcome, ?repair_outcome, "legacy backend repair fresh relaunch completed");
});
(
StatusCode::ACCEPTED,
Json(json!({
"outcome": "fresh_relaunch_started",
"session_id": body.session_id,
"backend": backend,
"repair_reservation": reservation.restart_generation
})),
)
}
pub async fn backend_session_ready(
State(state): State<SharedState>,
axum::extract::Path(backend_sid): axum::extract::Path<String>,
body_bytes: Bytes,
) -> Json<serde_json::Value> {
if let Some(error) = validate_backend_session_id_boundary(&backend_sid) {
return Json(json!({"delivered": false, "error": error}));
}
let hints = if body_bytes.is_empty() {
BackendSessionReadyHints::default()
} else {
match serde_json::from_slice::<BackendSessionReadyHints>(&body_bytes) {
Ok(h) => h,
Err(e) => {
tracing::debug!(
target: "ouija::api::backend_session_ready",
"failed to parse readiness hints ({e}); falling back to scan path"
);
BackendSessionReadyHints::default()
}
}
};
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
hint_pane = ?hints.pane,
hint_cwd = ?hints.cwd,
"backend session ready received"
);
Json(backend_session_ready_inner_with_hints(&state, backend_sid, hints).await)
}
#[derive(Debug, Default, Deserialize)]
struct BackendSessionReadyHints {
#[serde(default)]
pane: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(
default,
deserialize_with = "crate::daemon_protocol::deserialize_optional_incarnation"
)]
session_incarnation: Option<crate::daemon_protocol::SessionIncarnation>,
#[serde(default)]
created: bool,
#[serde(default)]
root_session: Option<bool>,
}
#[cfg(test)]
async fn backend_session_ready_inner(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: String,
) -> serde_json::Value {
backend_session_ready_inner_with_hints(state, backend_sid, BackendSessionReadyHints::default())
.await
}
async fn record_opencode_ready_attestation(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane: &str,
cwd: &str,
) -> crate::state::LocalBackendPaneAttestationRecordOutcome {
if cwd.is_empty() || cwd == "/" || !cwd.starts_with('/') {
return crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected;
}
let Ok(hinted_project) = crate::project_identity::resolve_project_identity_async(cwd).await
else {
return crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected;
};
let Some(backend_dir) = lookup_opencode_session_dir(state, backend_sid).await else {
return crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected;
};
let Ok(backend_project) =
crate::project_identity::resolve_project_identity_async(&backend_dir).await
else {
return crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected;
};
if backend_project != hinted_project {
return crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected;
}
state
.record_local_backend_pane_attestation(
&crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: backend_sid.to_string(),
},
pane,
&hinted_project,
)
.await
}
async fn recover_standalone_opencode_backend_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane: &str,
cwd: &str,
) -> Option<String> {
if cwd.is_empty() || cwd == "/" || !cwd.starts_with('/') {
return None;
}
let project = crate::project_identity::resolve_project_identity_async(cwd)
.await
.ok()?;
let hinted_project_dir = crate::state::project_dir_identity(&project.project_dir);
let hinted_canonical = crate::state::project_dir_identity(&project.canonical_repository);
let candidate_id = {
let proto = state.protocol.read().await;
let mut candidates = proto.sessions.values().filter(|session| {
matches!(session.origin, crate::daemon_protocol::Origin::Local)
&& session.pane.as_deref() == Some(pane)
&& session.metadata.backend.is_none()
&& session.metadata.backend_session_id.is_none()
&& session.metadata.session_start_credential.is_none()
&& session.metadata.backend_repair_reservation.is_none()
&& session.metadata.opencode_binding.is_none()
&& session
.metadata
.project_dir
.as_deref()
.map(crate::state::project_dir_identity)
== Some(hinted_project_dir.clone())
&& session
.metadata
.canonical_project_identity
.as_deref()
.map(crate::state::project_dir_identity)
== Some(hinted_canonical.clone())
});
let candidate = candidates.next()?;
if candidates.next().is_some() {
return None;
}
candidate.id.clone()
};
let identity = crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: backend_sid.to_string(),
};
let caller = crate::state::BackendRecoveryCallerEvidence {
pane: Some(pane.to_string()),
..Default::default()
};
match state
.recover_backend_identity(&candidate_id, &identity, &caller)
.await
{
crate::state::BackendIdentityRecoveryOutcome::Recovered(owner) => Some(owner.session_id),
_ => None,
}
}
async fn record_readiness_decline(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
hints: &BackendSessionReadyHints,
outcome: &str,
reason: &str,
) -> serde_json::Value {
let (incumbent_session, incumbent_backend_session_id) = match hints.pane.as_deref() {
Some(pane) => {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.find(|session| {
matches!(session.origin, crate::daemon_protocol::Origin::Local)
&& session.pane.as_deref() == Some(pane)
})
.map(|session| {
(
Some(session.id.clone()),
session.metadata.backend_session_id.clone(),
)
})
.unwrap_or((None, None))
}
None => (None, None),
};
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
pane = ?hints.pane,
cwd = ?hints.cwd,
incumbent_session = ?incumbent_session,
incumbent_backend_session_id = ?incumbent_backend_session_id,
outcome,
"backend session ready declined: {reason}"
);
state
.record_backend_readiness_decline(crate::state::BackendReadinessDecline {
at: chrono::Utc::now().timestamp(),
backend_session_id: backend_sid.to_string(),
pane: hints.pane.clone(),
cwd: hints.cwd.clone(),
outcome: outcome.to_string(),
reason: reason.to_string(),
incumbent_session,
incumbent_backend_session_id,
})
.await;
json!({
"delivered": false,
"outcome": outcome,
"error": reason,
})
}
pub async fn backend_session_declines(State(state): State<SharedState>) -> Json<serde_json::Value> {
Json(json!({ "declines": state.backend_readiness_declines().await }))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OpencodeServeSessionProbe {
Live {
has_parent: bool,
},
Absent,
Unknown,
}
async fn probe_opencode_serve_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
) -> OpencodeServeSessionProbe {
#[cfg(test)]
if let Some(probe) = state.opencode_serve_probe_test_result(backend_sid) {
return probe;
}
let port = state.opencode_serve_port();
let url = format!("http://127.0.0.1:{port}/session/{backend_sid}");
let Ok(resp) = state
.http_client
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await
else {
return OpencodeServeSessionProbe::Unknown;
};
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return OpencodeServeSessionProbe::Absent;
}
if !resp.status().is_success() {
return OpencodeServeSessionProbe::Unknown;
}
let Ok(body) = resp.json::<serde_json::Value>().await else {
return OpencodeServeSessionProbe::Unknown;
};
let has_parent = body
.get("parentID")
.is_some_and(|value| value.as_str().is_some_and(|id| !id.trim().is_empty()));
OpencodeServeSessionProbe::Live { has_parent }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct StaleRebindIncumbent {
pub backend: Option<String>,
pub backend_session_id: Option<String>,
pub opencode_binding: Option<crate::daemon_protocol::OpenCodeBinding>,
pub has_session_start_credential: bool,
pub has_backend_repair_reservation: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct StaleRebindAttestation {
pub created: bool,
pub root_session: Option<bool>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum StalenessEvidence {
ManagedServeRefutation,
PluginRootCreation,
}
fn stale_rebind_structural_precheck(
incumbent: &StaleRebindIncumbent,
reported_sid: &str,
) -> Result<(), &'static str> {
if incumbent.backend.as_deref() != Some("opencode") {
return Err("incumbent pane owner is not bound to the opencode backend");
}
let Some(incumbent_sid) = incumbent.backend_session_id.as_deref() else {
return Err("incumbent pane owner has no backend session id to replace");
};
if incumbent_sid == reported_sid {
return Err("reported conversation is already the incumbent binding");
}
if incumbent.has_session_start_credential || incumbent.has_backend_repair_reservation {
return Err("incumbent pane owner has an outstanding managed-launch claim");
}
Ok(())
}
fn classify_stale_opencode_rebind(
incumbent: &StaleRebindIncumbent,
reported_sid: &str,
attestation: StaleRebindAttestation,
incumbent_probe: OpencodeServeSessionProbe,
reported_probe: OpencodeServeSessionProbe,
) -> Result<StalenessEvidence, &'static str> {
stale_rebind_structural_precheck(incumbent, reported_sid)?;
if reported_probe == (OpencodeServeSessionProbe::Live { has_parent: true }) {
return Err("reported conversation is an opencode child session");
}
if incumbent.opencode_binding == Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged) {
return match (incumbent_probe, reported_probe) {
(OpencodeServeSessionProbe::Live { .. }, _) => {
Err("incumbent backend session is still present on the managed serve")
}
(OpencodeServeSessionProbe::Unknown, _) => {
Err("managed serve could not confirm the incumbent backend session")
}
(OpencodeServeSessionProbe::Absent, OpencodeServeSessionProbe::Live { .. }) => {
Ok(StalenessEvidence::ManagedServeRefutation)
}
(OpencodeServeSessionProbe::Absent, _) => {
Err("managed serve does not know the reported conversation either")
}
};
}
if !attestation.created {
return Err(
"standalone binding needs a plugin session-created attestation to rotate; \
managed-serve absence does not prove a standalone session is dead",
);
}
match attestation.root_session {
Some(true) => Ok(StalenessEvidence::PluginRootCreation),
Some(false) => Err("reported conversation is an opencode child session"),
None => Err("plugin did not attest whether the reported conversation is a root session"),
}
}
async fn rotate_stale_opencode_pane_owner(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane: &str,
cwd: &str,
attestation: StaleRebindAttestation,
) -> Result<String, String> {
if cwd.is_empty() || cwd == "/" || !cwd.starts_with('/') {
return Err("readiness cwd hint is not an absolute project path".into());
}
let Ok(project) = crate::project_identity::resolve_project_identity_async(cwd).await else {
return Err("readiness cwd hint does not resolve to a project".into());
};
let hinted_canonical = crate::state::project_dir_identity(&project.canonical_repository);
let (incumbent_owner, incumbent) = {
let proto = state.protocol.read().await;
let mut candidates = proto.sessions.values().filter(|session| {
matches!(session.origin, crate::daemon_protocol::Origin::Local)
&& session.pane.as_deref() == Some(pane)
});
let Some(candidate) = candidates.next() else {
return Err("no Local session owns the readiness pane".into());
};
if candidates.next().is_some() {
return Err("multiple Local sessions claim the readiness pane".into());
}
if candidate
.metadata
.canonical_project_identity
.as_deref()
.map(crate::state::project_dir_identity)
!= Some(hinted_canonical)
{
return Err("pane owner's canonical project does not match the readiness cwd".into());
}
if proto.lifecycle_leases.contains_key(&candidate.id) {
return Err("pane owner has a lifecycle lease in progress".into());
}
(
candidate.owner(),
StaleRebindIncumbent {
backend: candidate.metadata.backend.clone(),
backend_session_id: candidate.metadata.backend_session_id.clone(),
opencode_binding: candidate.metadata.opencode_binding.clone(),
has_session_start_credential: candidate.metadata.session_start_credential.is_some(),
has_backend_repair_reservation: candidate
.metadata
.backend_repair_reservation
.is_some(),
},
)
};
stale_rebind_structural_precheck(&incumbent, backend_sid).map_err(str::to_string)?;
let incumbent_probe = match incumbent.backend_session_id.as_deref() {
Some(sid) => probe_opencode_serve_session(state, sid).await,
None => OpencodeServeSessionProbe::Unknown,
};
let reported_probe = probe_opencode_serve_session(state, backend_sid).await;
let evidence = classify_stale_opencode_rebind(
&incumbent,
backend_sid,
attestation,
incumbent_probe,
reported_probe,
)
.map_err(str::to_string)?;
match state.opencode_rotation_pane_inspection(pane).await {
Ok(inspection) => {
if !crate::tmux::pane_accepts_owner_marker(&inspection, &incumbent_owner) {
return Err("pane owner markers do not match the incumbent owner".into());
}
}
Err(error) => {
return Err(format!(
"pane owner markers could not be inspected: {error}"
));
}
}
let identity = crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: backend_sid.to_string(),
};
let replacement_id = incumbent_owner.session_id.clone();
match state
.replace_reused_cross_backend_pane(
incumbent_owner,
pane.to_string(),
project,
identity,
replacement_id,
)
.await
{
crate::state::ReusedPaneReplacementOutcome::Replaced(owner) => {
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %owner.session_id,
incarnation = %owner.incarnation,
evidence = ?evidence,
"ready rotated a stale opencode pane binding onto the live conversation"
);
Ok(owner.session_id)
}
crate::state::ReusedPaneReplacementOutcome::PersistenceFailed => {
Err("stale binding rotation could not be persisted".into())
}
crate::state::ReusedPaneReplacementOutcome::NotApplicable
| crate::state::ReusedPaneReplacementOutcome::Refused => {
Err("stale binding rotation refused by pane/backend corroboration".into())
}
}
}
async fn backend_session_ready_inner_with_hints(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: String,
hints: BackendSessionReadyHints,
) -> serde_json::Value {
if let Some(error) = validate_backend_session_id_boundary(&backend_sid) {
return json!({"delivered": false, "error": error});
}
let (resolution, hinted_owner_matches) = {
let proto = state.protocol.read().await;
let resolution = resolve_opencode_backend_identity(&proto, &backend_sid);
let hinted_owner_matches = hints.session_incarnation.is_none_or(|expected| {
matches!(
&resolution,
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id }
if proto.sessions.get(session_id).is_some_and(|session| {
session.metadata.session_incarnation == expected
})
)
});
(resolution, hinted_owner_matches)
};
if !hinted_owner_matches {
return json!({
"delivered": false,
"outcome": "superseded",
"error": "backend readiness belongs to a replaced session incarnation",
});
}
let mut attestation_generation = None;
let name = match resolution {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => {
let n = session_id;
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %n,
"ready direct lookup matched existing backend session"
);
n
}
crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => {
return record_readiness_decline(
state,
&backend_sid,
&hints,
"incomplete_legacy",
"legacy backend metadata is incomplete; request an explicit fresh managed repair",
)
.await;
}
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. } => {
return record_readiness_decline(
state,
&backend_sid,
&hints,
"ambiguous",
"multiple Local sessions have this backend identity; repair state before retrying",
)
.await;
}
crate::daemon_protocol::BackendIdentityResolution::NotFound => {
let mut standalone_recovered = None;
if let (Some(pane), Some(cwd)) = (hints.pane.as_deref(), hints.cwd.as_deref()) {
standalone_recovered =
recover_standalone_opencode_backend_session(state, &backend_sid, pane, cwd)
.await;
if standalone_recovered.is_none() {
match rotate_stale_opencode_pane_owner(
state,
&backend_sid,
pane,
cwd,
StaleRebindAttestation {
created: hints.created,
root_session: hints.root_session,
},
)
.await
{
Ok(rotated) => standalone_recovered = Some(rotated),
Err(reason) => {
record_readiness_decline(
state,
&backend_sid,
&hints,
"stale_binding_rotation_refused",
&reason,
)
.await;
}
}
}
if standalone_recovered.is_none() {
match record_opencode_ready_attestation(state, &backend_sid, pane, cwd).await {
crate::state::LocalBackendPaneAttestationRecordOutcome::Recorded(
attestation,
) => {
attestation_generation = Some(attestation.generation);
}
crate::state::LocalBackendPaneAttestationRecordOutcome::Ambiguous {
..
} => {
return record_readiness_decline(
state,
&backend_sid,
&hints,
"ambiguous_attestation",
"backend readiness is ambiguous across live panes",
)
.await;
}
crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected => {
if state.settings.read().await.auto_register {
return record_readiness_decline(
state,
&backend_sid,
&hints,
"attestation_rejected",
"backend readiness pane attestation rejected",
)
.await;
}
}
}
}
}
let adopted = if standalone_recovered.is_some() {
None
} else {
adopt_backend_session_id(state, &backend_sid).await
};
if let Some(n) = standalone_recovered.or(adopted) {
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %n,
"ready bound backend session to an existing ouija session"
);
n
} else {
let auto_register = state.settings.read().await.auto_register;
if !auto_register {
tracing::warn!(
target: "ouija::api::backend_session_ready",
"auto_register disabled; declining to auto-provision for backend_session_id {backend_sid}"
);
return record_readiness_decline(
state,
&backend_sid,
&hints,
"auto_register_disabled",
"auto_register is disabled; no session with this backend_session_id",
)
.await;
}
if let (Some(pane), Some(cwd)) = (hints.pane.as_deref(), hints.cwd.as_deref()) {
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
pane,
cwd,
"ready trying explicit pane/cwd auto-provision"
);
if let Some(n) =
auto_provision_with_explicit_pane(state, &backend_sid, pane, cwd).await
{
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %n,
"ready explicit auto-provision succeeded"
);
n
} else {
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
pane,
cwd,
"ready explicit auto-provision failed"
);
return record_readiness_decline(
state,
&backend_sid,
&hints,
"auto_provision_failed",
"explicit pane/cwd auto-provision found no bindable pane; \
no session with this backend_session_id",
)
.await;
}
} else {
let Some(dir) = lookup_opencode_session_dir(state, &backend_sid).await else {
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
"ready fallback could not resolve opencode session directory"
);
return record_readiness_decline(
state,
&backend_sid,
&hints,
"session_dir_unresolved",
"managed opencode serve could not resolve this session's directory; \
no session with this backend_session_id",
)
.await;
};
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
dir,
"ready trying scan-based auto-provision"
);
let Some(n) =
auto_provision_from_backend_session(state, &backend_sid, &dir).await
else {
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
dir,
"ready scan-based auto-provision failed"
);
return record_readiness_decline(
state,
&backend_sid,
&hints,
"auto_provision_failed",
"scan-based auto-provision found no bindable pane; \
no session with this backend_session_id",
)
.await;
};
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %n,
"ready scan-based auto-provision succeeded"
);
n
}
}
}
};
let current_incarnation = state
.protocol
.read()
.await
.sessions
.get(&name)
.map(|session| session.metadata.session_incarnation);
if hints
.session_incarnation
.is_some_and(|expected| current_incarnation != Some(expected))
{
return json!({
"delivered": false,
"outcome": "superseded",
"error": "backend readiness belongs to a replaced session incarnation",
});
}
let identity = crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: backend_sid.clone(),
};
if let Some(generation) = attestation_generation {
state
.consume_local_backend_pane_attestation(&identity, generation)
.await;
} else if let Some(attestation) = state.local_backend_pane_attestation(&identity).await {
state
.consume_local_backend_pane_attestation(&identity, attestation.generation())
.await;
}
let delivered = deliver_pending_prompt(state, &name).await;
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %name,
delivered,
"backend session ready complete"
);
json!({
"delivered": delivered,
"session": name,
"session_incarnation": current_incarnation.map(|incarnation| incarnation.to_string()),
})
}
fn validate_backend_session_id_boundary(backend_sid: &str) -> Option<String> {
crate::daemon_protocol::validate_backend_session_id_boundary(backend_sid)
}
async fn auto_provision_from_backend_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
dir: &str,
) -> Option<String> {
let target_project = crate::project_identity::resolve_project_identity_async(dir)
.await
.ok()?;
{
let proto = state.protocol.read().await;
match resolve_opencode_backend_identity(&proto, backend_sid) {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => {
return Some(session_id);
}
crate::daemon_protocol::BackendIdentityResolution::NotFound => {}
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. }
| crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => {
return None;
}
}
}
let panes = state.list_assistant_panes().await;
let registered_panes: std::collections::HashSet<String> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| matches!(s.origin, crate::daemon_protocol::Origin::Local))
.filter_map(|s| s.pane.clone())
.collect()
};
let opencode_process_names = state
.backends
.get("opencode")
.map(|backend| {
backend
.process_names()
.iter()
.map(|name| (*name).to_string())
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
let mut candidates = Vec::new();
for pane in panes {
if registered_panes.contains(&pane.pane_id)
|| !pane
.process_name
.as_deref()
.is_some_and(|name| opencode_process_names.contains(name))
{
continue;
}
let Some(path) = pane.pane_current_path.as_deref() else {
continue;
};
let Ok(pane_project) = crate::project_identity::resolve_project_identity_async(path).await
else {
continue;
};
if pane_project.canonical_repository == target_project.canonical_repository {
candidates.push(pane.pane_id);
}
}
let pane_id = match candidates.len() {
1 => candidates.into_iter().next().unwrap(),
0 => {
tracing::warn!(
"auto-provision declined: no tmux pane running opencode in dir {dir} for backend_session_id {backend_sid}"
);
return None;
}
n => {
tracing::warn!(
"auto-provision declined: {n} opencode panes in dir {dir}; cannot map backend_session_id {backend_sid} unambiguously"
);
return None;
}
};
register_auto_provisioned_session(state, backend_sid, &pane_id, &target_project).await
}
async fn auto_provision_with_explicit_pane(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane: &str,
cwd: &str,
) -> Option<String> {
if cwd.is_empty() || cwd == "/" || !cwd.starts_with('/') {
tracing::warn!(
"auto-provision declined: invalid hint cwd {cwd:?} (must be absolute, non-empty, non-root) for backend_session_id {backend_sid}"
);
return None;
}
let Ok(project) = crate::project_identity::resolve_project_identity_async(cwd).await else {
tracing::warn!(
"auto-provision declined: could not resolve hinted cwd {cwd:?} for backend_session_id {backend_sid}"
);
return None;
};
{
let proto = state.protocol.read().await;
match resolve_opencode_backend_identity(&proto, backend_sid) {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => {
return Some(session_id);
}
crate::daemon_protocol::BackendIdentityResolution::NotFound => {}
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. }
| crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => {
return None;
}
}
}
let Some(backend_dir) = lookup_opencode_session_dir(state, backend_sid).await else {
tracing::warn!(
"auto-provision declined: backend_session_id {backend_sid} is unknown to opencode serve"
);
return None;
};
let Ok(backend_project) =
crate::project_identity::resolve_project_identity_async(&backend_dir).await
else {
return None;
};
if backend_project.canonical_repository != project.canonical_repository {
tracing::warn!(
"auto-provision declined: backend_session_id {backend_sid} belongs to dir {}, not hinted cwd {}",
backend_project.project_dir,
project.project_dir
);
return None;
}
let panes = state.list_assistant_panes().await;
let Some(hinted_pane) = panes.iter().find(|p| p.pane_id == pane) else {
tracing::warn!(
"auto-provision declined: hint pane {pane} is not among current assistant panes (backend_session_id {backend_sid})"
);
return None;
};
let opencode_process_names = state
.backends
.get("opencode")
.map(|backend| {
backend
.process_names()
.iter()
.map(|name| (*name).to_string())
.collect::<std::collections::HashSet<_>>()
})
.unwrap_or_default();
let pane_matches_opencode = hinted_pane
.process_name
.as_deref()
.is_some_and(|name| opencode_process_names.contains(name));
if !pane_matches_opencode {
tracing::warn!(
"auto-provision declined: hint pane {pane} is not running opencode (backend_session_id {backend_sid})"
);
return None;
}
let cwd_matches_pane = match hinted_pane.pane_current_path.as_deref() {
Some(path) => crate::project_identity::resolve_project_identity_async(path)
.await
.is_ok_and(|pane_project| {
pane_project.canonical_repository == project.canonical_repository
}),
None => false,
};
if !cwd_matches_pane {
tracing::warn!(
"auto-provision declined: hint pane {pane} is not in hinted cwd {} (backend_session_id {backend_sid})",
project.project_dir
);
return None;
}
{
let proto = state.protocol.read().await;
let already_bound = proto.sessions.values().any(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.pane.as_deref() == Some(pane)
});
if already_bound {
tracing::warn!(
"auto-provision declined: hint pane {pane} is already bound to another local session (backend_session_id {backend_sid})"
);
return None;
}
}
register_auto_provisioned_session(state, backend_sid, pane, &project).await
}
async fn register_auto_provisioned_session(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
pane_id: &str,
project: &crate::project_identity::ProjectIdentity,
) -> Option<String> {
let basename = std::path::Path::new(&project.project_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
let base_id = crate::state::sanitize_session_id(basename);
if base_id.is_empty() {
tracing::warn!(
"auto-provision declined: could not derive a session id from dir {dir} (basename='{basename}')",
dir = project.project_dir
);
return None;
}
let preferred_id = state.pane_last_session_id(pane_id).await.unwrap_or(base_id);
let id = {
let proto = state.protocol.read().await;
match resolve_opencode_backend_identity(&proto, backend_sid) {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => {
return Some(session_id);
}
crate::daemon_protocol::BackendIdentityResolution::NotFound => {}
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. }
| crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => {
return None;
}
}
crate::state::resolve_unique_session_id(
&proto.sessions,
&proto.lifecycle_leases,
&preferred_id,
Some(pane_id),
)
};
tracing::info!(
"auto-provisioned session '{id}' for pane {pane_id} / backend_session_id {backend_sid} (dir: {})",
project.project_dir
);
let metadata = crate::daemon_protocol::SessionMeta {
project_dir: Some(project.project_dir.clone()),
canonical_project_identity: Some(project.canonical_repository.clone()),
role: Some(format!("working on {basename}")),
backend: Some("opencode".into()),
backend_session_id: Some(backend_sid.to_string()),
..Default::default()
};
let effects = state
.apply_and_execute(crate::daemon_protocol::Event::RegisterIfPaneUnbound {
id: id.clone(),
pane: pane_id.to_string(),
expected_backend_session_id: Some(backend_sid.to_string()),
expected_orphaned_marker_owner: None,
metadata,
})
.await;
let proto = state.protocol.read().await;
auto_provision_register_result(&proto, backend_sid, &id, &effects)
}
fn auto_provision_register_result(
proto: &crate::daemon_protocol::DaemonState,
backend_sid: &str,
id: &str,
effects: &[crate::daemon_protocol::Effect],
) -> Option<String> {
if effects.iter().any(|effect| {
matches!(
effect,
crate::daemon_protocol::Effect::RegisterOk { session_id, .. } if session_id == id
)
}) {
Some(id.to_string())
} else if effects.iter().any(|effect| {
matches!(
effect,
crate::daemon_protocol::Effect::RegisterFailed { session_id, .. } if session_id == id
)
}) {
match resolve_opencode_backend_identity(proto, backend_sid) {
crate::daemon_protocol::BackendIdentityResolution::Resolved { session_id } => {
Some(session_id)
}
crate::daemon_protocol::BackendIdentityResolution::NotFound
| crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. }
| crate::daemon_protocol::BackendIdentityResolution::IncompleteLegacy { .. } => None,
}
} else {
None
}
}
fn resolve_opencode_backend_identity(
proto: &crate::daemon_protocol::DaemonState,
backend_sid: &str,
) -> crate::daemon_protocol::BackendIdentityResolution {
proto.resolve_backend_identity(&crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: backend_sid.into(),
})
}
fn disambiguate_adoption_candidates(
backend_sid: &str,
dir: &str,
candidates: Vec<String>,
) -> Option<String> {
match candidates.len() {
0 => None,
1 => candidates.into_iter().next(),
n => {
tracing::warn!(
"refusing to adopt backend_session_id {backend_sid}: {n} ambiguous candidates in dir {dir}: {candidates:?}"
);
None
}
}
}
async fn lookup_opencode_session_dir(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
) -> Option<String> {
let port = state.opencode_serve_port();
let url = format!("http://127.0.0.1:{port}/session/{backend_sid}");
let resp = state
.http_client
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.await
.map_err(|e| {
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
port,
error = %e,
"opencode session dir lookup request failed"
);
e
})
.ok()?;
if !resp.status().is_success() {
tracing::warn!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
port,
status = %resp.status(),
"opencode session dir lookup returned non-success"
);
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
let dir = body["directory"].as_str().map(str::to_string);
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
port,
directory = ?dir,
"opencode session dir lookup complete"
);
dir
}
fn parse_opencode_model(model: &str) -> Option<(String, String)> {
let trimmed = model.trim();
let (provider, model_id) = trimmed.split_once('/')?;
let provider = provider.trim();
let model_id = model_id.trim();
if provider.is_empty() || model_id.is_empty() {
return None;
}
Some((provider.to_string(), model_id.to_string()))
}
fn parse_opencode_config_model(config: &serde_json::Value) -> Option<(String, String)> {
config.get("model")?.as_str().and_then(parse_opencode_model)
}
async fn resolve_opencode_compact_model(
state: &std::sync::Arc<crate::state::AppState>,
session_model: Option<&str>,
project_dir: Option<&str>,
) -> Option<(String, String)> {
if let Some(m) = session_model
&& let Some(pair) = parse_opencode_model(m)
{
return Some(pair);
}
let port = state.opencode_serve_port();
let url = format!("http://127.0.0.1:{port}/config");
let mut req = state
.http_client
.get(&url)
.timeout(std::time::Duration::from_secs(5));
if let Some(dir) = project_dir {
req = req.header("x-opencode-directory", dir);
}
let resp = req.send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
parse_opencode_config_model(&body)
}
async fn adopt_backend_session_id(
state: &std::sync::Arc<crate::state::AppState>,
backend_sid: &str,
) -> Option<String> {
let dir = lookup_opencode_session_dir(state, backend_sid).await?;
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
dir,
"adoption resolved opencode session directory"
);
let candidates: Vec<OpenCodeAdoptionCandidate> = {
let proto = state.protocol.read().await;
proto
.sessions
.values()
.filter(|s| {
matches!(s.origin, crate::daemon_protocol::Origin::Local)
&& s.metadata.project_dir.as_deref() == Some(dir.as_str())
&& s.metadata.backend.is_none()
&& s.metadata.backend_session_id.is_none()
&& s.metadata.session_start_credential.is_none()
})
.map(|s| OpenCodeAdoptionCandidate {
session_id: s.id.clone(),
backend: s.metadata.backend.clone(),
backend_session_id: s.metadata.backend_session_id.clone(),
session_start_credential: s.metadata.session_start_credential.clone(),
incarnation: s.metadata.session_incarnation,
})
.collect()
};
let candidate_ids = candidates
.iter()
.map(|candidate| candidate.session_id.clone())
.collect();
let session_id = disambiguate_adoption_candidates(backend_sid, &dir, candidate_ids)?;
let candidate = candidates
.into_iter()
.find(|candidate| candidate.session_id == session_id)
.expect("selected adoption candidate came from this list");
tracing::info!(
target: "ouija::api::backend_session_ready",
backend_session_id = %backend_sid,
session = %session_id,
"adoption selected existing ouija session"
);
tracing::info!(
"adopting backend_session_id {backend_sid} for session {session_id} (dir: {dir})"
);
commit_opencode_adoption_if_unchanged(state, candidate, backend_sid).await
}
#[derive(Clone, Debug)]
struct OpenCodeAdoptionCandidate {
session_id: String,
backend: Option<String>,
backend_session_id: Option<String>,
session_start_credential: Option<String>,
incarnation: crate::daemon_protocol::SessionIncarnation,
}
async fn commit_opencode_adoption_if_unchanged(
state: &std::sync::Arc<crate::state::AppState>,
candidate: OpenCodeAdoptionCandidate,
backend_sid: &str,
) -> Option<String> {
let owner = crate::daemon_protocol::ResourceOwner {
session_id: candidate.session_id.clone(),
incarnation: candidate.incarnation,
};
let event = crate::daemon_protocol::Event::AdoptBackend {
id: candidate.session_id.clone(),
backend: "opencode".into(),
backend_session_id: backend_sid.to_string(),
expected_backend_session_id: candidate.backend_session_id.clone(),
expected_session_start_credential: candidate.session_start_credential.clone(),
};
let effects = state
.apply_owned_event_if(&owner, event, |session| {
matches!(session.origin, crate::daemon_protocol::Origin::Local)
&& session.metadata.backend == candidate.backend
&& session.metadata.backend_session_id == candidate.backend_session_id
&& session.metadata.session_start_credential == candidate.session_start_credential
&& session.metadata.session_incarnation == candidate.incarnation
})
.await;
if effects.is_empty() {
return None;
}
let proto = state.protocol.read().await;
matches!(
resolve_opencode_backend_identity(&proto, backend_sid),
crate::daemon_protocol::BackendIdentityResolution::Resolved {
session_id: ref resolved_id
} if resolved_id == &candidate.session_id
)
.then_some(candidate.session_id)
}
pub async fn list_projects(
State(state): State<SharedState>,
) -> axum::Json<Vec<crate::project_index::ProjectInfo>> {
let index = state.project_index.read().await;
let mut projects: Vec<_> = index.values().cloned().collect();
projects.sort_by(|a, b| a.name.cmp(&b.name));
axum::Json(projects)
}
#[derive(Deserialize)]
pub struct ClearReminderBody {
pub from: String,
pub clearing_id: u64,
}
pub async fn clear_reminder(
State(state): State<SharedState>,
Json(body): Json<ClearReminderBody>,
) -> (StatusCode, Json<serde_json::Value>) {
state
.notify_agent(
&body.from,
crate::session_agent::SessionMsg::ClearReminder {
clearing_id: body.clearing_id,
},
)
.await;
(
StatusCode::OK,
Json(json!({
"cleared": body.clearing_id,
"session": body.from,
"hint": "Reminder paused. It will resume after new activity (incoming message, hook fire, etc.)."
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{Router, routing::get};
use tokio::net::TcpListener;
#[tokio::test]
async fn status_and_single_session_expose_incarnation_and_parent_observability() {
let state = crate::state::AppState::new_for_test();
state.protocol.write().await.sessions.insert(
"worker".into(),
crate::daemon_protocol::SessionEntry {
id: "worker".into(),
metadata: crate::daemon_protocol::SessionMeta {
session_incarnation: crate::daemon_protocol::SessionIncarnation(u64::MAX),
parent_session: Some("manual-root".into()),
fresh_context_after_active_secs: Some(3_600),
active_context_accumulated_secs: 1_234,
active_context_segment_started_at: Some(1_700_000_000),
active_context_restart_due: true,
active_context_accounting_provisional: true,
..Default::default()
},
..Default::default()
},
);
let Json(all) = status(State(state.clone())).await;
assert_eq!(
all["sessions"][0]["session_incarnation"],
u64::MAX.to_string()
);
assert_eq!(all["sessions"][0]["parent_session"], "manual-root");
assert_eq!(all["sessions"][0]["fresh_context_after_active_secs"], 3_600);
assert_eq!(all["sessions"][0]["active_context_accumulated_secs"], 1_234);
assert_eq!(all["sessions"][0]["active_context_segment_open"], true);
assert_eq!(all["sessions"][0]["active_context_restart_due"], true);
assert_eq!(
all["sessions"][0]["active_context_accounting_provisional"],
true
);
let (code, Json(one)) =
get_session(State(state), axum::extract::Path("worker".to_string())).await;
assert_eq!(code, StatusCode::OK);
assert_eq!(one["session_incarnation"], u64::MAX.to_string());
assert_eq!(one["parent_session"], "manual-root");
assert_eq!(one["fresh_context_after_active_secs"], 3_600);
assert_eq!(one["active_context_accumulated_secs"], 1_234);
assert_eq!(one["active_context_segment_open"], true);
assert_eq!(one["active_context_restart_due"], true);
assert_eq!(one["active_context_accounting_provisional"], true);
}
#[test]
fn opencode_first_turn_correlates_transformed_prompt_by_message_id() {
let pending = serde_json::json!([
{"info":{"id":"u1","role":"user"},"parts":[{"type":"text","text":"<msg from=\"parent\" id=\"42\" reply=\"true\">do it\n\nReminder: report back</msg>"}]},
{"info":{"id":"a1","role":"assistant","parentID":"u1","providerID":"anthropic","modelID":"sonnet","time":{}},"parts":[]}
]);
assert_eq!(
classify_opencode_first_turn(&pending, Some(41), None, None),
None
);
assert_eq!(
classify_opencode_first_turn(&pending, Some(42), None, None),
None
);
let complete = serde_json::json!([
{"info":{"id":"u1","role":"user"},"parts":[{"type":"text","text":"<msg from=\"parent\" id=\"42\" reply=\"true\">do it\n\nReminder: report back</msg>"}]},
{"info":{"id":"a1","role":"assistant","parentID":"u1","providerID":"anthropic","modelID":"sonnet","time":{"completed":123}},"parts":[]}
]);
assert_eq!(
classify_opencode_first_turn(&complete, Some(42), None, Some("anthropic/sonnet")),
Some((
crate::start_outcome::StartOutcomeStatus::Started,
"OpenCode first assistant turn completed".into()
))
);
}
#[test]
fn opencode_first_turn_surfaces_error_and_model_mismatch() {
let failed = serde_json::json!([
{"info":{"id":"u1","role":"user"},"parts":[{"type":"text","text":"do it"}]},
{"info":{"id":"a1","role":"assistant","parentID":"u1","error":{"name":"ProviderError","message":"denied"}},"parts":[]}
]);
let (status, detail) =
classify_opencode_first_turn(&failed, None, Some("do it"), None).unwrap();
assert_eq!(status, crate::start_outcome::StartOutcomeStatus::Failed);
assert!(detail.contains("ProviderError"));
let mismatch = serde_json::json!([
{"info":{"id":"u1","role":"user"},"parts":[{"type":"text","text":"do it"}]},
{"info":{"id":"a1","role":"assistant","parentID":"u1","providerID":"openai","modelID":"gpt-5","finish":"stop"},"parts":[]}
]);
let (status, detail) =
classify_opencode_first_turn(&mismatch, None, Some("do it"), Some("anthropic/sonnet"))
.unwrap();
assert_eq!(status, crate::start_outcome::StartOutcomeStatus::Failed);
assert!(detail.contains("openai/gpt-5"));
assert!(detail.contains("anthropic/sonnet"));
}
#[tokio::test]
async fn restart_observation_uses_rotated_owner() {
let state = crate::state::AppState::new_for_test();
let incumbent = crate::daemon_protocol::ResourceOwner {
session_id: "worker".into(),
incarnation: crate::daemon_protocol::SessionIncarnation(7),
};
let target = crate::daemon_protocol::ResourceOwner {
session_id: "worker".into(),
incarnation: crate::daemon_protocol::SessionIncarnation(8),
};
state.protocol.write().await.sessions.insert(
"worker".into(),
crate::daemon_protocol::SessionEntry {
id: "worker".into(),
metadata: crate::daemon_protocol::SessionMeta {
session_incarnation: target.incarnation,
..Default::default()
},
..Default::default()
},
);
assert_eq!(
current_exact_or_newer_owner(&state, &incumbent).await,
target
);
}
#[tokio::test]
async fn omitted_restart_backend_uses_existing_session_backend() {
let state = crate::state::AppState::new_for_test();
let selected = state.backend_or_default(Some("opencode")).await;
assert_eq!(selected.name(), "opencode");
let existing_backend = Some("opencode".to_string());
let selected = state
.backend_or_default(None.or(existing_backend.as_deref()))
.await;
assert_eq!(selected.name(), "opencode");
}
#[test]
fn omitted_replacement_replays_existing_stored_prompt_for_observation() {
let existing = crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
prompt: Some("durable assignment".into()),
reminder: Some("report completion".into()),
..Default::default()
};
let replacement: Option<String> = None;
let effective = replacement.or_else(|| existing.prompt.clone());
let expected = effective.as_deref().map(|prompt| {
existing.effective_reminder("worker", None).map_or_else(
|| prompt.to_string(),
|reminder| format!("{prompt}\n\n{reminder}"),
)
});
assert_eq!(effective.as_deref(), Some("durable assignment"));
assert!(
expected
.as_deref()
.unwrap()
.starts_with("durable assignment\n\n")
);
}
#[test]
fn inference_timeout_precedes_default_cli_wait_budget() {
assert!(
OPENCODE_FIRST_TURN_TIMEOUT
< std::time::Duration::from_secs(crate::DEFAULT_START_WAIT_SECS)
);
}
async fn reserved_owner(
state: &crate::state::SharedState,
name: &str,
) -> crate::daemon_protocol::ResourceOwner {
match state.reserve_start(name).await.expect("reservation") {
crate::daemon_protocol::StartDisposition::Reserved(owner) => owner,
other => panic!("expected a fresh reservation, got {other:?}"),
}
}
async fn complete_reserved_start(
state: &crate::state::SharedState,
owner: &crate::daemon_protocol::ResourceOwner,
pane: &str,
) {
state.protocol.write().await.sessions.insert(
owner.session_id.clone(),
crate::daemon_protocol::SessionEntry {
id: owner.session_id.clone(),
pane: Some(pane.to_string()),
origin: crate::daemon_protocol::Origin::Local,
metadata: crate::daemon_protocol::SessionMeta {
session_incarnation: owner.incarnation,
..Default::default()
},
..Default::default()
},
);
state
.finalize_reserved_start(
owner,
Some(pane.to_string()),
crate::daemon_protocol::SessionMeta {
session_incarnation: owner.incarnation,
..Default::default()
},
)
.await
.expect("finalize");
state.abort_lifecycle(owner).await.expect("release lease");
}
#[tokio::test]
async fn a_start_still_running_is_in_progress_not_started() {
let state = crate::state::AppState::new_for_test();
let owner = reserved_owner(&state, "tp-64").await;
let status = resolve_start_status(&state, &owner).await;
assert_eq!(status.as_str(), "in_progress");
assert!(!status.is_terminal(), "an in-flight start is not terminal");
}
#[tokio::test]
async fn a_successful_start_reports_started_for_its_exact_owner() {
let state = crate::state::AppState::new_for_test();
let owner = reserved_owner(&state, "tp-64").await;
complete_reserved_start(&state, &owner, "%151").await;
let recorded = classify_completed_start(&state, &owner).await;
assert_eq!(recorded, crate::start_outcome::StartOutcomeStatus::Started);
state.start_outcomes.record(
&owner,
recorded,
"started 'tp-64' in /tmp/tp-64 (pane %151)".into(),
);
let status = resolve_start_status(&state, &owner).await;
assert_eq!(status.as_str(), "started");
assert!(status.is_terminal());
assert_eq!(
status.detail(),
Some("started 'tp-64' in /tmp/tp-64 (pane %151)")
);
}
#[tokio::test]
async fn a_failed_start_reports_the_logged_reason_not_success() {
let state = crate::state::AppState::new_for_test();
let owner = reserved_owner(&state, "tp-64").await;
state.abort_lifecycle(&owner).await.expect("abort");
let recorded = classify_completed_start(&state, &owner).await;
assert_eq!(recorded, crate::start_outcome::StartOutcomeStatus::Failed);
let reason = "start failed: OpenCode attach setup failed for 'tp-64' (pane %150)";
state.start_outcomes.record(&owner, recorded, reason.into());
let status = resolve_start_status(&state, &owner).await;
assert_eq!(status.as_str(), "failed");
assert_eq!(status.detail(), Some(reason));
}
#[tokio::test]
async fn a_superseded_owner_never_receives_the_newer_incarnation_outcome() {
let state = crate::state::AppState::new_for_test();
let first = reserved_owner(&state, "tp-64").await;
state.abort_lifecycle(&first).await.expect("abort");
let second = reserved_owner(&state, "tp-64").await;
assert!(second.incarnation > first.incarnation);
complete_reserved_start(&state, &second, "%151").await;
state.start_outcomes.record(
&second,
crate::start_outcome::StartOutcomeStatus::Started,
"started 'tp-64' in /tmp/tp-64 (pane %151)".into(),
);
let first_status = resolve_start_status(&state, &first).await;
assert_eq!(first_status.as_str(), "superseded");
assert_eq!(
resolve_start_status(&state, &second).await.as_str(),
"started"
);
}
#[tokio::test]
async fn start_status_endpoint_scopes_the_answer_to_the_ticket_incarnation() {
let state = crate::state::AppState::new_for_test();
let owner = reserved_owner(&state, "tp-64").await;
complete_reserved_start(&state, &owner, "%151").await;
let (code, Json(body)) = start_status(
State(state.clone()),
Query(StartStatusQuery {
session: "tp-64".into(),
incarnation: owner.incarnation.to_string(),
}),
)
.await;
assert_eq!(code, StatusCode::OK);
assert_eq!(body["status"], "started");
assert_eq!(body["incarnation"], owner.incarnation.to_string());
assert_eq!(body["terminal"], true);
let (code, Json(body)) = start_status(
State(state.clone()),
Query(StartStatusQuery {
session: "tp-64".into(),
incarnation: "not-a-number".into(),
}),
)
.await;
assert_eq!(code, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("incarnation"));
let (_, Json(body)) = start_status(
State(state),
Query(StartStatusQuery {
session: "tp-64".into(),
incarnation: (owner.incarnation.0 - 1).to_string(),
}),
)
.await;
assert_eq!(body["status"], "superseded");
}
#[tokio::test]
async fn an_unknown_ticket_is_not_reported_as_started() {
let state = crate::state::AppState::new_for_test();
let ghost = crate::daemon_protocol::ResourceOwner {
session_id: "never-existed".into(),
incarnation: crate::daemon_protocol::SessionIncarnation(1),
};
let status = resolve_start_status(&state, &ghost).await;
assert_eq!(status.as_str(), "unknown");
assert!(!status.is_terminal());
}
#[tokio::test]
async fn retained_start_outcomes_do_not_grow_without_limit() {
let state = crate::state::AppState::new_for_test();
for incarnation in 0..(crate::start_outcome::MAX_RETAINED_START_OUTCOMES as u64 * 3) {
state.start_outcomes.record(
&crate::daemon_protocol::ResourceOwner {
session_id: format!("s-{incarnation}"),
incarnation: crate::daemon_protocol::SessionIncarnation(incarnation),
},
crate::start_outcome::StartOutcomeStatus::Started,
"started".into(),
);
}
assert_eq!(
state.start_outcomes.len(),
crate::start_outcome::MAX_RETAINED_START_OUTCOMES
);
}
#[test]
fn restart_outcomes_have_stable_wire_spellings() {
assert_eq!(
restart_outcome_str(crate::nostr_transport::RestartOutcome::Restarted),
"restarted"
);
assert_eq!(
restart_outcome_str(crate::nostr_transport::RestartOutcome::Failed),
"failed"
);
assert_eq!(
restart_outcome_str(crate::nostr_transport::RestartOutcome::Superseded),
"superseded"
);
}
#[test]
fn a_restart_outcome_maps_onto_the_recorded_start_outcome() {
assert_eq!(
start_outcome_for_restart(crate::nostr_transport::RestartOutcome::Restarted),
crate::start_outcome::StartOutcomeStatus::Started
);
assert_eq!(
start_outcome_for_restart(crate::nostr_transport::RestartOutcome::Failed),
crate::start_outcome::StartOutcomeStatus::Failed
);
assert_eq!(
start_outcome_for_restart(crate::nostr_transport::RestartOutcome::Superseded),
crate::start_outcome::StartOutcomeStatus::Superseded
);
}
fn backend_identity_request(backend: &str, session_id: &str) -> BackendIdentityRequest {
BackendIdentityRequest {
backend: backend.into(),
session_id: session_id.into(),
}
}
#[tokio::test]
async fn backend_identity_resolve_maps_zero_legacy_and_ambiguous_outcomes() {
let state = crate::state::AppState::new_for_test();
let request = backend_identity_request("future", "native-1");
let (status, Json(body)) =
resolve_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["outcome"], "not_found");
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "resolved".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("future".into()),
backend_session_id: Some("native-1".into()),
..Default::default()
},
})
.await;
let (status, Json(body)) =
resolve_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "resolved");
assert_eq!(body["session_id"], "resolved");
state.protocol.write().await.sessions.clear();
state.protocol.write().await.sessions.insert(
"legacy".into(),
crate::daemon_protocol::SessionEntry {
id: "legacy".into(),
metadata: crate::daemon_protocol::SessionMeta {
backend_session_id: Some("native-1".into()),
..Default::default()
},
..Default::default()
},
);
let (status, Json(body)) =
resolve_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["outcome"], "incomplete_legacy");
let mut proto = state.protocol.write().await;
proto.sessions.clear();
for id in ["first", "second"] {
proto.sessions.insert(
id.into(),
crate::daemon_protocol::SessionEntry {
id: id.into(),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("future".into()),
backend_session_id: Some("native-1".into()),
..Default::default()
},
..Default::default()
},
);
}
drop(proto);
let (status, Json(body)) = resolve_backend_identity(State(state), Json(request)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["outcome"], "ambiguous");
}
#[tokio::test]
async fn backend_identity_bind_requires_proof_and_maps_terminal_outcomes() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "target".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
session_start_credential: Some("launch-proof".into()),
..Default::default()
},
})
.await;
let identity = backend_identity_request("future", "native-1");
let (status, Json(body)) = bind_backend_identity(
State(state.clone()),
Json(BackendIdentityBindRequest {
target_session_id: "target".into(),
identity: identity.clone(),
launch_credential: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["outcome"], "missing_launch_proof");
let request = BackendIdentityBindRequest {
target_session_id: "target".into(),
identity: identity.clone(),
launch_credential: Some("launch-proof".into()),
};
let (status, Json(body)) =
bind_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "bound");
let (status, Json(body)) = bind_backend_identity(State(state.clone()), Json(request)).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "already_bound");
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "conflict".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
session_start_credential: Some("conflict-proof".into()),
..Default::default()
},
})
.await;
let (status, Json(body)) = bind_backend_identity(
State(state.clone()),
Json(BackendIdentityBindRequest {
target_session_id: "conflict".into(),
identity: identity.clone(),
launch_credential: Some("conflict-proof".into()),
}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["outcome"], "identity_bound_to_other");
let (status, Json(body)) = bind_backend_identity(
State(state.clone()),
Json(BackendIdentityBindRequest {
target_session_id: "dead".into(),
identity: identity.clone(),
launch_credential: Some("proof".into()),
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["outcome"], "target_not_found");
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "stale".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, Json(body)) = bind_backend_identity(
State(state.clone()),
Json(BackendIdentityBindRequest {
target_session_id: "stale".into(),
identity,
launch_credential: Some("expired-proof".into()),
}),
)
.await;
assert_eq!(status, StatusCode::GONE);
assert_eq!(body["outcome"], "credential_expired");
}
#[tokio::test]
async fn backend_identity_recovery_maps_success_and_replay_without_respawn() {
let state = crate::state::AppState::new_for_test();
let project = tempfile::tempdir().unwrap();
let project_dir = project.path().to_string_lossy().into_owned();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "divine-invite-darshan".into(),
pane: Some("%712".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some(project_dir.clone()),
canonical_project_identity: Some(project_dir.clone()),
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["divine-invite-darshan"].owner();
*state.cached_assistant_panes.write().await = vec![crate::tmux::TmuxPane {
pane_id: "%712".into(),
session_name: "divine-invite-darshan".into(),
pane_current_path: Some(project_dir),
process_name: Some("codex".into()),
}];
state.set_backend_recovery_test_inspection(
crate::tmux::ManagedPaneInspection::MarkerOwner(owner.clone()),
);
let request = BackendIdentityRecoveryRequest {
target_session_id: "divine-invite-darshan".into(),
identity: backend_identity_request("codex-cli", "same-running-thread"),
caller: crate::state::BackendRecoveryCallerEvidence {
pane: Some("%712".into()),
pane_var_id: Some("divine-invite-darshan".into()),
env_id: Some("divine-invite-darshan".into()),
},
};
let (status, Json(body)) =
recover_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "recovered");
assert_eq!(body["session_id"], "divine-invite-darshan");
assert_eq!(body["session_incarnation"], owner.incarnation.to_string());
let (status, Json(body)) =
recover_backend_identity(State(state.clone()), Json(request)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["outcome"], "target_not_blank");
assert!(!body.to_string().contains("same-running-thread"));
assert_eq!(
state.protocol.read().await.sessions["divine-invite-darshan"]
.metadata
.backend_session_id
.as_deref(),
Some("same-running-thread")
);
}
#[tokio::test]
async fn backend_identity_recovery_rejects_remote_incomplete_and_sibling_evidence() {
let state = crate::state::AppState::new_for_test();
state.protocol.write().await.sessions.insert(
"remote-target".into(),
crate::daemon_protocol::SessionEntry {
id: "remote-target".into(),
origin: crate::daemon_protocol::Origin::Remote("npub1peer".into()),
..Default::default()
},
);
let (status, Json(body)) = recover_backend_identity(
State(state.clone()),
Json(BackendIdentityRecoveryRequest {
target_session_id: "remote-target".into(),
identity: backend_identity_request("codex-cli", "thread"),
caller: Default::default(),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["outcome"], "target_not_local");
let (status, _) = recover_backend_identity(
State(state),
Json(BackendIdentityRecoveryRequest {
target_session_id: "remote-target".into(),
identity: backend_identity_request("codex-cli", ""),
caller: crate::state::BackendRecoveryCallerEvidence {
pane: Some("%sibling".into()),
pane_var_id: Some("sibling".into()),
env_id: Some("sibling".into()),
},
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn backend_identity_repair_requires_named_legacy_local_session() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = repair_backend_identity(
State(state),
Json(BackendIdentityRepairRequest {
session_id: "missing".into(),
backend: "codex-cli".into(),
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["outcome"], "target_not_found");
}
#[tokio::test]
async fn backend_identity_repair_rejects_duplicate_in_progress_request() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "legacy".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
..Default::default()
},
})
.await;
let request = BackendIdentityRepairRequest {
session_id: "legacy".into(),
backend: "codex-cli".into(),
};
let (status, _) =
repair_backend_identity(State(state.clone()), Json(request.clone())).await;
assert_eq!(status, StatusCode::ACCEPTED);
let (status, Json(body)) = repair_backend_identity(State(state), Json(request)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["outcome"], "repair_in_progress");
}
async fn failed_repair_reservation_can_retry(
stage_before_failure: bool,
) -> crate::daemon_protocol::BackendRepairPhase {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "legacy".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
..Default::default()
},
})
.await;
let first_reservation = {
let mut protocol = state.protocol.write().await;
let session = protocol.sessions.get_mut("legacy").unwrap();
let reservation = crate::daemon_protocol::BackendRepairReservation {
original_incarnation: session.metadata.session_incarnation,
restart_generation: session.metadata.restart_generation.saturating_add(1),
phase: crate::daemon_protocol::BackendRepairPhase::PreStage,
};
session.metadata.backend_repair_reservation = Some(reservation.clone());
reservation
};
if stage_before_failure {
let effects = {
let mut protocol = state.protocol.write().await;
protocol.apply(crate::daemon_protocol::Event::StageFreshLaunch {
id: "legacy".into(),
backend: "codex-cli".into(),
session_start_credential: Some("failed-proof".into()),
expected_repair_reservation: Some(first_reservation.clone()),
})
};
assert!(!effects.is_empty(), "the first launch must stage");
}
let failed_phase = {
let mut protocol = state.protocol.write().await;
let session = protocol.sessions.get_mut("legacy").unwrap();
let phase = session
.metadata
.backend_repair_reservation
.as_ref()
.unwrap()
.phase;
assert_eq!(
classify_repair_restart_outcome(
Some(&mut *session),
&first_reservation,
crate::nostr_transport::RestartOutcome::Failed,
),
RepairRestartOutcome::RestartFailed
);
assert!(session.metadata.backend_repair_reservation.is_none());
phase
};
let retry = {
let mut protocol = state.protocol.write().await;
let session = protocol.sessions.get_mut("legacy").unwrap();
let retry = crate::daemon_protocol::BackendRepairReservation {
original_incarnation: session.metadata.session_incarnation,
restart_generation: session.metadata.restart_generation.saturating_add(1),
phase: crate::daemon_protocol::BackendRepairPhase::PreStage,
};
session.metadata.backend_repair_reservation = Some(retry.clone());
retry
};
let bound = {
let mut protocol = state.protocol.write().await;
let effects = protocol.apply(crate::daemon_protocol::Event::StageFreshLaunch {
id: "legacy".into(),
backend: "codex-cli".into(),
session_start_credential: Some("retry-proof".into()),
expected_repair_reservation: Some(retry),
});
assert!(!effects.is_empty(), "the retry must stage");
protocol.bind_backend_identity(
"legacy",
&crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "retry-thread".into(),
},
Some("retry-proof"),
)
};
assert!(matches!(
bound.outcome,
crate::daemon_protocol::BackendIdentityBindOutcome::Bound { .. }
));
assert!(
state.protocol.read().await.sessions["legacy"]
.metadata
.backend_repair_reservation
.is_none()
);
failed_phase
}
#[tokio::test]
async fn pre_stage_failed_repair_clears_its_reservation_and_allows_retry() {
assert_eq!(
failed_repair_reservation_can_retry(false).await,
crate::daemon_protocol::BackendRepairPhase::PreStage
);
}
#[tokio::test]
async fn post_stage_failed_repair_clears_its_reservation_and_allows_retry() {
assert_eq!(
failed_repair_reservation_can_retry(true).await,
crate::daemon_protocol::BackendRepairPhase::Staged
);
}
fn pending_prompt(pane_id: &str, prompt: &str) -> crate::state::PendingPrompt {
crate::state::PendingPrompt::new(pane_id.into(), prompt.into(), None)
}
fn pending_opencode_prompt(
pane_id: &str,
prompt: &str,
backend_session_id: &str,
) -> crate::state::PendingPrompt {
crate::state::PendingPrompt::new(
pane_id.into(),
prompt.into(),
Some(backend_session_id.into()),
)
}
#[tokio::test]
async fn register_returns_conflict_when_protocol_rejects_duplicate_backend_session_id() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "owner".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_dup".into()),
..Default::default()
},
})
.await;
let body = serde_json::json!({
"id": "intruder",
"backend": "opencode",
"backend_session_id": "ses_dup"
});
let (status, Json(response)) = register(
State(state),
ConnectInfo("127.0.0.1:9000".parse().unwrap()),
HeaderMap::new(),
Bytes::from(body.to_string()),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(response["session_id"], "intruder");
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("backend_session_id ses_dup is already bound to session 'owner'"),
"protocol failure reason must be preserved, got: {response}"
);
}
#[test]
fn normalize_optional_string_passthrough() {
assert_eq!(
normalize_optional_string(Some("sonnet".into())),
Some("sonnet".into())
);
assert_eq!(normalize_optional_string(None), None);
}
#[test]
fn normalize_optional_string_trims_and_drops_empty() {
assert_eq!(normalize_optional_string(Some("".into())), None);
assert_eq!(normalize_optional_string(Some(" ".into())), None);
assert_eq!(normalize_optional_string(Some("\t\n ".into())), None);
assert_eq!(
normalize_optional_string(Some(" opus ".into())),
Some("opus".into())
);
}
#[test]
fn start_lifecycle_validation_requires_parent_choice() {
let mut body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","idle_policy":"keep-open"}"#).unwrap();
let err = validate_start_lifecycle(&mut body).unwrap_err();
assert!(
err.contains("--parent-session <SESSION_ID>"),
"error must teach parent-session choice, got: {err}"
);
assert!(
err.contains("--no-parent-session"),
"error must teach no-parent-session choice, got: {err}"
);
}
#[test]
fn start_lifecycle_validation_requires_idle_policy() {
let mut body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","no_parent_session":true}"#).unwrap();
let err = validate_start_lifecycle(&mut body).unwrap_err();
assert!(
err.contains("--when-done <keep-open|ask-parent|close>"),
"error must teach when-done choices, got: {err}"
);
}
#[test]
fn start_lifecycle_validation_rejects_manual_clear_reminder_commands() {
let mut body: SessionNameBody = serde_json::from_str(
r#"{
"name":"s",
"no_parent_session":true,
"idle_policy":"keep-open",
"reminder":"When done, run ouija clear-reminder 7"
}"#,
)
.unwrap();
let err = validate_start_lifecycle(&mut body).unwrap_err();
assert!(err.contains("ouija clear-reminder"));
assert!(
err.contains("generated"),
"error must explain that Ouija supplies the command, got: {err}"
);
}
#[tokio::test]
async fn start_session_rejects_manual_clear_reminder_commands_at_api_boundary() {
let state = crate::state::AppState::new_for_test();
let body: SessionNameBody = serde_json::from_str(
r#"{
"name":"s",
"no_parent_session":true,
"idle_policy":"keep-open",
"reminder":"When done, run ouija clear-reminder 7"
}"#,
)
.unwrap();
let (status, Json(response)) = start_session(State(state), Json(body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(response["error"].as_str().is_some_and(|error| {
error.contains("ouija clear-reminder") && error.contains("generated")
}));
}
#[test]
fn start_lifecycle_validation_rejects_ask_parent_without_parent() {
let mut body: SessionNameBody = serde_json::from_str(
r#"{"name":"s","no_parent_session":true,"idle_policy":"ask-parent-when-done"}"#,
)
.unwrap();
let err = validate_start_lifecycle(&mut body).unwrap_err();
assert!(
err.contains("ask-parent-when-done"),
"error must name the incompatible policy, got: {err}"
);
assert!(
err.contains("--parent-session <SESSION_ID>"),
"error must teach the fix, got: {err}"
);
}
#[test]
fn start_lifecycle_validation_accepts_parent_with_ask_parent() {
let mut body: SessionNameBody = serde_json::from_str(
r#"{"name":"s","parent_session":"parent","idle_policy":"ask-parent-when-done"}"#,
)
.unwrap();
validate_start_lifecycle(&mut body).expect("valid lifecycle policy");
assert_eq!(body.parent_session.as_deref(), Some("parent"));
}
fn assert_unknown_backend_response(status: StatusCode, body: &serde_json::Value) {
assert_eq!(status, StatusCode::BAD_REQUEST);
let error = body["error"].as_str().unwrap_or_default();
assert!(
error.contains("unknown backend 'codex'"),
"error must name the rejected backend, got: {error}"
);
assert!(
error.contains("codex-cli"),
"error must list the valid Codex backend name, got: {error}"
);
let valid_backends = body["valid_backends"]
.as_array()
.expect("valid_backends must be an array");
assert!(
valid_backends.iter().any(|name| name == "codex-cli"),
"valid_backends must include codex-cli, got: {valid_backends:?}"
);
}
#[tokio::test]
async fn start_session_rejects_unknown_backend_before_spawn() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = start_session(
State(state),
Json(SessionNameBody {
name: "wrong-codex".into(),
fresh: None,
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: Some("codex".into()),
model: None,
effort: None,
reminder: None,
parent_session: None,
no_parent_session: Some(true),
idle_policy: Some(crate::daemon_protocol::IdlePolicy::KeepOpen),
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
}),
)
.await;
assert_unknown_backend_response(status, &body);
}
#[tokio::test]
async fn start_session_does_not_spawn_when_same_id_lease_is_in_progress() {
let state = crate::state::AppState::new_for_test();
let reserved = state.reserve_start("same-id").await.unwrap();
assert!(matches!(
reserved,
crate::daemon_protocol::StartDisposition::Reserved(_)
));
let (status, Json(body)) = start_session(
State(state.clone()),
Json(SessionNameBody {
name: "same-id".into(),
fresh: None,
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: None,
model: None,
effort: None,
reminder: None,
parent_session: None,
no_parent_session: Some(true),
idle_policy: Some(crate::daemon_protocol::IdlePolicy::KeepOpen),
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
}),
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(body["status"], "start_in_progress");
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn start_session_rejects_zero_active_context_duration_at_api_ingress() {
let state = crate::state::AppState::new_for_test();
state.reserve_start("same-id").await.unwrap();
let body: SessionNameBody = serde_json::from_value(json!({
"name": "same-id",
"fresh_context_after_active_secs": 0,
"no_parent_session": true,
"idle_policy": "keep-open"
}))
.unwrap();
let (status, Json(response)) = start_session(State(state), Json(body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(response["error"].as_str().is_some_and(|error| {
error.contains("fresh_context_after_active_secs") && error.contains("greater than zero")
}));
}
#[tokio::test]
async fn restart_session_requires_fresh_for_active_context_duration_at_api_ingress() {
let state = crate::state::AppState::new_for_test();
state.reserve_start("same-id").await.unwrap();
let body: RestartSessionBody = serde_json::from_value(json!({
"name": "same-id",
"fresh": false,
"fresh_context_after_active_secs": 60
}))
.unwrap();
let (status, Json(response)) = restart_session(State(state), Json(body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(response["error"].as_str().is_some_and(|error| {
error.contains("fresh_context_after_active_secs") && error.contains("--fresh")
}));
}
#[tokio::test]
async fn start_session_requires_fresh_for_an_existing_active_context_policy_change() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "same-id".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
fresh_context_after_active_secs: Some(60),
..Default::default()
},
})
.await;
let body: SessionNameBody = serde_json::from_value(json!({
"name": "same-id",
"fresh_context_after_active_secs": 120,
"no_parent_session": true,
"idle_policy": "keep-open"
}))
.unwrap();
let (status, Json(response)) = start_session(State(state), Json(body)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(response["error"].as_str().is_some_and(|error| {
error.contains("fresh_context_after_active_secs") && error.contains("--fresh")
}));
}
#[tokio::test]
async fn kill_session_response_exposes_typed_outcome() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = kill_session(
State(state),
Json(SessionNameBody {
name: "missing".into(),
fresh: None,
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: None,
model: None,
effort: None,
reminder: None,
parent_session: None,
no_parent_session: None,
idle_policy: None,
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "failed");
assert_eq!(body["result"], "session 'missing' not found");
}
#[tokio::test]
async fn kill_session_response_reports_superseded_claim() {
let state = crate::state::AppState::new_for_test();
let owner = {
let mut protocol = state.protocol.write().await;
protocol.apply(crate::daemon_protocol::Event::Register {
id: "worker".into(),
pane: Some("%2".into()),
metadata: Default::default(),
});
protocol.sessions["worker"].owner()
};
assert_eq!(
state
.claim_existing_stop(&owner, "%2", false)
.await
.unwrap(),
crate::daemon_protocol::LifecycleMutationOutcome::Applied
);
let (status, Json(body)) = kill_session(
State(state),
Json(SessionNameBody {
name: "worker".into(),
fresh: None,
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: None,
model: None,
effort: None,
reminder: None,
parent_session: None,
no_parent_session: None,
idle_policy: None,
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["outcome"], "superseded");
assert!(
body["result"]
.as_str()
.unwrap()
.contains("backend exit was superseded")
);
}
#[tokio::test]
async fn restart_session_rejects_unknown_backend() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = restart_session(
State(state),
Json(RestartSessionBody {
session: SessionNameBody {
name: "wrong-codex".into(),
fresh: Some(true),
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: Some("codex".into()),
model: None,
effort: None,
reminder: None,
parent_session: None,
no_parent_session: None,
idle_policy: None,
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
},
suppress_stored_prompt: false,
one_shot_prompt: None,
}),
)
.await;
assert_unknown_backend_response(status, &body);
}
#[test]
fn restart_request_accepts_launch_scoped_prompt_fields() {
let body: RestartSessionBody = serde_json::from_value(json!({
"name": "worker",
"fresh": true,
"prompt": "replacement",
"suppress_stored_prompt": true,
"one_shot_prompt": "adopt token",
"backend": "codex-cli"
}))
.unwrap();
assert_eq!(body.session.prompt.as_deref(), Some("replacement"));
assert!(body.suppress_stored_prompt);
assert_eq!(body.one_shot_prompt.as_deref(), Some("adopt token"));
assert_eq!(body.session.backend.as_deref(), Some("codex-cli"));
}
#[test]
fn restart_request_deserializes_active_context_seconds() {
let body: RestartSessionBody = serde_json::from_value(json!({
"name": "worker",
"fresh": true,
"fresh_context_after_active_secs": 5400
}))
.unwrap();
assert_eq!(body.session.fresh_context_after_active_secs, Some(5_400));
}
#[tokio::test]
async fn restart_session_rejects_manual_clear_reminder_before_restart() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = restart_session(
State(state),
Json(RestartSessionBody {
session: SessionNameBody {
name: "restart-clear-reminder".into(),
fresh: Some(true),
fresh_context_after_active_secs: None,
worktree: None,
project_dir: None,
prompt: None,
from: None,
backend: None,
model: None,
effort: None,
reminder: Some("When done, run ouija clear-reminder 7".into()),
parent_session: None,
no_parent_session: None,
idle_policy: None,
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
},
suppress_stored_prompt: false,
one_shot_prompt: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().is_some_and(|error| {
error.contains("ouija clear-reminder") && error.contains("generated")
}));
}
#[tokio::test]
async fn start_existing_session_no_parent_clears_previous_parent() {
use axum::Json;
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
async fn create_session() -> Json<serde_json::Value> {
Json(serde_json::json!({ "id": "ses_new" }))
}
async fn prompt_async(
AxumState(captured): AxumState<StdArc<Mutex<Option<String>>>>,
Json(body): Json<serde_json::Value>,
) -> StatusCode {
*captured.lock().await = body["parts"][0]["text"].as_str().map(String::from);
StatusCode::NO_CONTENT
}
let captured = StdArc::new(Mutex::new(None));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session", post(create_session))
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(captured.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let config = crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
};
let state = crate::state::AppState::new(config);
{
let mut proto = state.protocol.write().await;
proto.sessions.insert(
"oc".into(),
crate::daemon_protocol::SessionEntry {
id: "oc".into(),
pane: None,
origin: crate::daemon_protocol::Origin::Local,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_old".into()),
parent_session: Some("old-parent".into()),
idle_policy: Some(crate::daemon_protocol::IdlePolicy::AskParentWhenDone),
..Default::default()
},
registered_at: 0,
active_context_due_boundary: Default::default(),
},
);
}
let (status, Json(body)) = start_session(
State(state.clone()),
Json(SessionNameBody {
name: "oc".into(),
fresh: None,
fresh_context_after_active_secs: None,
worktree: None,
project_dir: Some(dir.path().to_string_lossy().into_owned()),
prompt: Some("queued prompt".into()),
from: None,
backend: Some("opencode".into()),
model: None,
effort: None,
reminder: Some("new manual reminder".into()),
parent_session: None,
no_parent_session: Some(true),
idle_policy: Some(crate::daemon_protocol::IdlePolicy::KeepOpen),
branch: None,
base_branch: None,
delete_worktree: None,
keep_worktree: None,
force_reset: None,
}),
)
.await;
assert_eq!(status, StatusCode::ACCEPTED, "response: {body}");
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if captured.lock().await.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("restart prompt should be delivered");
let proto = state.protocol.read().await;
let metadata = &proto.sessions["oc"].metadata;
assert_eq!(metadata.backend_session_id.as_deref(), Some("ses_new"));
assert_eq!(metadata.parent_session, None);
assert_eq!(
metadata.idle_policy,
Some(crate::daemon_protocol::IdlePolicy::KeepOpen)
);
let prompt = captured.lock().await.clone().unwrap();
assert!(prompt.contains("Lifecycle policy: keep-open"));
assert!(!prompt.contains("old-parent"));
server.abort();
}
#[tokio::test]
async fn create_task_rejects_unknown_backend() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = create_task(
State(state),
Json(CreateTaskBody {
name: "wrong-codex-task".into(),
cron: "0 0 * * *".into(),
target_session: None,
message: Some("run".into()),
prompt: None,
reminder: None,
project_dir: None,
backend: Some("codex".into()),
model: None,
effort: None,
once: None,
backend_session_id: None,
on_fire: None,
}),
)
.await;
assert_unknown_backend_response(status, &body);
}
#[tokio::test]
async fn create_task_rejects_manual_clear_reminder_before_persistence() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = create_task(
State(state.clone()),
Json(CreateTaskBody {
name: "clear-reminder-task".into(),
cron: "0 0 * * *".into(),
target_session: None,
message: Some("run".into()),
prompt: None,
reminder: Some("When done, run ouija clear-reminder 7".into()),
project_dir: None,
backend: None,
model: None,
effort: None,
once: None,
backend_session_id: None,
on_fire: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.is_some_and(|error| error.contains("ouija clear-reminder"))
);
assert!(state.scheduled_tasks.read().await.is_empty());
}
#[tokio::test]
async fn create_inject_only_task_requires_explicit_target() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = create_task(
State(state.clone()),
Json(CreateTaskBody {
name: "context-audit".into(),
cron: "*/15 * * * *".into(),
target_session: Some(" ".into()),
message: Some("audit at your next safe boundary".into()),
prompt: None,
reminder: None,
project_dir: None,
backend: None,
model: None,
effort: None,
once: None,
backend_session_id: None,
on_fire: Some(crate::scheduler::OnFire::InjectOnly),
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.is_some_and(|error| error.contains("explicit target_session"))
);
assert!(state.scheduled_tasks.read().await.is_empty());
}
#[tokio::test]
async fn create_inject_only_task_persists_exact_target_and_mode() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) = create_task(
State(state.clone()),
Json(CreateTaskBody {
name: "context-audit".into(),
cron: "*/15 * * * *".into(),
target_session: Some("manual-root".into()),
message: Some("audit at your next safe boundary".into()),
prompt: None,
reminder: None,
project_dir: None,
backend: None,
model: None,
effort: None,
once: None,
backend_session_id: None,
on_fire: Some(crate::scheduler::OnFire::InjectOnly),
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {body}");
let tasks = state.scheduled_tasks.read().await;
let task = tasks.values().next().expect("task persisted");
assert_eq!(task.target_session.as_deref(), Some("manual-root"));
assert_eq!(task.on_fire, crate::scheduler::OnFire::InjectOnly);
drop(tasks);
let Json(listed) = list_tasks(State(state)).await;
assert_eq!(listed["tasks"][0]["on_fire"]["mode"], "inject_only");
assert_eq!(
listed["tasks"][0]["target_session"],
serde_json::json!("manual-root")
);
}
#[test]
fn disambiguate_single_candidate_adopts() {
let got = disambiguate_adoption_candidates("ses_x", "/repo", vec!["only".into()]);
assert_eq!(got.as_deref(), Some("only"));
}
#[test]
fn disambiguate_zero_candidates_fails() {
let got = disambiguate_adoption_candidates("ses_x", "/repo", vec![]);
assert!(got.is_none());
}
#[test]
fn disambiguate_multiple_candidates_fails_closed() {
let got = disambiguate_adoption_candidates(
"ses_x",
"/repo",
vec!["hub".into(), "hub-skill-probe".into()],
);
assert!(got.is_none());
}
#[test]
fn needle_for_127_0_0_1_encodes_little_endian_hex() {
let peer: SocketAddr = "127.0.0.1:45084".parse().unwrap();
assert_eq!(
needle_for_loopback_peer(peer).as_deref(),
Some("0100007F:B01C")
);
}
#[test]
fn parse_tcp_inode_finds_matching_local() {
let table = "\
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n\
0: 0100007F:B01C 0100007F:1EC8 01 00000000:00000000 00:00000000 00000000 1000 0 1234567 1 0000000000000000 20 4 20 10 -1\n\
1: 0100007F:1EC8 0100007F:B01C 01 00000000:00000000 00:00000000 00000000 1000 0 7654321 1 0000000000000000 20 4 20 10 -1\n";
assert_eq!(
parse_tcp_inode_for_local(table, "0100007F:B01C"),
Some(1234567)
);
assert_eq!(
parse_tcp_inode_for_local(table, "0100007F:1EC8"),
Some(7654321)
);
assert_eq!(parse_tcp_inode_for_local(table, "0100007F:FFFF"), None);
}
#[test]
fn parse_tcp_inode_skips_header_and_short_lines() {
let table = "sl local_address rem_address st\nshort line\n";
assert!(parse_tcp_inode_for_local(table, "0100007F:0001").is_none());
}
#[test]
fn extract_from_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"foo\"\ndescription = \"A test crate\"\n",
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("A test crate"));
}
#[test]
fn extract_from_package_json() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
r#"{"name":"foo","description":"A JS project"}"#,
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("A JS project"));
}
#[test]
fn extract_from_readme() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("README.md"),
"# My Project\n\nThis is a great project.\n",
)
.unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("This is a great project."));
}
#[test]
fn extract_cargo_toml_preferred_over_readme() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\ndescription = \"From cargo\"\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "# Title\n\nFrom readme\n").unwrap();
let desc = extract_project_description(dir.path().to_str().unwrap());
assert_eq!(desc.as_deref(), Some("From cargo"));
}
#[test]
fn extract_missing_files_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(extract_project_description(dir.path().to_str().unwrap()).is_none());
}
#[tokio::test]
async fn inject_for_opencode_pane_reports_tmux_delivery() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-live".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
..Default::default()
},
})
.await;
let (status, body) = inject(
State(state),
Json(InjectBody {
pane: "%oc".into(),
message: "hello raw tmux".into(),
vim_mode: false,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "injected");
assert_eq!(body["delivery"], "tmux");
}
#[tokio::test]
async fn inject_unregistered_pane_uses_raw_tmux_delivery() {
let state = crate::state::AppState::new_for_test();
let (status, body) = inject(
State(state),
Json(InjectBody {
pane: "%unregistered".into(),
message: "hello raw pane".into(),
vim_mode: false,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "injected");
assert_eq!(body["delivery"], "tmux");
}
#[tokio::test]
async fn force_pane_inject_uses_detected_backend_config_without_fake_session() {
let state = crate::state::AppState::new_for_test();
let target = resolve_force_pane_inject_target(&state, "%oc", Some("opencode".into())).await;
assert_eq!(target.session_id, None);
assert_eq!(target.inject_config.paste_settle_ms, 100);
assert!(!target.inject_config.use_inner_bracketed_paste);
assert_eq!(target.inject_config.startup_inject_delay_secs, 0);
assert_eq!(target.tui_pattern, None);
}
#[tokio::test]
async fn force_pane_inject_detects_backend_when_registered_metadata_has_no_backend() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "legacy".into(),
pane: Some("%legacy".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let target =
resolve_force_pane_inject_target(&state, "%legacy", Some("opencode".into())).await;
assert_eq!(target.session_id.as_deref(), Some("legacy"));
assert_eq!(target.inject_config.paste_settle_ms, 100);
assert!(!target.inject_config.use_inner_bracketed_paste);
assert_eq!(target.inject_config.startup_inject_delay_secs, 0);
assert_eq!(target.tui_pattern, None);
}
#[tokio::test]
async fn settings_default_backend_is_persisted_and_drives_only_fallbacks() {
let state = crate::state::AppState::new_for_test();
let app = Router::new()
.route("/api/settings", get(get_settings).post(update_settings))
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
let response = client
.post(format!("http://{addr}/api/settings"))
.json(&json!({"default_backend": "claude-code"}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let settings: serde_json::Value = client
.get(format!("http://{addr}/api/settings"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(settings["default_backend"], "claude-code");
assert_eq!(
serde_json::to_value(
crate::persistence::load_settings(&state.config.config_dir).unwrap()
)
.unwrap()["default_backend"],
"claude-code"
);
assert_eq!(
state.backend_for_session("missing-session").await.name(),
"claude-code"
);
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "explicit-opencode".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
..Default::default()
},
})
.await;
assert_eq!(
state.backend_for_session("explicit-opencode").await.name(),
"opencode"
);
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "unknown-stored-backend".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("removed-backend".into()),
..Default::default()
},
})
.await;
assert_eq!(
state
.backend_for_session("unknown-stored-backend")
.await
.name(),
"opencode",
"unknown stored metadata must not be reinterpreted as backend absence"
);
server.abort();
}
#[tokio::test]
async fn settings_reject_invalid_default_backend_without_mutation() {
let state = crate::state::AppState::new_for_test();
state.settings.write().await.auto_register = false;
let before = state.settings.read().await.clone();
crate::persistence::save_settings(&state.config.config_dir, &before).unwrap();
let persisted_before =
std::fs::read(state.config.config_dir.join("settings.json")).unwrap();
let (status, body) = update_settings(
State(state.clone()),
Json(
serde_json::from_value(json!({
"default_backend": "not-a-backend",
"auto_register": true
}))
.unwrap(),
),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body["error"],
"unknown backend 'not-a-backend'. Valid backends: claude-code, opencode, codex-cli"
);
let after = state.settings.read().await.clone();
assert_eq!(
serde_json::to_value(after).unwrap(),
serde_json::to_value(before).unwrap()
);
assert_eq!(
std::fs::read(state.config.config_dir.join("settings.json")).unwrap(),
persisted_before
);
}
#[tokio::test]
async fn send_to_adopted_opencode_live_pane_reports_tmux_delivery() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-live".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
..Default::default()
},
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-live".into(),
message: "hello live pane".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "delivered");
assert_eq!(body["method"], "tmux");
}
async fn state_with_outstanding_ask() -> SharedState {
let state = crate::state::AppState::new_for_test();
for (id, pane) in [("asker", "%a1"), ("owing", "%o1")] {
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: id.into(),
pane: Some(pane.into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
}
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "asker".into(),
to: "owing".into(),
message: "which shape do you want?".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
state
}
#[tokio::test]
async fn an_unconfirmed_reply_still_clears_the_obligation() {
let state = state_with_outstanding_ask().await;
let msg_id = state.protocol.read().await.pending_replies["owing"][0].msg_id;
let (status, _body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "owing".into(),
to: "asker".into(),
message: "here is the answer".into(),
expects_reply: false,
responds_to: Some(msg_id),
done: true,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
!state
.protocol
.read()
.await
.pending_replies
.contains_key("owing"),
"a completed reply must clear the obligation even when delivery could not be confirmed"
);
}
#[tokio::test]
async fn plain_tell_answering_an_ask_warns_that_the_obligation_remains() {
let state = state_with_outstanding_ask().await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "owing".into(),
to: "asker".into(),
message: "the second shape".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let warning = body["warning"].as_str().unwrap_or_else(|| {
panic!("a plain tell answering an ask must warn, got: {:?}", body.0)
});
assert!(
warning.contains("ouija reply asker"),
"warning must teach the fix, got: {warning}"
);
}
#[tokio::test]
async fn plain_tell_without_an_outstanding_ask_does_not_warn() {
let state = crate::state::AppState::new_for_test();
for (id, pane) in [("a", "%a2"), ("b", "%b2")] {
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: id.into(),
pane: Some(pane.into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
}
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "a".into(),
to: "b".into(),
message: "just an update".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body.get("warning").is_none(),
"an ordinary tell must stay quiet, got: {:?}",
body.0
);
}
#[tokio::test]
async fn progress_tell_with_reply_to_does_not_warn() {
let state = state_with_outstanding_ask().await;
let msg_id = state.protocol.read().await.pending_replies["owing"][0].msg_id;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "owing".into(),
to: "asker".into(),
message: "still working on it".into(),
expects_reply: false,
responds_to: Some(msg_id),
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body.get("warning").is_none(),
"an explicit progress update must not warn, got: {:?}",
body.0
);
}
async fn state_with_victim_and_recipient() -> SharedState {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "victim".into(),
pane: Some("%victim".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "recipient".into(),
pane: Some("%recipient".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
}
#[tokio::test]
async fn send_rejects_paneless_claim_of_tmux_native_session() {
let state = state_with_victim_and_recipient().await;
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "victim".into(),
to: "recipient".into(),
message: "impersonated".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: Some(crate::daemon_protocol::SenderContext {
pane: None,
self_id: None,
..Default::default()
}),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("ouija whoami"),
"rejection must steer the caller to whoami, got: {err}"
);
let proto = state.protocol.read().await;
assert!(
!proto.pending_replies.contains_key("recipient"),
"rejected send must not create pending-reply state"
);
}
#[tokio::test]
async fn send_rejects_claim_from_wrong_pane() {
let state = state_with_victim_and_recipient().await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "victim".into(),
to: "recipient".into(),
message: "impersonated".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: Some(crate::daemon_protocol::SenderContext {
pane: Some("%other".into()),
..Default::default()
}),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("%victim") && err.contains("%other"),
"rejection must name both panes, got: {err}"
);
}
#[tokio::test]
async fn send_with_matching_sender_ctx_delivers() {
let state = state_with_victim_and_recipient().await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "victim".into(),
to: "recipient".into(),
message: "genuine".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: Some(crate::daemon_protocol::SenderContext {
pane: Some("%victim".into()),
..Default::default()
}),
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
}
fn trusted_local_sender_context(
pane: Option<&str>,
self_id: Option<&str>,
backend_identity: Option<crate::backend::BackendSessionIdentity>,
) -> crate::daemon_protocol::SenderContext {
serde_json::from_value(serde_json::json!({
"pane": pane,
"self_id": self_id,
"backend_identity": backend_identity,
"trusted_local_claim": true,
}))
.unwrap()
}
#[tokio::test]
async fn rename_accepts_trusted_local_claim_when_backend_identity_is_not_found() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "hub".into(),
pane: Some("%hub".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
backend_session_id: Some("old-thread".into()),
..Default::default()
},
})
.await;
let (status, body) = rename(
State(state.clone()),
Json(RenameBody {
old_id: "hub".into(),
new_id: "hub-cx".into(),
sender_ctx: Some(trusted_local_sender_context(
None,
None,
Some(crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "replacement-thread".into(),
}),
)),
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
let proto = state.protocol.read().await;
assert!(!proto.sessions.contains_key("hub"));
assert!(proto.sessions.contains_key("hub-cx"));
}
#[tokio::test]
async fn rename_rejects_trusted_local_claim_resolving_to_sibling() {
let state = crate::state::AppState::new_for_test();
for (id, pane, backend_session_id) in [
("hub", "%hub", "hub-thread"),
("sibling", "%sibling", "sibling-thread"),
] {
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: id.into(),
pane: Some(pane.into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
backend_session_id: Some(backend_session_id.into()),
..Default::default()
},
})
.await;
}
let (status, body) = rename(
State(state.clone()),
Json(RenameBody {
old_id: "hub".into(),
new_id: "hub-cx".into(),
sender_ctx: Some(trusted_local_sender_context(
None,
None,
Some(crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "sibling-thread".into(),
}),
)),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "body: {:?}", body.0);
let proto = state.protocol.read().await;
assert!(proto.sessions.contains_key("hub"));
assert!(!proto.sessions.contains_key("hub-cx"));
}
#[tokio::test]
async fn claim_creates_requested_free_id_for_verified_unregistered_backend() {
let state = crate::state::AppState::new_for_test();
let worktree = "/home/daniel/.ouija/worktrees/ouija/requested";
*state.cached_assistant_panes.write().await =
vec![pane_for_backend(worktree, "%claimant", "codex")];
let request = LocalClaimRequest {
requested_id: "requested".into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%claimant".into()),
pane_var_id: None,
env_id: None,
backend_identity: crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "new-thread".into(),
},
},
};
let (status, body) = claim_local_identity(State(state.clone()), Json(request)).await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
let protocol = state.protocol.read().await;
assert_eq!(protocol.sessions.len(), 1);
let claimed = &protocol.sessions["requested"];
assert_eq!(claimed.pane.as_deref(), Some("%claimant"));
assert_eq!(claimed.metadata.project_dir.as_deref(), Some(worktree));
assert_eq!(claimed.metadata.backend.as_deref(), Some("codex-cli"));
assert_eq!(
claimed.metadata.backend_session_id.as_deref(),
Some("new-thread")
);
}
#[tokio::test]
async fn claim_retry_by_same_owner_is_idempotent() {
let state = crate::state::AppState::new_for_test();
let worktree = "/home/daniel/.ouija/worktrees/ouija/requested";
*state.cached_assistant_panes.write().await =
vec![pane_for_backend(worktree, "%claimant", "codex")];
let request = || LocalClaimRequest {
requested_id: "requested".into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%claimant".into()),
pane_var_id: None,
env_id: None,
backend_identity: crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "new-thread".into(),
},
},
};
let (first_status, first_body) =
claim_local_identity(State(state.clone()), Json(request())).await;
assert_eq!(
first_status,
StatusCode::OK,
"first body: {:?}",
first_body.0
);
let first_owner = state.protocol.read().await.sessions["requested"].owner();
let (retry_status, retry_body) =
claim_local_identity(State(state.clone()), Json(request())).await;
assert_eq!(
retry_status,
StatusCode::OK,
"retry body: {:?}",
retry_body.0
);
let protocol = state.protocol.read().await;
assert_eq!(protocol.sessions.len(), 1);
assert_eq!(protocol.sessions["requested"].owner(), first_owner);
}
#[tokio::test]
async fn claim_cannot_take_occupied_destination() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "requested".into(),
pane: Some("%incumbent".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/home/daniel/code/incumbent".into()),
backend: Some("codex-cli".into()),
backend_session_id: Some("incumbent-thread".into()),
..Default::default()
},
})
.await;
let incumbent_before = state.protocol.read().await.sessions["requested"].clone();
*state.cached_assistant_panes.write().await = vec![pane_for_backend(
"/home/daniel/.ouija/worktrees/ouija/requested",
"%claimant",
"codex",
)];
let (status, body) = claim_local_identity(
State(state.clone()),
Json(LocalClaimRequest {
requested_id: "requested".into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%claimant".into()),
pane_var_id: None,
env_id: None,
backend_identity: crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "claimant-thread".into(),
},
},
}),
)
.await;
assert!(
matches!(status, StatusCode::CONFLICT | StatusCode::FORBIDDEN),
"body: {:?}",
body.0
);
let protocol = state.protocol.read().await;
assert_eq!(protocol.sessions.get("requested"), Some(&incumbent_before));
}
#[tokio::test]
async fn claim_rejects_noncanonical_and_incomplete_evidence_as_bad_request() {
let state = crate::state::AppState::new_for_test();
for (requested_id, identity) in [
(
"Not Canonical",
crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "thread".into(),
},
),
(
"requested",
crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: String::new(),
},
),
] {
let (status, body) = claim_local_identity(
State(state.clone()),
Json(LocalClaimRequest {
requested_id: requested_id.into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%1".into()),
pane_var_id: None,
env_id: None,
backend_identity: identity,
},
}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {:?}", body.0);
assert!(body["outcome"].as_str().is_some());
}
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn claim_reuses_a_name_held_only_by_history() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "requested".into(),
pane: Some("%old".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/old-worktree".into()),
canonical_project_identity: Some("/tmp/old-repository".into()),
backend: Some("codex-cli".into()),
backend_session_id: Some("old-thread".into()),
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["requested"].owner();
state
.apply_and_execute(crate::daemon_protocol::Event::DormantOwned {
owner,
expected_pane: Some("%old".into()),
observed_at: 1_753_920_100,
source: crate::daemon_protocol::DormancySource::Reaped,
})
.await;
*state.cached_assistant_panes.write().await =
vec![pane_for_backend("/tmp/new-worktree", "%claimant", "codex")];
let (status, body) = claim_local_identity(
State(state.clone()),
Json(LocalClaimRequest {
requested_id: "requested".into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%claimant".into()),
pane_var_id: None,
env_id: None,
backend_identity: crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "new-thread".into(),
},
},
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
assert_eq!(body["outcome"], "claimed");
assert_eq!(body["session_id"], "requested");
let protocol = state.protocol.read().await;
assert!(protocol.sessions.contains_key("requested"));
assert!(!protocol.dormant_sessions.contains_key("requested"));
}
async fn dormant_test_state(id: &str, project_dir: &str) -> SharedState {
let state = crate::state::AppState::new_for_test();
let mut metadata = crate::daemon_protocol::SessionMeta {
project_dir: Some(project_dir.into()),
canonical_project_identity: Some("/tmp/dormant-repository".into()),
backend: Some("codex-cli".into()),
backend_session_id: Some("opaque-backend-secret".into()),
session_incarnation: crate::daemon_protocol::SessionIncarnation(41),
fresh_context_after_active_secs: Some(3600),
active_context_accumulated_secs: 927,
active_context_segment_started_at: None,
active_context_restart_due: true,
prompt: Some("private stored prompt".into()),
..Default::default()
};
metadata.session_start_credential = Some("launch-secret".into());
state.protocol.write().await.dormant_sessions.insert(
id.into(),
crate::daemon_protocol::DormantSession {
id: id.into(),
prior_owner: crate::daemon_protocol::ResourceOwner {
session_id: id.into(),
incarnation: crate::daemon_protocol::SessionIncarnation(41),
},
metadata,
canonical_project_identity: "/tmp/dormant-repository".into(),
dormant_at: 1_753_920_123,
source: crate::daemon_protocol::DormancySource::TrustedSessionEnd,
},
);
state
}
#[tokio::test]
async fn dormant_resume_returns_only_exact_owner_ticket_and_reserves_before_background_work() {
let state = dormant_test_state("feat/identity", "/tmp/preserved-worktree").await;
let (status, Json(body)) = resume_dormant_identity(
State(state.clone()),
axum::extract::Path("feat/identity".into()),
)
.await;
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(body["session"], "feat/identity");
assert_eq!(body["status"], "starting");
assert!(body["incarnation"].as_str().is_some());
let serialized = serde_json::to_string(&body).unwrap();
assert!(!serialized.contains("opaque-backend-secret"));
}
#[tokio::test]
async fn dormant_resume_missing_target_is_not_found() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) =
resume_dormant_identity(State(state), axum::extract::Path("missing".into())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["outcome"], "dormant_not_found");
}
#[tokio::test]
async fn dormant_list_and_show_expose_only_redacted_non_routable_identity_metadata() {
let state = dormant_test_state("feat/identity", "/tmp/preserved-worktree").await;
let (list_status, Json(list)) = dormant_identities(State(state.clone())).await;
assert_eq!(list_status, StatusCode::OK);
let rows = list["dormant_sessions"].as_array().unwrap();
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row["id"], "feat/identity");
assert_eq!(row["prior_incarnation"], "41");
assert_eq!(row["backend"], "codex-cli");
assert_eq!(row["project_dir"], "/tmp/preserved-worktree");
assert_eq!(row["canonical_project_identity"], "/tmp/dormant-repository");
assert_eq!(row["dormant_at"], 1_753_920_123i64);
assert_eq!(row["source"], "trusted_session_end");
assert_eq!(row["fresh_context_after_active_secs"], 3600);
assert_eq!(row["active_context_accumulated_secs"], 927);
assert_eq!(row["active_context_segment_open"], false);
assert_eq!(row["active_context_restart_due"], true);
assert_eq!(row["routable"], false);
assert!(
row["backend_session_fingerprint"]
.as_str()
.is_some_and(|value| value.starts_with("sha256:"))
);
let serialized = serde_json::to_string(&list).unwrap();
for forbidden in [
"opaque-backend-secret",
"launch-secret",
"private stored prompt",
"session_start_credential",
"backend_repair_reservation",
"pending_replies",
] {
assert!(
!serialized.contains(forbidden),
"dormant inspection leaked {forbidden}"
);
}
let (show_status, Json(show)) =
dormant_identity(State(state), axum::extract::Path("feat/identity".into())).await;
assert_eq!(show_status, StatusCode::OK);
assert_eq!(show["dormant_session"], *row);
}
#[tokio::test]
async fn dormant_show_missing_target_is_not_found() {
let state = crate::state::AppState::new_for_test();
let (status, Json(body)) =
dormant_identity(State(state), axum::extract::Path("missing".into())).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["outcome"], "dormant_not_found");
assert_eq!(body["session_id"], "missing");
}
#[tokio::test]
async fn dormant_unregister_forgets_exact_row_and_preserves_worktree() {
let worktree = tempfile::tempdir().unwrap();
let worktree_path = worktree.path().to_string_lossy().into_owned();
let state = dormant_test_state("parked", &worktree_path).await;
{
let protocol = state.protocol.read().await;
state.persist_protocol_state(&protocol).unwrap();
}
let (status, Json(body)) = remove(
State(state.clone()),
Json(RemoveBody {
id: "parked".into(),
keep_worktree: Some(false),
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["forgotten_dormant"], "parked");
assert_eq!(body["worktree_preserved"], true);
assert!(worktree.path().exists());
assert!(
!state
.protocol
.read()
.await
.dormant_sessions
.contains_key("parked")
);
assert!(
!crate::persistence::load_sessions(&state.config.data_dir)
.unwrap()
.dormant_sessions
.contains_key("parked"),
"a daemon restart must not resurrect an explicitly forgotten identity"
);
}
#[tokio::test]
async fn dormant_unregister_persistence_failure_is_internal_error_without_forgetting() {
let state = dormant_test_state("parked", "/tmp/preserved-worktree").await;
{
let protocol = state.protocol.read().await;
state.persist_protocol_state(&protocol).unwrap();
}
std::fs::create_dir(state.config.data_dir.join("sessions.tmp")).unwrap();
let (status, Json(body)) = remove(
State(state.clone()),
Json(RemoveBody {
id: "parked".into(),
keep_worktree: Some(false),
}),
)
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["outcome"], "persistence_failed");
assert!(
state
.protocol
.read()
.await
.dormant_sessions
.contains_key("parked"),
"failed persistence must preserve live protocol authority"
);
assert!(
crate::persistence::load_sessions(&state.config.data_dir)
.unwrap()
.dormant_sessions
.contains_key("parked"),
"failed persistence must preserve restart authority"
);
}
#[tokio::test]
async fn rename_reuses_a_name_held_only_by_history() {
let state = dormant_test_state("reserved", "/tmp/preserved-worktree").await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "source".into(),
pane: Some("%source".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, Json(body)) = rename(
State(state.clone()),
Json(RenameBody {
old_id: "source".into(),
new_id: "reserved".into(),
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["renamed"], "source");
assert_eq!(body["to"], "reserved");
let protocol = state.protocol.read().await;
assert!(protocol.sessions.contains_key("reserved"));
assert!(!protocol.dormant_sessions.contains_key("reserved"));
}
#[tokio::test]
async fn claim_persistence_failure_is_internal_error_without_success() {
let config = crate::state::tests::test_config();
let state = crate::state::AppState::new(config.clone());
let project = tempfile::tempdir().unwrap();
let project_dir = project.path().canonicalize().unwrap();
let project_dir = project_dir.to_string_lossy().into_owned();
*state.cached_assistant_panes.write().await =
vec![pane_for_backend(&project_dir, "%claimant", "codex")];
std::fs::create_dir(config.data_dir.join("sessions.tmp")).unwrap();
let (status, body) = claim_local_identity(
State(state.clone()),
Json(LocalClaimRequest {
requested_id: "requested".into(),
caller: crate::state::LocalClaimEvidence {
pane: Some("%claimant".into()),
pane_var_id: None,
env_id: None,
backend_identity: crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "new-thread".into(),
},
},
}),
)
.await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body["outcome"], "persistence_failed");
assert!(body.get("session_id").is_none());
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn rename_missing_source_never_claims() {
let state = crate::state::AppState::new_for_test();
let (status, body) = rename(
State(state.clone()),
Json(RenameBody {
old_id: "missing".into(),
new_id: "requested".into(),
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND, "body: {:?}", body.0);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn rename_accepts_legacy_body_without_sender_context() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "legacy".into(),
pane: Some("%legacy".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let body: RenameBody = serde_json::from_value(serde_json::json!({
"old_id": "legacy",
"new_id": "legacy-renamed"
}))
.expect("legacy rename body must remain compatible");
let (status, response) = rename(State(state.clone()), Json(body)).await;
assert_eq!(status, StatusCode::OK, "body: {:?}", response.0);
let proto = state.protocol.read().await;
assert!(proto.sessions.contains_key("legacy-renamed"));
}
#[tokio::test]
async fn send_accepts_trusted_local_claim_from_replacement_codex_thread() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "hub-4".into(),
pane: Some("%old".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
backend_session_id: Some("old-thread".into()),
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "recipient".into(),
pane: Some("%recipient".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "hub-4".into(),
to: "recipient".into(),
message: "replacement thread".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: Some(trusted_local_sender_context(
None,
None,
Some(crate::backend::BackendSessionIdentity {
backend: "codex-cli".into(),
session_id: "new-thread".into(),
}),
)),
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
}
#[tokio::test]
async fn send_rejects_trusted_local_claim_of_absent_sender_before_mutation() {
let state = state_with_victim_and_recipient().await;
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "missing".into(),
to: "recipient".into(),
message: "forged".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: Some(trusted_local_sender_context(None, None, None)),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "body: {:?}", body.0);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("not registered")
);
assert!(
!state
.protocol
.read()
.await
.pending_replies
.contains_key("recipient"),
"rejected send must not mutate pending-reply state"
);
}
#[tokio::test]
async fn send_rejects_trusted_local_claim_of_remote_or_human_before_mutation() {
for (from, origin) in [
(
"peer/task",
crate::daemon_protocol::Origin::Remote("npub1peer".into()),
),
(
"operator",
crate::daemon_protocol::Origin::Human("npub1operator".into()),
),
] {
let state = state_with_victim_and_recipient().await;
state.protocol.write().await.sessions.insert(
from.into(),
crate::daemon_protocol::SessionEntry {
id: from.into(),
origin,
..Default::default()
},
);
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: from.into(),
to: "recipient".into(),
message: "forged".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: Some(trusted_local_sender_context(None, None, None)),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{from}: {:?}", body.0);
assert!(
!state
.protocol
.read()
.await
.pending_replies
.contains_key("recipient"),
"{from}: rejected send must not mutate pending-reply state"
);
}
}
#[tokio::test]
async fn send_rejects_paneless_opencode_sibling_impersonation() {
let state = crate::state::AppState::new_for_test();
for (id, pane) in [("oc-a", "%a"), ("oc-b", "%b")] {
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: id.into(),
pane: Some(pane.into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some(format!("ses_{id}")),
..Default::default()
},
})
.await;
}
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "recipient".into(),
pane: Some("%recipient".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "oc-a".into(),
to: "recipient".into(),
message: "impersonated".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: Some(crate::daemon_protocol::SenderContext {
pane: None,
self_id: Some("oc-b".into()),
..Default::default()
}),
}),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("ouija whoami"),
"rejection must steer the caller to whoami, got: {err}"
);
let proto = state.protocol.read().await;
assert!(
!proto.pending_replies.contains_key("recipient"),
"rejected send must not create pending-reply state"
);
}
#[tokio::test]
async fn send_from_opencode_session_as_itself_delivers() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-a".into(),
pane: Some("%a".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_a".into()),
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "recipient".into(),
pane: Some("%recipient".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "oc-a".into(),
to: "recipient".into(),
message: "genuine".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: Some(crate::daemon_protocol::SenderContext {
pane: None,
self_id: Some("oc-a".into()),
..Default::default()
}),
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
}
#[tokio::test]
async fn send_without_sender_ctx_is_allowed_for_legacy_callers() {
let state = state_with_victim_and_recipient().await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "victim".into(),
to: "recipient".into(),
message: "legacy".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK, "body: {:?}", body.0);
}
#[test]
fn send_body_without_sender_ctx_field_still_deserializes() {
let body: SendBody = serde_json::from_str(r#"{"from":"a","to":"b","message":"hi"}"#)
.expect("legacy body must deserialize");
assert!(body.sender_ctx.is_none());
}
async fn state_with_opencode_prompt_server() -> (SharedState, tokio::task::JoinHandle<()>) {
use axum::Router;
use axum::routing::post;
use tokio::net::TcpListener;
async fn prompt_async() -> Json<serde_json::Value> {
Json(serde_json::json!({ "ok": true }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}/prompt_async", post(prompt_async));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
(state, server)
}
#[tokio::test]
async fn send_to_strong_opencode_binding_reports_http_acceptance() {
let (state, server) = state_with_opencode_prompt_server().await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello http".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
server.abort();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "accepted");
assert_eq!(body["method"], "http");
}
#[tokio::test]
async fn send_to_session_with_restart_lease_returns_conflict() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_old".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["oc-managed"].owner();
assert_eq!(
state.claim_existing_start(&owner).await.unwrap(),
crate::daemon_protocol::LifecycleMutationOutcome::Applied
);
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello during restart".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(
body["error"]
.as_str()
.unwrap_or("")
.contains("restart is in progress"),
"expected in-flight restart error, got {body:?}"
);
}
#[tokio::test]
async fn send_to_strong_opencode_binding_reports_http_failure() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello unreachable http".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert_eq!(body["method"], "http");
assert!(body["error"].as_str().unwrap().contains("prompt_async"));
}
#[tokio::test]
async fn rejected_api_delivery_logs_not_delivered() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, _) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello unreachable http".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let log = state.message_log.read().await;
assert_eq!(log.len(), 1);
assert!(!log[0].delivered);
}
#[tokio::test]
async fn send_effect_execution_revalidates_http_method_after_binding_changes() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let effects = {
let mut proto = state.protocol.write().await;
proto.apply(crate::daemon_protocol::Event::Send {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello http".into(),
expects_reply: false,
responds_to: None,
done: false,
})
};
{
let mut proto = state.protocol.write().await;
let session = proto.sessions.get_mut("oc-managed").unwrap();
session.metadata.opencode_binding =
Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted);
}
let result = execute_send_effects_for_api(&state, &effects).await;
assert!(matches!(
result,
Ok(crate::state::DeliveryOutcome::Accepted)
));
}
#[tokio::test]
async fn send_effect_execution_uses_current_http_session_after_metadata_changes() {
use axum::Router;
use axum::extract::Path;
use axum::routing::post;
use tokio::net::TcpListener;
type Captures = std::sync::Arc<std::sync::Mutex<Vec<String>>>;
async fn prompt_async(
Path(session_id): Path<String>,
State(captures): State<Captures>,
) -> Json<serde_json::Value> {
captures.lock().unwrap().push(session_id);
Json(serde_json::json!({ "ok": true }))
}
let captures = Captures::default();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(captures.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_old".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let effects = {
let mut proto = state.protocol.write().await;
proto.apply(crate::daemon_protocol::Event::Send {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello old http session".into(),
expects_reply: false,
responds_to: None,
done: false,
})
};
{
let mut proto = state.protocol.write().await;
let session = proto.sessions.get_mut("oc-managed").unwrap();
session.metadata.backend_session_id = Some("ses_new".into());
}
execute_send_effects_for_api(&state, &effects)
.await
.unwrap();
server.abort();
assert_eq!(captures.lock().unwrap().as_slice(), ["ses_new"]);
}
#[tokio::test]
async fn send_http_failure_does_not_leave_pending_reply() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, _) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello unreachable http".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let proto = state.protocol.read().await;
assert!(!proto.pending_replies.contains_key("oc-managed"));
}
#[tokio::test]
async fn send_ambiguous_http_failure_keeps_pending_reply_and_reports_unknown() {
use axum::Router;
use axum::http::StatusCode;
use axum::routing::post;
use tokio::net::TcpListener;
async fn prompt_async() -> StatusCode {
StatusCode::BAD_GATEWAY
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route("/session/{session_id}/prompt_async", post(prompt_async));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "hello maybe accepted".into(),
expects_reply: true,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "unknown");
assert_eq!(body["method"], "http");
assert_eq!(body["guidance"], UNCONFIRMED_DELIVERY_GUIDANCE);
assert!(
body["reason"].as_str().is_some_and(|r| !r.is_empty()),
"an unconfirmed send must say what was not observed, got: {:?}",
body["reason"]
);
let proto = state.protocol.read().await;
assert!(proto.pending_replies.contains_key("oc-managed"));
server.abort();
}
#[test]
fn confirmed_inject_reports_injected_without_resend_guidance() {
let body = inject_response_body(&crate::tmux::InjectVerification::Confirmed);
assert_eq!(body["status"], "injected");
assert_eq!(body["delivery"], "tmux");
assert!(
body.get("guidance").is_none(),
"a confirmed inject has nothing to warn about, got: {body}"
);
}
#[test]
fn unconfirmed_inject_reports_unknown_instead_of_claiming_injection() {
let body = inject_response_body(&crate::tmux::InjectVerification::Unconfirmed(
"injected text was not observed in pane %87 after paste".into(),
));
assert_eq!(body["status"], "unknown");
assert_eq!(body["delivery"], "tmux");
assert_eq!(
body["reason"],
"injected text was not observed in pane %87 after paste"
);
assert_eq!(body["guidance"], UNCONFIRMED_DELIVERY_GUIDANCE);
}
#[test]
fn unconfirmed_delivery_guidance_tells_callers_not_to_re_send() {
assert!(
UNCONFIRMED_DELIVERY_GUIDANCE.contains("Do not re-send"),
"guidance must name the action that causes duplicate delivery: \
{UNCONFIRMED_DELIVERY_GUIDANCE}"
);
}
#[tokio::test]
async fn failed_done_reply_preserves_sender_retry_state() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta {
reminder: Some("keep working".into()),
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
}
let (status, _) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "done, but unreachable".into(),
expects_reply: false,
responds_to: Some(7),
done: true,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let proto = state.protocol.read().await;
let pending = proto.pending_replies.get("sender").unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].msg_id, 7);
assert_eq!(
proto.sessions["sender"].metadata.reminder.as_deref(),
Some("keep working")
);
}
#[tokio::test]
async fn failed_send_rollback_does_not_restore_concurrently_cleared_pending_reply() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "target".into(),
pane: Some("%target".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (effects, rollback) = {
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
let mut rollback = FailedSendRollback::capture(&proto, "sender", Some(7), false);
let effects = proto.apply(crate::daemon_protocol::Event::Send {
from: "sender".into(),
to: "target".into(),
message: "progress that will fail".into(),
expects_reply: false,
responds_to: Some(7),
done: false,
});
rollback.capture_after_send(&proto);
(effects, rollback)
};
{
let mut proto = state.protocol.write().await;
proto.pending_replies.remove("sender");
}
rollback_failed_delivery(&state, &effects, rollback).await;
let proto = state.protocol.read().await;
assert!(!proto.pending_replies.contains_key("sender"));
}
#[tokio::test]
async fn failed_done_reply_does_not_restore_concurrently_cleared_retry_state() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
#[derive(Clone)]
struct Gate {
started: StdArc<Notify>,
release: StdArc<Notify>,
}
async fn prompt_async(AxumState(gate): AxumState<Gate>) -> StatusCode {
gate.started.notify_one();
gate.release.notified().await;
StatusCode::NOT_FOUND
}
let gate = Gate {
started: StdArc::new(Notify::new()),
release: StdArc::new(Notify::new()),
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(gate.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta {
reminder: Some("keep working".into()),
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
}
let delivery = tokio::spawn({
let state = state.clone();
async move {
send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "done, but unreachable".into(),
expects_reply: false,
responds_to: Some(7),
done: true,
sender_ctx: None,
}),
)
.await
}
});
gate.started.notified().await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.remove("sender");
proto.sessions.get_mut("sender").unwrap().metadata.reminder = None;
}
gate.release.notify_one();
let (status, _) = delivery.await.unwrap();
assert_eq!(status, StatusCode::BAD_GATEWAY);
let proto = state.protocol.read().await;
assert!(!proto.pending_replies.contains_key("sender"));
assert_eq!(proto.sessions["sender"].metadata.reminder, None);
server.abort();
}
#[tokio::test]
async fn successful_done_reply_clears_mutated_reserved_retry_state() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
#[derive(Clone)]
struct Gate {
started: StdArc<Notify>,
release: StdArc<Notify>,
}
async fn prompt_async(AxumState(gate): AxumState<Gate>) -> StatusCode {
gate.started.notify_one();
gate.release.notified().await;
StatusCode::NO_CONTENT
}
let gate = Gate {
started: StdArc::new(Notify::new()),
release: StdArc::new(Notify::new()),
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(gate.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta {
reminder: Some("keep working".into()),
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-managed".into(),
pane: Some("%oc".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
}
let delivery = tokio::spawn({
let state = state.clone();
async move {
send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-managed".into(),
message: "done successfully".into(),
expects_reply: false,
responds_to: Some(7),
done: true,
sender_ctx: None,
}),
)
.await
}
});
gate.started.notified().await;
{
let mut proto = state.protocol.write().await;
let pending = proto.pending_replies.get_mut("sender").unwrap();
pending[0].in_progress = true;
pending[0].last_activity = 200;
proto.sessions.get_mut("sender").unwrap().metadata.reminder =
Some("keep working (activity tick)".into());
}
gate.release.notify_one();
let (status, _) = delivery.await.unwrap();
assert_eq!(status, StatusCode::OK);
let proto = state.protocol.read().await;
assert!(
!proto.pending_replies.contains_key("sender"),
"successful done delivery must clear the reply slot by msg_id even if the reserved entry mutated"
);
assert_eq!(proto.sessions["sender"].metadata.reminder, None);
server.abort();
}
#[tokio::test]
async fn send_failed_restores_done_reply_retry_state() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta {
reminder: Some("keep working".into()),
..Default::default()
},
})
.await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
}
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "missing-target".into(),
message: "done, but target is gone".into(),
expects_reply: false,
responds_to: Some(7),
done: true,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].as_str().unwrap().contains("not found"));
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("sender")
.expect("failed send must restore sender pending reply");
assert!(entries.iter().any(|entry| entry.msg_id == 7));
assert_eq!(
proto.sessions["sender"].metadata.reminder.as_deref(),
Some("keep working")
);
}
#[tokio::test]
async fn send_failed_restores_progress_reply_retry_state() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"sender".into(),
vec![crate::daemon_protocol::PendingReplyEntry {
msg_id: 7,
from: "requester".into(),
message: "please respond".into(),
received_at: 100,
last_activity: 100,
in_progress: false,
}],
);
}
let (status, body) = send_msg(
State(state.clone()),
Json(SendBody {
from: "sender".into(),
to: "missing-target".into(),
message: "still working, but target is gone".into(),
expects_reply: false,
responds_to: Some(7),
done: false,
sender_ctx: None,
}),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].as_str().unwrap().contains("not found"));
let proto = state.protocol.read().await;
let entry = proto
.pending_replies
.get("sender")
.and_then(|entries| entries.iter().find(|entry| entry.msg_id == 7))
.expect("failed send must preserve sender pending reply");
assert!(!entry.in_progress);
assert_eq!(entry.last_activity, 100);
}
#[tokio::test]
async fn send_to_headless_opencode_session_reports_http_acceptance() {
let (state, server) = state_with_opencode_prompt_server().await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender".into(),
pane: Some("%sender".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-headless".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_oc".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let (status, body) = send_msg(
State(state),
Json(SendBody {
from: "sender".into(),
to: "oc-headless".into(),
message: "hello headless".into(),
expects_reply: false,
responds_to: None,
done: false,
sender_ctx: None,
}),
)
.await;
server.abort();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["status"], "accepted");
assert_eq!(body["method"], "http");
}
#[tokio::test]
async fn compact_session_not_found_returns_404() {
let state = crate::state::AppState::new_for_test();
let (status, body) = compact_inner(
&state,
"ghost".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body["error"].as_str().unwrap().contains("not found"));
}
#[tokio::test]
async fn compact_cc_without_pane_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-no-pane".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"cc-no-pane".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(body["error"].as_str().unwrap().contains("pane"));
}
#[tokio::test]
async fn compact_oc_without_backend_session_id_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-sid".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: None,
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-sid".into(),
CompactBody {
continuation: Some("go".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("backend_session_id"),
"expected error to mention backend_session_id, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_weak_binding_returns_400_before_summarize() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-weak".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-weak".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("strong") || err.contains("binding"),
"expected error to mention weak binding, got: {err}"
);
}
#[tokio::test]
async fn compact_rejects_remote_tui_session_before_injection() {
let state = crate::state::AppState::new_for_test();
{
let mut proto = state.protocol.write().await;
proto.sessions.insert(
"remote-cc".into(),
crate::daemon_protocol::SessionEntry {
id: "remote-cc".into(),
pane: Some("%17".into()),
origin: crate::daemon_protocol::Origin::Remote("npub1remote".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
registered_at: 0,
active_context_due_boundary: Default::default(),
},
);
}
let (status, body) = compact_inner(
&state,
"remote-cc".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("local"),
"expected local-session error, got: {err}"
);
}
#[tokio::test]
async fn compact_rejects_remote_http_session_before_summarize() {
let state = crate::state::AppState::new_for_test();
{
let mut proto = state.protocol.write().await;
proto.sessions.insert(
"remote-oc".into(),
crate::daemon_protocol::SessionEntry {
id: "remote-oc".into(),
pane: None,
origin: crate::daemon_protocol::Origin::Remote("npub1remote".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_remote".into()),
opencode_binding: Some(
crate::daemon_protocol::OpenCodeBinding::StrongManaged,
),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
registered_at: 0,
active_context_due_boundary: Default::default(),
},
);
}
let (status, body) = compact_inner(
&state,
"remote-oc".into(),
CompactBody { continuation: None },
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("local"),
"expected local-session error, got: {err}"
);
}
#[tokio::test]
async fn compact_oc_summarize_failure_returns_502() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-fail".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-fail".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("summarize"),
"expected error to mention /summarize, got: {err}"
);
}
#[tokio::test]
async fn compact_oc_bare_compact_is_no_longer_rejected_at_api_boundary() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-cont".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-cont".into(),
CompactBody { continuation: None },
)
.await;
assert_eq!(
status,
StatusCode::BAD_GATEWAY,
"bare /compact must now progress to the summarize call (which 502s in the test env), \
not get rejected at the API boundary"
);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("summarize"),
"expected error to mention /summarize, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_empty_continuation_normalizes_to_bare_compact() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-empty-cont".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-empty-cont".into(),
CompactBody {
continuation: Some(" ".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("summarize"),
"expected error to mention /summarize, got: {}",
body["error"]
);
}
#[tokio::test]
async fn compact_oc_reentrant_request_returns_409_while_summarize_in_flight() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
use tokio::sync::Notify;
struct SummarizeState {
calls: AtomicUsize,
first_started: Notify,
release_first: Notify,
}
async fn summarize(AxumState(state): AxumState<StdArc<SummarizeState>>) -> StatusCode {
let call = state.calls.fetch_add(1, Ordering::SeqCst) + 1;
if call == 1 {
state.first_started.notify_waiters();
state.release_first.notified().await;
}
StatusCode::OK
}
let summarize_state = StdArc::new(SummarizeState {
calls: AtomicUsize::new(0),
first_started: Notify::new(),
release_first: Notify::new(),
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/summarize", post(summarize))
.with_state(summarize_state.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let config = crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
};
let state = crate::state::AppState::new(config);
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-busy".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let first_state = state.clone();
let first = tokio::spawn(async move {
compact_inner(
&first_state,
"oc-busy".into(),
CompactBody { continuation: None },
)
.await
});
summarize_state.first_started.notified().await;
let (status, body) =
compact_inner(&state, "oc-busy".into(), CompactBody { continuation: None }).await;
assert_eq!(status, StatusCode::CONFLICT);
assert!(
body["error"]
.as_str()
.unwrap_or_default()
.contains("compact"),
"expected compact conflict error, got: {}",
body["error"]
);
assert_eq!(summarize_state.calls.load(Ordering::SeqCst), 1);
summarize_state.release_first.notify_waiters();
let (first_status, _) = first.await.unwrap();
assert_eq!(first_status, StatusCode::OK);
server.abort();
}
#[tokio::test]
async fn compact_oc_reentrant_request_after_rename_returns_409() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
use tokio::sync::Notify;
struct SummarizeState {
calls: AtomicUsize,
first_started: Notify,
release_first: Notify,
}
async fn summarize(AxumState(state): AxumState<StdArc<SummarizeState>>) -> StatusCode {
let call = state.calls.fetch_add(1, Ordering::SeqCst) + 1;
if call == 1 {
state.first_started.notify_waiters();
state.release_first.notified().await;
}
StatusCode::OK
}
let summarize_state = StdArc::new(SummarizeState {
calls: AtomicUsize::new(0),
first_started: Notify::new(),
release_first: Notify::new(),
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/summarize", post(summarize))
.with_state(summarize_state.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let config = crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
};
let state = crate::state::AppState::new(config);
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-busy".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
},
})
.await;
let first_state = state.clone();
let first = tokio::spawn(async move {
compact_inner(
&first_state,
"oc-busy".into(),
CompactBody { continuation: None },
)
.await
});
summarize_state.first_started.notified().await;
state
.apply_and_execute(crate::daemon_protocol::Event::Rename {
old_id: "oc-busy".into(),
new_id: "oc-renamed".into(),
})
.await;
let (status, _) = compact_inner(
&state,
"oc-renamed".into(),
CompactBody { continuation: None },
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(summarize_state.calls.load(Ordering::SeqCst), 1);
summarize_state.release_first.notify_waiters();
let (first_status, _) = first.await.unwrap();
assert_eq!(first_status, StatusCode::OK);
server.abort();
}
#[tokio::test]
async fn compact_oc_without_model_and_serve_unreachable_returns_400() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc-no-model".into(),
pane: None,
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_probe".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
model: None,
..Default::default()
},
})
.await;
let (status, body) = compact_inner(
&state,
"oc-no-model".into(),
CompactBody {
continuation: Some("keep going".into()),
},
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let err = body["error"].as_str().unwrap_or_default();
assert!(
err.contains("provider") || err.contains("model"),
"expected error to mention provider/model resolution, got: {err}"
);
}
#[test]
fn compact_body_rejects_unknown_fields() {
let bad = serde_json::json!({"continuatino": "oops"});
let err = serde_json::from_value::<CompactBody>(bad).unwrap_err();
assert!(
err.to_string().contains("unknown field"),
"expected unknown-field error, got: {err}"
);
}
#[test]
fn compact_success_body_matches_docstring_envelope() {
let body = compact_success_body(false, None);
let obj = body.as_object().expect("success body is a JSON object");
assert_eq!(
obj.len(),
3,
"success body must have exactly 3 keys; got {:?}",
obj.keys().collect::<Vec<_>>()
);
assert_eq!(body["status"], "ok");
assert_eq!(body["compacted"], true);
assert_eq!(body["continuation_delivered"], false);
}
#[test]
fn compact_success_body_propagates_continuation_delivered_flag() {
assert_eq!(
compact_success_body(true, None)["continuation_delivered"],
true
);
assert_eq!(
compact_success_body(false, None)["continuation_delivered"],
false
);
}
#[test]
fn compact_success_body_with_error_preserves_envelope() {
let body = compact_success_body(false, Some("boom".into()));
let obj = body.as_object().expect("success body is a JSON object");
assert_eq!(obj.len(), 4);
assert_eq!(body["status"], "ok");
assert_eq!(body["compacted"], true);
assert_eq!(body["continuation_delivered"], false);
assert_eq!(body["error"], "boom");
}
#[tokio::test]
async fn compact_cc_inject_failure_drains_parked_continuation() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-inject-fail".into(),
pane: Some("%999999999".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let (status, _body) = compact_inner(
&state,
"cc-inject-fail".into(),
CompactBody {
continuation: Some("rollback me".into()),
},
)
.await;
assert_eq!(
status,
StatusCode::INTERNAL_SERVER_ERROR,
"expected inject failure to surface as 500"
);
let pending = state
.drain_agent_compact_continuation("cc-inject-fail")
.await;
assert_eq!(
pending, None,
"rollback must drain the parked continuation on inject failure — slot was not released"
);
}
#[tokio::test]
async fn compact_cc_with_pending_continuation_returns_409() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "cc-busy".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("claude-code".into()),
..Default::default()
},
})
.await;
let acquired = state
.try_set_pending_compact_continuation("cc-busy", "first".into())
.await;
assert!(acquired, "slot should be empty for a fresh session");
let (status, body) = compact_inner(
&state,
"cc-busy".into(),
CompactBody {
continuation: Some("second".into()),
},
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let err = body["error"].as_str().unwrap();
assert!(
err.contains("pending") || err.contains("in progress"),
"expected error to mention pending/in-progress, got: {err}"
);
let pending = state.drain_agent_compact_continuation("cc-busy").await;
assert_eq!(
pending.as_deref(),
Some("first"),
"first caller's continuation must not be overwritten by the rejected second attempt"
);
}
#[tokio::test]
async fn lookup_opencode_session_dir_returns_none_when_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
let dir = lookup_opencode_session_dir(&state, "ses_does_not_exist").await;
assert!(
dir.is_none(),
"unreachable opencode serve must produce None, got Some({dir:?})"
);
}
#[test]
fn parse_opencode_model_two_segments() {
assert_eq!(
parse_opencode_model("anthropic/claude-sonnet-4-6"),
Some(("anthropic".into(), "claude-sonnet-4-6".into()))
);
}
#[test]
fn parse_opencode_model_splits_on_first_slash_only() {
assert_eq!(
parse_opencode_model("openrouter/openai/gpt-5.4"),
Some(("openrouter".into(), "openai/gpt-5.4".into()))
);
}
#[test]
fn parse_opencode_model_trims_whitespace_per_segment() {
assert_eq!(
parse_opencode_model(" openrouter / gpt-5.4 "),
Some(("openrouter".into(), "gpt-5.4".into()))
);
}
#[test]
fn parse_opencode_model_rejects_no_slash() {
assert_eq!(parse_opencode_model("sonnet"), None);
assert_eq!(parse_opencode_model(""), None);
assert_eq!(parse_opencode_model(" "), None);
}
#[test]
fn parse_opencode_model_rejects_empty_segment() {
assert_eq!(parse_opencode_model("/"), None);
assert_eq!(parse_opencode_model("anthropic/"), None);
assert_eq!(parse_opencode_model("/sonnet"), None);
assert_eq!(parse_opencode_model(" / "), None);
assert_eq!(parse_opencode_model("anthropic/ "), None);
}
#[test]
fn parse_opencode_config_model_prefers_top_level_model_over_provider_default() {
let config = serde_json::json!({
"model": "openrouter/openai/gpt-5.5",
"providers": {
"default": {
"openai": "gpt-5.5-pro"
}
}
});
assert_eq!(
parse_opencode_config_model(&config),
Some(("openrouter".into(), "openai/gpt-5.5".into()))
);
}
#[tokio::test]
async fn resolve_opencode_compact_model_uses_session_model_when_parseable() {
let state = crate::state::AppState::new_for_test();
let result =
resolve_opencode_compact_model(&state, Some("anthropic/claude-sonnet-4-6"), None).await;
assert_eq!(
result,
Some(("anthropic".into(), "claude-sonnet-4-6".into()))
);
}
#[tokio::test]
async fn readiness_prompt_delivery_quarantines_ambiguous_http_status() {
use axum::Router;
use axum::http::StatusCode;
use axum::routing::post;
use tokio::net::TcpListener;
async fn prompt_async() -> StatusCode {
StatusCode::BAD_GATEWAY
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new().route("/session/{session_id}/prompt_async", post(prompt_async));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/project", "%17")];
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%17", "queued prompt", "ses_ready"),
);
let delivered = deliver_pending_prompt(&state, "oc").await;
assert!(!delivered);
assert!(state.pending_prompts.lock().unwrap().get("oc").is_none());
server.abort();
}
#[tokio::test]
async fn readiness_prompt_delivery_reserves_pending_prompt_while_http_is_in_flight() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
#[derive(Clone)]
struct Gate {
started: StdArc<Notify>,
release: StdArc<Notify>,
}
async fn prompt_async(AxumState(gate): AxumState<Gate>) -> StatusCode {
gate.started.notify_one();
gate.release.notified().await;
StatusCode::NO_CONTENT
}
let gate = Gate {
started: StdArc::new(Notify::new()),
release: StdArc::new(Notify::new()),
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(gate.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%17", "queued prompt", "ses_ready"),
);
let delivery = tokio::spawn({
let state = state.clone();
async move { deliver_pending_prompt(&state, "oc").await }
});
gate.started.notified().await;
assert!(!state.pending_prompts.lock().unwrap().contains_key("oc"));
gate.release.notify_one();
assert!(delivery.await.unwrap());
server.abort();
}
#[tokio::test]
async fn readiness_prompt_delivery_retries_after_http_and_fallback_fail() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
#[derive(Clone)]
struct Gate {
started: StdArc<Notify>,
release: StdArc<Notify>,
}
async fn prompt_async(AxumState(gate): AxumState<Gate>) -> StatusCode {
gate.started.notify_one();
gate.release.notified().await;
StatusCode::NOT_FOUND
}
let gate = Gate {
started: StdArc::new(Notify::new()),
release: StdArc::new(Notify::new()),
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(gate.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%17", "queued prompt", "ses_ready"),
);
let delivery = tokio::spawn({
let state = state.clone();
async move { deliver_pending_prompt(&state, "oc").await }
});
gate.started.notified().await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%18".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
gate.release.notify_one();
assert!(!delivery.await.unwrap());
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending_opencode_prompt(
"%17",
"queued prompt",
"ses_ready"
))
);
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/project", "%17")];
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
!state.pending_prompts.lock().unwrap().contains_key("oc"),
"the retry may deliver after the original pane and immutable backend pair return"
);
server.abort();
}
#[test]
fn pending_prompt_retry_reserves_prompt_before_raw_fallback() {
let state = crate::state::AppState::new_for_test();
state
.pending_prompts
.lock()
.unwrap()
.insert("oc".into(), pending_prompt("%17", "queued prompt"));
let reserved =
reserve_pending_prompt_if_matches(&state, "oc", "%17", "queued prompt", None);
assert_eq!(reserved, Some(pending_prompt("%17", "queued prompt")));
assert!(!state.pending_prompts.lock().unwrap().contains_key("oc"));
}
#[tokio::test]
async fn pending_prompt_retry_rearms_after_transient_pane_failure() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
let pending = pending_opencode_prompt("%17", "queued prompt", "ses_ready");
state
.pending_prompts
.lock()
.unwrap()
.insert("oc".into(), pending.clone());
schedule_pending_prompt_retry(&state, "oc", pending.clone());
tokio::time::sleep(PENDING_PROMPT_RETRY_DELAY * 2).await;
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending),
"first retry should restore the prompt while the pane is not live"
);
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/project", "%17")];
tokio::time::sleep(PENDING_PROMPT_RETRY_DELAY * 2).await;
assert!(
!state.pending_prompts.lock().unwrap().contains_key("oc"),
"a follow-up retry should consume the restored prompt once the pane becomes live"
);
}
#[tokio::test]
async fn readiness_prompt_delivery_uses_raw_tmux_for_weak_opencode_binding() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::net::TcpListener;
async fn prompt_async(AxumState(called): AxumState<StdArc<AtomicBool>>) -> StatusCode {
called.store(true, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
let called = StdArc::new(AtomicBool::new(false));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(called.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted),
..Default::default()
},
})
.await;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/project", "%17")];
state
.pending_prompts
.lock()
.unwrap()
.insert("oc".into(), pending_prompt("%17", "queued prompt"));
let delivered = deliver_pending_prompt(&state, "oc").await;
assert!(delivered);
assert!(!called.load(Ordering::SeqCst));
assert!(!state.pending_prompts.lock().unwrap().contains_key("oc"));
server.abort();
}
#[tokio::test]
async fn readiness_prompt_delivery_rejects_stale_weak_opencode_pane() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::net::TcpListener;
async fn prompt_async(AxumState(called): AxumState<StdArc<AtomicBool>>) -> StatusCode {
called.store(true, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
let called = StdArc::new(AtomicBool::new(false));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(called.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_ready".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted),
..Default::default()
},
})
.await;
state
.pending_prompts
.lock()
.unwrap()
.insert("oc".into(), pending_prompt("%17", "queued prompt"));
let delivered = deliver_pending_prompt(&state, "oc").await;
assert!(!delivered);
assert!(!called.load(Ordering::SeqCst));
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending_prompt("%17", "queued prompt"))
);
server.abort();
}
#[tokio::test]
async fn readiness_prompt_delivery_rejects_stale_strong_opencode_pane() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
async fn prompt_async(AxumState(calls): AxumState<StdArc<AtomicUsize>>) -> StatusCode {
calls.fetch_add(1, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
let calls = StdArc::new(AtomicUsize::new(0));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(calls.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%new".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_new".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%old", "queued prompt", "ses_old"),
);
let delivered = deliver_pending_prompt(&state, "oc").await;
assert!(!delivered);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending_opencode_prompt("%old", "queued prompt", "ses_old"))
);
server.abort();
}
#[tokio::test]
async fn readiness_prompt_delivery_rejects_stale_opencode_backend_session() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::net::TcpListener;
async fn prompt_async(AxumState(calls): AxumState<StdArc<AtomicUsize>>) -> StatusCode {
calls.fetch_add(1, Ordering::SeqCst);
StatusCode::NO_CONTENT
}
let calls = StdArc::new(AtomicUsize::new(0));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(calls.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_old".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%17", "queued prompt", "ses_old"),
);
state
.apply_and_execute(crate::daemon_protocol::Event::StageFreshLaunch {
id: "oc".into(),
backend: "opencode".into(),
session_start_credential: None,
expected_repair_reservation: None,
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::AdoptBackend {
id: "oc".into(),
backend: "opencode".into(),
backend_session_id: "ses_new".into(),
expected_backend_session_id: None,
expected_session_start_credential: None,
})
.await;
let delivered = deliver_pending_prompt(&state, "oc").await;
assert!(!delivered);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending_opencode_prompt("%17", "queued prompt", "ses_old"))
);
server.abort();
}
#[tokio::test]
async fn readiness_prompt_http_fallback_rejects_stale_opencode_backend_session() {
use axum::Router;
use axum::extract::State as AxumState;
use axum::http::StatusCode;
use axum::routing::post;
use std::sync::Arc as StdArc;
use tokio::net::TcpListener;
use tokio::sync::Notify;
#[derive(Clone)]
struct Gate {
started: StdArc<Notify>,
release: StdArc<Notify>,
}
async fn prompt_async(AxumState(gate): AxumState<Gate>) -> StatusCode {
gate.started.notify_one();
gate.release.notified().await;
StatusCode::NOT_FOUND
}
let gate = Gate {
started: StdArc::new(Notify::new()),
release: StdArc::new(Notify::new()),
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let app = Router::new()
.route("/session/{session_id}/prompt_async", post(prompt_async))
.with_state(gate.clone());
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let dir = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: port.checked_sub(320).unwrap(),
data_dir: dir.path().to_path_buf(),
config_dir: dir.path().to_path_buf(),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "oc".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_old".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
..Default::default()
},
})
.await;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/project", "%17")];
state.pending_prompts.lock().unwrap().insert(
"oc".into(),
pending_opencode_prompt("%17", "queued prompt", "ses_old"),
);
let delivery = tokio::spawn({
let state = state.clone();
async move { deliver_pending_prompt(&state, "oc").await }
});
gate.started.notified().await;
state
.apply_and_execute(crate::daemon_protocol::Event::StageFreshLaunch {
id: "oc".into(),
backend: "opencode".into(),
session_start_credential: None,
expected_repair_reservation: None,
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::AdoptBackend {
id: "oc".into(),
backend: "opencode".into(),
backend_session_id: "ses_new".into(),
expected_backend_session_id: None,
expected_session_start_credential: None,
})
.await;
gate.release.notify_one();
assert!(!delivery.await.unwrap());
assert_eq!(
state.pending_prompts.lock().unwrap().get("oc"),
Some(&pending_opencode_prompt("%17", "queued prompt", "ses_old"))
);
server.abort();
}
#[tokio::test]
async fn resolve_opencode_compact_model_returns_none_when_no_model_and_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
let result = resolve_opencode_compact_model(&state, None, None).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn resolve_opencode_compact_model_falls_through_on_unparseable_session_model() {
let state = crate::state::AppState::new_for_test();
let result = resolve_opencode_compact_model(&state, Some("sonnet"), None).await;
assert_eq!(result, None, "bare 'sonnet' must not be accepted as a pair");
}
#[tokio::test]
async fn resolve_opencode_compact_model_scopes_config_lookup_to_session_directory() {
use axum::Router;
use axum::http::HeaderMap;
use axum::routing::get;
use tokio::net::TcpListener;
async fn config(headers: HeaderMap) -> Json<serde_json::Value> {
let model = match headers
.get("x-opencode-directory")
.and_then(|v| v.to_str().ok())
{
Some("/tmp/project-a") => "openrouter/openai/gpt-5.5",
_ => "openai/gpt-5.5-pro",
};
Json(serde_json::json!({ "model": model }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/config", get(config));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
let result = resolve_opencode_compact_model(&state, None, Some("/tmp/project-a")).await;
server.abort();
assert_eq!(
result,
Some(("openrouter".into(), "openai/gpt-5.5".into())),
"resolver must use the target session directory's OpenCode config model"
);
}
fn pane_in(dir: &str, pane_id: &str) -> crate::tmux::TmuxPane {
crate::tmux::TmuxPane {
pane_id: pane_id.into(),
session_name: "test".into(),
pane_current_path: Some(dir.into()),
process_name: Some("opencode".into()),
}
}
fn pane_for_backend(dir: &str, pane_id: &str, process_name: &str) -> crate::tmux::TmuxPane {
crate::tmux::TmuxPane {
pane_id: pane_id.into(),
session_name: "test".into(),
pane_current_path: Some(dir.into()),
process_name: Some(process_name.into()),
}
}
#[tokio::test]
async fn auto_provision_creates_session_for_single_matching_pane() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let result =
auto_provision_from_backend_session(&state, "ses_brand_new", "/tmp/freshproject").await;
let session_id = result.expect("auto-provision must succeed for exactly one matching pane");
assert_eq!(session_id, "freshproject");
let proto = state.protocol.read().await;
let session = proto
.sessions
.get(&session_id)
.expect("session must exist in protocol state");
assert_eq!(session.pane.as_deref(), Some("%17"), "pane must be bound");
assert_eq!(
session.metadata.backend.as_deref(),
Some("opencode"),
"backend must be opencode"
);
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_brand_new"),
"backend_session_id must be bound atomically with the Register"
);
assert_eq!(
session.metadata.project_dir.as_deref(),
Some("/tmp/freshproject"),
"project_dir must be set"
);
drop(proto);
let resolved = state.find_session_by_pane("%17").await;
assert_eq!(
resolved.as_deref(),
Some("freshproject"),
"find_session_by_pane must resolve the auto-provisioned pane"
);
}
#[tokio::test]
async fn auto_provision_declines_when_no_pane_matches_dir() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/someother", "%11")];
let result =
auto_provision_from_backend_session(&state, "ses_unmatched", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must decline with zero matching panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created on zero-match decline, got: {:?}",
proto.sessions.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn auto_provision_declines_when_matching_dir_pane_is_not_opencode() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await =
vec![pane_for_backend("/tmp/freshproject", "%17", "claude")];
let result =
auto_provision_from_backend_session(&state, "ses_claude", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must reject non-opencode panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created for a non-opencode pane"
);
}
#[tokio::test]
async fn auto_provision_declines_on_ambiguous_multiple_panes() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![
pane_in("/tmp/freshproject", "%17"),
pane_in("/tmp/freshproject", "%23"),
];
let result =
auto_provision_from_backend_session(&state, "ses_ambiguous", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must decline on >=2 matching panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created on ambiguity decline"
);
}
#[tokio::test]
async fn auto_provision_skips_panes_already_registered() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "preexisting".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
..Default::default()
},
})
.await;
let result =
auto_provision_from_backend_session(&state, "ses_intruder", "/tmp/freshproject").await;
assert!(
result.is_none(),
"auto-provision must skip already-registered panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no additional session must be created, got: {:?}",
proto.sessions.keys().collect::<Vec<_>>()
);
let preexisting = proto.sessions.get("preexisting").unwrap();
assert!(
preexisting.metadata.backend_session_id.is_none(),
"pre-existing session must NOT be clobbered with the intruder's backend_session_id"
);
}
#[tokio::test]
async fn auto_provision_short_circuits_when_concurrent_call_won_the_race() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "already-bound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_raced".into()),
..Default::default()
},
})
.await;
let result =
auto_provision_from_backend_session(&state, "ses_raced", "/tmp/freshproject").await;
assert_eq!(
result.as_deref(),
Some("already-bound"),
"recheck must surface the concurrent winner's id, not invent a new one"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no extra session must be created on the lost race"
);
}
#[test]
fn auto_provision_register_result_returns_backend_winner_after_register_failed() {
let mut proto = crate::daemon_protocol::DaemonState::new_for_model("d".into(), "h".into());
proto.apply(crate::daemon_protocol::Event::Register {
id: "winner".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_raced".into()),
..Default::default()
},
});
let effects = vec![crate::daemon_protocol::Effect::RegisterFailed {
session_id: "freshproject".into(),
reason: "backend_session_id ses_raced is already bound to session 'winner'".into(),
}];
let result = auto_provision_register_result(&proto, "ses_raced", "freshproject", &effects);
assert_eq!(
result.as_deref(),
Some("winner"),
"lost guarded-register races must surface the concurrent backend owner"
);
}
#[tokio::test]
async fn ready_records_attestation_when_auto_register_disabled() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
let project = tempfile::tempdir().unwrap();
let project_dir = project.path().canonicalize().unwrap();
let project_dir = project_dir.to_string_lossy().into_owned();
let response_project = project_dir.clone();
async fn session_dir(State(project): State<String>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": project }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new()
.route("/session/{session_id}", get(session_dir))
.with_state(response_project);
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let data = tempfile::tempdir().unwrap();
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: data.path().to_path_buf(),
config_dir: data.path().to_path_buf(),
});
state.settings.write().await.auto_register = false;
*state.cached_assistant_panes.write().await = vec![pane_in(&project_dir, "%17")];
let identity = crate::backend::BackendSessionIdentity {
backend: "opencode".into(),
session_id: "ses_attested".into(),
};
let response = backend_session_ready_inner_with_hints(
&state,
identity.session_id.clone(),
BackendSessionReadyHints {
pane: Some("%17".into()),
cwd: Some(project_dir),
session_incarnation: None,
..Default::default()
},
)
.await;
assert_eq!(response["delivered"], false);
assert!(state.protocol.read().await.sessions.is_empty());
assert!(matches!(
state.local_backend_pane_attestation(&identity).await,
Some(crate::state::LocalBackendPaneAttestationState::Unique(_))
));
server.abort();
}
#[tokio::test]
async fn backend_session_ready_respects_auto_register_disabled() {
let state = crate::state::AppState::new_for_test();
state.settings.write().await.auto_register = false;
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let response = backend_session_ready_inner(&state, "ses_gated".into()).await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
assert!(response.get("session").is_none());
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created when auto_register is disabled"
);
}
#[tokio::test]
async fn backend_session_ready_returns_strict_error_when_serve_unreachable() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
let response = backend_session_ready_inner(&state, "ses_no_serve".into()).await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created when opencode serve is unreachable"
);
}
#[tokio::test]
async fn backend_session_ready_direct_lookup_hits_when_session_already_bound() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_known".into()),
..Default::default()
},
})
.await;
let response = backend_session_ready_inner(&state, "ses_known".into()).await;
assert_eq!(
response["session"].as_str(),
Some("prebound"),
"direct lookup must surface the session id, got: {response}"
);
}
#[tokio::test]
async fn backend_session_ready_rejects_stale_incarnation_hint() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_known".into()),
..Default::default()
},
})
.await;
let current = state.protocol.read().await.sessions["prebound"]
.metadata
.session_incarnation;
let response = backend_session_ready_inner_with_hints(
&state,
"ses_known".into(),
BackendSessionReadyHints {
pane: Some("%17".into()),
cwd: Some("/tmp/freshproject".into()),
session_incarnation: Some(crate::daemon_protocol::SessionIncarnation(
current.0.saturating_sub(1),
)),
..Default::default()
},
)
.await;
assert_eq!(response["outcome"], "superseded");
assert_eq!(response["delivered"], false);
}
#[tokio::test]
async fn stale_readiness_hint_does_not_adopt_an_unbound_candidate() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
async fn session_dir() -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": "/tmp/stale-adoption" }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}", get(session_dir));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "unbound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/stale-adoption".into()),
..Default::default()
},
})
.await;
let response = backend_session_ready_inner_with_hints(
&state,
"ses_stale_adoption".into(),
BackendSessionReadyHints {
pane: Some("%17".into()),
cwd: Some("/tmp/stale-adoption".into()),
session_incarnation: Some(crate::daemon_protocol::SessionIncarnation(u64::MAX)),
..Default::default()
},
)
.await;
assert_eq!(response["outcome"], "superseded");
let proto = state.protocol.read().await;
let candidate = &proto.sessions["unbound"];
assert_eq!(candidate.metadata.backend, None);
assert_eq!(candidate.metadata.backend_session_id, None);
server.abort();
}
#[tokio::test]
async fn stale_readiness_hint_does_not_auto_provision_a_session() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
async fn session_dir() -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": "/tmp/stale-provision" }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}", get(session_dir));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/stale-provision", "%31")];
let response = backend_session_ready_inner_with_hints(
&state,
"ses_stale_provision".into(),
BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/stale-provision".into()),
session_incarnation: Some(crate::daemon_protocol::SessionIncarnation(u64::MAX)),
..Default::default()
},
)
.await;
assert_eq!(response["outcome"], "superseded");
assert!(
state.protocol.read().await.sessions.is_empty(),
"stale readiness must fail before auto-provision mutates state"
);
server.abort();
}
#[tokio::test]
async fn backend_session_ready_does_not_match_a_different_backend_with_same_native_id() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "codex-owner".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("codex-cli".into()),
backend_session_id: Some("shared-native-id".into()),
..Default::default()
},
})
.await;
let response = backend_session_ready_inner(&state, "shared-native-id".into()).await;
assert!(response.get("session").is_none(), "got: {response}");
assert_eq!(response["delivered"], false);
assert_eq!(
state.protocol.read().await.sessions["codex-owner"]
.metadata
.backend
.as_deref(),
Some("codex-cli")
);
}
#[tokio::test]
async fn backend_session_ready_only_overlaps_legacy_row_by_exact_native_id() {
let backend_only_state = crate::state::AppState::new_for_test();
backend_only_state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "backend-only".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
..Default::default()
},
})
.await;
let response =
backend_session_ready_inner(&backend_only_state, "unrelated-native-id".into()).await;
assert_eq!(
response["outcome"], "session_dir_unresolved",
"got: {response}"
);
assert!(
response["error"]
.as_str()
.unwrap_or_default()
.contains("no session with this backend_session_id"),
"got: {response}"
);
assert!(response.get("session").is_none(), "got: {response}");
let id_only_state = crate::state::AppState::new_for_test();
id_only_state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "id-only".into(),
pane: Some("%18".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend_session_id: Some("legacy-native-id".into()),
..Default::default()
},
})
.await;
let response = backend_session_ready_inner(&id_only_state, "legacy-native-id".into()).await;
assert_eq!(response["outcome"], "incomplete_legacy", "got: {response}");
assert!(response.get("session").is_none(), "got: {response}");
}
#[tokio::test]
async fn backend_session_ready_rejects_backend_session_id_path_separators() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/local-project", "%31")];
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/local-project".into()),
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_bad/../../x".into(), hints).await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("invalid backend_session_id"),
"expected validation error, got: {response}"
);
assert!(
state.protocol.read().await.sessions.is_empty(),
"invalid backend_session_id must be rejected before auto-provision"
);
}
#[test]
fn backend_session_id_boundary_rejects_url_delimiters_and_whitespace() {
for backend_sid in ["ses/bad", "ses?bad", "ses#bad", "ses bad", "ses\tbad"] {
assert!(
validate_backend_session_id_boundary(backend_sid).is_some(),
"{backend_sid:?} must be rejected"
);
}
assert!(validate_backend_session_id_boundary("ses_good-123").is_none());
}
#[tokio::test]
async fn backend_session_ready_ignores_remote_backend_session_id_collision() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
async fn session_dir() -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": "/tmp/local-project" }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}", get(session_dir));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/local-project", "%31")];
{
let mut proto = state.protocol.write().await;
proto.sessions.insert(
"remote-host/local-project".into(),
crate::daemon_protocol::SessionEntry {
id: "remote-host/local-project".into(),
pane: None,
origin: crate::daemon_protocol::Origin::Remote("npub1remote".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/remote-project".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_collision".into()),
..Default::default()
},
registered_at: 0,
active_context_due_boundary: Default::default(),
},
);
}
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/local-project".into()),
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_collision".into(), hints).await;
assert_eq!(
response["session"].as_str(),
Some("local-project"),
"remote backend_session_id collision must not satisfy readiness: {response}"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.contains_key("local-project"),
"local auto-provision must still create a local session"
);
server.abort();
}
#[tokio::test]
async fn backend_session_ready_uses_explicit_pane_and_cwd_hints() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
async fn session_dir() -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": "/tmp/explicit-project" }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}", get(session_dir));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/explicit-project", "%31")];
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/explicit-project".into()),
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_explicit".into(), hints).await;
assert_eq!(
response["session"].as_str(),
Some("explicit-project"),
"hint path must auto-provision with id derived from cwd basename, got: {response}"
);
let proto = state.protocol.read().await;
let session = proto
.sessions
.get("explicit-project")
.expect("session exists");
assert_eq!(session.pane.as_deref(), Some("%31"));
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_explicit"),
);
assert_eq!(
session.metadata.project_dir.as_deref(),
Some("/tmp/explicit-project"),
);
server.abort();
}
#[tokio::test]
async fn backend_session_ready_binds_standalone_blank_scanner_row_without_serve() {
let project = tempfile::tempdir().unwrap();
let project_dir = project.path().canonicalize().unwrap();
let project_dir = project_dir.to_string_lossy().into_owned();
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "standalone".into(),
pane: Some("%3".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some(project_dir.clone()),
canonical_project_identity: Some(project_dir.clone()),
scanner_registration: true,
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["standalone"].owner();
*state.cached_assistant_panes.write().await = vec![pane_in(&project_dir, "%3")];
state.set_backend_recovery_test_inspection(
crate::tmux::ManagedPaneInspection::MarkerOwner(owner.clone()),
);
let response = backend_session_ready_inner_with_hints(
&state,
"ses_standalone".into(),
BackendSessionReadyHints {
pane: Some("%3".into()),
cwd: Some(project_dir.clone()),
session_incarnation: None,
..Default::default()
},
)
.await;
assert_eq!(
response["session"].as_str(),
Some("standalone"),
"standalone blank scanner row must be bound by readiness, got: {response}"
);
let proto = state.protocol.read().await;
let session = &proto.sessions["standalone"];
assert_eq!(session.owner(), owner);
assert_eq!(session.metadata.backend.as_deref(), Some("opencode"));
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_standalone")
);
assert!(matches!(
resolve_opencode_backend_identity(&proto, "ses_standalone"),
crate::daemon_protocol::BackendIdentityResolution::Resolved {
ref session_id
} if session_id == "standalone"
));
}
fn strong_incumbent(sid: &str) -> StaleRebindIncumbent {
StaleRebindIncumbent {
backend: Some("opencode".into()),
backend_session_id: Some(sid.into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::StrongManaged),
has_session_start_credential: false,
has_backend_repair_reservation: false,
}
}
fn weak_incumbent(sid: &str) -> StaleRebindIncumbent {
StaleRebindIncumbent {
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted),
..strong_incumbent(sid)
}
}
fn root_creation() -> StaleRebindAttestation {
StaleRebindAttestation {
created: true,
root_session: Some(true),
}
}
#[test]
fn stale_rebind_accepts_managed_serve_refutation_of_a_strong_binding() {
assert_eq!(
classify_stale_opencode_rebind(
&strong_incumbent("ses_old"),
"ses_new",
StaleRebindAttestation::default(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Live { has_parent: false },
),
Ok(StalenessEvidence::ManagedServeRefutation)
);
}
#[test]
fn stale_rebind_refuses_when_strong_incumbent_is_still_on_the_serve() {
assert_eq!(
classify_stale_opencode_rebind(
&strong_incumbent("ses_old"),
"ses_new",
root_creation(),
OpencodeServeSessionProbe::Live { has_parent: false },
OpencodeServeSessionProbe::Live { has_parent: false },
),
Err("incumbent backend session is still present on the managed serve")
);
}
#[test]
fn stale_rebind_refuses_when_the_serve_is_unreachable() {
assert_eq!(
classify_stale_opencode_rebind(
&strong_incumbent("ses_old"),
"ses_new",
root_creation(),
OpencodeServeSessionProbe::Unknown,
OpencodeServeSessionProbe::Live { has_parent: false },
),
Err("managed serve could not confirm the incumbent backend session")
);
}
#[test]
fn stale_rebind_refuses_when_the_serve_does_not_know_the_reported_session() {
assert_eq!(
classify_stale_opencode_rebind(
&strong_incumbent("ses_old"),
"ses_new",
StaleRebindAttestation::default(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Absent,
),
Err("managed serve does not know the reported conversation either")
);
}
#[test]
fn stale_rebind_refuses_a_serve_visible_child_conversation() {
for incumbent in [strong_incumbent("ses_old"), weak_incumbent("ses_old")] {
assert_eq!(
classify_stale_opencode_rebind(
&incumbent,
"ses_child",
root_creation(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Live { has_parent: true },
),
Err("reported conversation is an opencode child session"),
"a subagent conversation must never take the pane's binding"
);
}
}
#[test]
fn stale_rebind_does_not_treat_a_standalone_session_as_dead() {
assert_eq!(
classify_stale_opencode_rebind(
&weak_incumbent("ses_old"),
"ses_new",
StaleRebindAttestation::default(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Absent,
),
Err(
"standalone binding needs a plugin session-created attestation to rotate; \
managed-serve absence does not prove a standalone session is dead"
)
);
}
#[test]
fn stale_rebind_accepts_a_plugin_root_creation_attestation() {
assert_eq!(
classify_stale_opencode_rebind(
&weak_incumbent("ses_old"),
"ses_new",
root_creation(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Absent,
),
Ok(StalenessEvidence::PluginRootCreation)
);
}
#[test]
fn stale_rebind_refuses_an_unattested_or_child_plugin_report() {
let cases = [
(
StaleRebindAttestation {
created: true,
root_session: None,
},
"plugin did not attest whether the reported conversation is a root session",
),
(
StaleRebindAttestation {
created: true,
root_session: Some(false),
},
"reported conversation is an opencode child session",
),
(
StaleRebindAttestation {
created: false,
root_session: Some(true),
},
"standalone binding needs a plugin session-created attestation to rotate; \
managed-serve absence does not prove a standalone session is dead",
),
];
for (attestation, expected) in cases {
assert_eq!(
classify_stale_opencode_rebind(
&weak_incumbent("ses_old"),
"ses_new",
attestation,
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Absent,
),
Err(expected)
);
}
}
#[test]
fn stale_rebind_refuses_structurally_ineligible_incumbents() {
let cases: [(StaleRebindIncumbent, &str, &str); 4] = [
(
StaleRebindIncumbent {
backend: Some("claude-code".into()),
..weak_incumbent("ses_old")
},
"ses_new",
"incumbent pane owner is not bound to the opencode backend",
),
(
StaleRebindIncumbent {
backend_session_id: None,
..weak_incumbent("ses_old")
},
"ses_new",
"incumbent pane owner has no backend session id to replace",
),
(
weak_incumbent("ses_same"),
"ses_same",
"reported conversation is already the incumbent binding",
),
(
StaleRebindIncumbent {
has_session_start_credential: true,
..weak_incumbent("ses_old")
},
"ses_new",
"incumbent pane owner has an outstanding managed-launch claim",
),
];
for (incumbent, reported, expected) in cases {
assert_eq!(
classify_stale_opencode_rebind(
&incumbent,
reported,
root_creation(),
OpencodeServeSessionProbe::Absent,
OpencodeServeSessionProbe::Live { has_parent: false },
),
Err(expected)
);
}
}
async fn stale_binding_fixture(
project_dir: &str,
) -> (
std::sync::Arc<crate::state::AppState>,
crate::daemon_protocol::ResourceOwner,
) {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "hub-fundamentals".into(),
pane: Some("%87".into()),
metadata: crate::daemon_protocol::SessionMeta {
backend: Some("opencode".into()),
backend_session_id: Some("ses_stale".into()),
opencode_binding: Some(crate::daemon_protocol::OpenCodeBinding::WeakAdopted),
project_dir: Some(project_dir.to_string()),
canonical_project_identity: Some(project_dir.to_string()),
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["hub-fundamentals"].owner();
*state.cached_assistant_panes.write().await = vec![pane_in(project_dir, "%87")];
state.set_opencode_rotation_test_inspection(
crate::tmux::ManagedPaneInspection::MarkerOwner(owner.clone()),
);
state.set_opencode_serve_probe_test_result("ses_stale", OpencodeServeSessionProbe::Absent);
state.set_opencode_serve_probe_test_result("ses_live", OpencodeServeSessionProbe::Absent);
(state, owner)
}
fn rotation_hints(
project_dir: &str,
attestation: StaleRebindAttestation,
) -> BackendSessionReadyHints {
BackendSessionReadyHints {
pane: Some("%87".into()),
cwd: Some(project_dir.to_string()),
session_incarnation: None,
created: attestation.created,
root_session: attestation.root_session,
}
}
fn temp_project() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().canonicalize().unwrap();
let path = path.to_string_lossy().into_owned();
(dir, path)
}
#[tokio::test]
async fn backend_session_ready_rotates_a_stale_standalone_binding() {
let (_guard, project_dir) = temp_project();
let (state, stale_owner) = stale_binding_fixture(&project_dir).await;
let response = backend_session_ready_inner_with_hints(
&state,
"ses_live".into(),
rotation_hints(&project_dir, root_creation()),
)
.await;
assert_eq!(
response["session"].as_str(),
Some("hub-fundamentals"),
"a fully corroborated rotation must bind the live conversation, got: {response}"
);
let proto = state.protocol.read().await;
let session = &proto.sessions["hub-fundamentals"];
assert_eq!(
session.metadata.backend_session_id.as_deref(),
Some("ses_live")
);
assert!(
session.owner().incarnation > stale_owner.incarnation,
"rotation must allocate a newer incarnation"
);
assert!(matches!(
resolve_opencode_backend_identity(&proto, "ses_live"),
crate::daemon_protocol::BackendIdentityResolution::Resolved {
ref session_id
} if session_id == "hub-fundamentals"
));
}
#[tokio::test]
async fn backend_session_ready_keeps_a_standalone_binding_without_attestation() {
let (_guard, project_dir) = temp_project();
let (state, stale_owner) = stale_binding_fixture(&project_dir).await;
let response = backend_session_ready_inner_with_hints(
&state,
"ses_live".into(),
rotation_hints(&project_dir, StaleRebindAttestation::default()),
)
.await;
assert_eq!(response["delivered"], false);
let proto = state.protocol.read().await;
assert_eq!(proto.sessions["hub-fundamentals"].owner(), stale_owner);
assert_eq!(
proto.sessions["hub-fundamentals"]
.metadata
.backend_session_id
.as_deref(),
Some("ses_stale")
);
drop(proto);
let declines = state.backend_readiness_declines().await;
let rotation = declines
.iter()
.find(|decline| decline.outcome == "stale_binding_rotation_refused")
.expect("the refusal must be visible to an operator");
assert_eq!(rotation.backend_session_id, "ses_live");
assert_eq!(
rotation.incumbent_session.as_deref(),
Some("hub-fundamentals")
);
assert_eq!(
rotation.incumbent_backend_session_id.as_deref(),
Some("ses_stale")
);
assert!(
rotation
.reason
.contains("does not prove a standalone session is dead"),
"decline must explain itself: {rotation:?}"
);
}
#[tokio::test]
async fn stale_rotation_refuses_a_foreign_pane_owner_marker() {
let (_guard, project_dir) = temp_project();
let (state, _owner) = stale_binding_fixture(&project_dir).await;
state.set_opencode_rotation_test_inspection(
crate::tmux::ManagedPaneInspection::MarkerOwner(
crate::daemon_protocol::ResourceOwner {
session_id: "someone-else".into(),
incarnation: crate::daemon_protocol::SessionIncarnation(u64::MAX),
},
),
);
let refusal = rotate_stale_opencode_pane_owner(
&state,
"ses_live",
"%87",
&project_dir,
root_creation(),
)
.await
.expect_err("foreign markers must fail closed");
assert!(
refusal.contains("pane owner markers"),
"unexpected refusal: {refusal}"
);
}
#[tokio::test]
async fn stale_rotation_refuses_when_the_pane_runs_another_backend() {
let (_guard, project_dir) = temp_project();
let (state, _owner) = stale_binding_fixture(&project_dir).await;
*state.cached_assistant_panes.write().await =
vec![pane_for_backend(&project_dir, "%87", "claude")];
let refusal = rotate_stale_opencode_pane_owner(
&state,
"ses_live",
"%87",
&project_dir,
root_creation(),
)
.await
.expect_err("backend-process corroboration must fail closed");
assert!(
refusal.contains("pane/backend corroboration"),
"unexpected refusal: {refusal}"
);
}
#[tokio::test]
async fn stale_rotation_refuses_a_mismatched_canonical_project() {
let (_guard, project_dir) = temp_project();
let (other_guard, other_dir) = temp_project();
let (state, _owner) = stale_binding_fixture(&project_dir).await;
let refusal = rotate_stale_opencode_pane_owner(
&state,
"ses_live",
"%87",
&other_dir,
root_creation(),
)
.await
.expect_err("a foreign project must fail closed");
assert!(
refusal.contains("canonical project"),
"unexpected refusal: {refusal}"
);
drop(other_guard);
}
#[tokio::test]
async fn stale_rotation_refuses_a_lifecycle_lease_in_progress() {
let (_guard, project_dir) = temp_project();
let (state, owner) = stale_binding_fixture(&project_dir).await;
{
let mut proto = state.protocol.write().await;
let lease = crate::daemon_protocol::LifecycleLease {
owner: owner.clone(),
phase: crate::daemon_protocol::LifecyclePhase::Starting,
backend: None,
backend_session_id: None,
backend_session_owner: None,
restart_target_owner: None,
restart_previous: None,
dormant_resume_previous: None,
project_dir: None,
project_dir_owner: None,
project_dir_cleanup_on_abandon: false,
inert_pane: None,
inert_pane_owner: None,
sweep_unconfirmed: None,
};
proto
.lifecycle_leases
.insert(owner.session_id.clone(), lease);
}
let refusal = rotate_stale_opencode_pane_owner(
&state,
"ses_live",
"%87",
&project_dir,
root_creation(),
)
.await
.expect_err("a lease in progress must fail closed");
assert!(
refusal.contains("lifecycle lease"),
"unexpected refusal: {refusal}"
);
}
#[tokio::test]
async fn stale_rotation_refuses_two_candidate_pane_owners() {
let (_guard, project_dir) = temp_project();
let (state, _owner) = stale_binding_fixture(&project_dir).await;
{
let mut proto = state.protocol.write().await;
let mut twin = proto.sessions["hub-fundamentals"].clone();
twin.id = "hub-fundamentals-2".into();
twin.metadata.backend_session_id = Some("ses_stale_twin".into());
proto.sessions.insert(twin.id.clone(), twin);
}
let refusal = rotate_stale_opencode_pane_owner(
&state,
"ses_live",
"%87",
&project_dir,
root_creation(),
)
.await
.expect_err("two pane owners must fail closed");
assert!(
refusal.contains("multiple Local sessions"),
"unexpected refusal: {refusal}"
);
}
#[tokio::test]
async fn backend_session_ready_explicit_hints_respect_auto_register_disabled() {
let state = crate::state::AppState::new_for_test();
state.settings.write().await.auto_register = false;
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: Some("/tmp/explicit-project".into()),
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_gated_with_hints".into(), hints)
.await;
assert_eq!(response["delivered"], false);
assert!(
response["error"]
.as_str()
.unwrap_or("")
.contains("no session with this backend_session_id"),
"expected strict error, got: {response}"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"hints must not bypass the auto_register opt-out"
);
}
#[tokio::test]
async fn backend_session_ready_partial_hints_fall_back_to_scan_path() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/half-hinted", "%31")];
let hints = BackendSessionReadyHints {
pane: Some("%31".into()),
cwd: None,
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_half".into(), hints).await;
assert_eq!(response["delivered"], false);
assert!(response.get("session").is_none());
let proto = state.protocol.read().await;
assert!(proto.sessions.is_empty());
}
#[tokio::test]
async fn backend_session_ready_explicit_hints_direct_lookup_still_wins() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/some-real-project".into()),
backend: Some("opencode".into()),
backend_session_id: Some("ses_known".into()),
..Default::default()
},
})
.await;
let hints = BackendSessionReadyHints {
pane: Some("%99".into()),
cwd: Some("/tmp/unrelated".into()),
session_incarnation: None,
..Default::default()
};
let response =
backend_session_ready_inner_with_hints(&state, "ses_known".into(), hints).await;
assert_eq!(
response["session"].as_str(),
Some("prebound"),
"direct lookup must win even when hints point elsewhere, got: {response}"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"no session must be created from the hints when direct lookup hits"
);
}
#[tokio::test]
async fn hint_path_rejects_pane_not_in_assistant_panes() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/unrelated", "%11")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_ghost",
"%99", "/tmp/freshproject",
)
.await;
assert!(
result.is_none(),
"hint path must reject a pane that is not in list_assistant_panes, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert!(
proto.sessions.is_empty(),
"no session must be created for an unverified pane"
);
}
#[tokio::test]
async fn hint_path_rejects_pane_whose_actual_cwd_differs_from_hint() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/actual-project", "%17")];
let result =
auto_provision_with_explicit_pane(&state, "ses_mismatch", "%17", "/tmp/hinted-project")
.await;
assert!(
result.is_none(),
"hint path must reject pane/cwd mismatches, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_backend_session_id_for_different_opencode_directory() {
use axum::Router;
use axum::routing::get;
use tokio::net::TcpListener;
async fn session_dir() -> Json<serde_json::Value> {
Json(serde_json::json!({ "directory": "/tmp/other-project" }))
}
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let serve_port = listener.local_addr().unwrap().port();
assert!(serve_port >= 320, "test listener port must be >= 320");
let app = Router::new().route("/session/{session_id}", get(session_dir));
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let state = crate::state::AppState::new(crate::config::OuijaConfig {
name: "test".into(),
npub: "npub1test".into(),
port: serve_port - 320,
data_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
config_dir: std::path::PathBuf::from("/tmp/ouija-test-agent"),
});
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/hinted-project", "%17")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_other_dir",
"%17",
"/tmp/hinted-project",
)
.await;
assert!(
result.is_none(),
"hint path must reject backend ids whose opencode session directory differs, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
server.abort();
}
#[tokio::test]
async fn hint_path_rejects_non_opencode_pane_even_when_cwd_matches() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await =
vec![pane_for_backend("/tmp/freshproject", "%17", "claude")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_not_opencode",
"%17",
"/tmp/freshproject",
)
.await;
assert!(
result.is_none(),
"hint path must reject a non-opencode assistant pane, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_empty_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result = auto_provision_with_explicit_pane(
&state,
"ses_bad_cwd",
"%17",
"", )
.await;
assert!(
result.is_none(),
"empty cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_bare_root_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result = auto_provision_with_explicit_pane(&state, "ses_root_cwd", "%17", "/").await;
assert!(
result.is_none(),
"bare `/` cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[tokio::test]
async fn hint_path_rejects_relative_cwd() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/ignored", "%17")];
let result =
auto_provision_with_explicit_pane(&state, "ses_rel_cwd", "%17", "relative/path").await;
assert!(
result.is_none(),
"relative cwd must be rejected, got Some({result:?})"
);
assert!(state.protocol.read().await.sessions.is_empty());
}
#[test]
fn hints_tolerate_unknown_fields_for_forward_compat() {
let body =
br#"{"pane":"%17","cwd":"/tmp/foo","tty_path":"/dev/pts/3","plugin_version":"2.0"}"#;
let hints: BackendSessionReadyHints =
serde_json::from_slice(body).expect("unknown fields must not fail parsing");
assert_eq!(hints.pane.as_deref(), Some("%17"));
assert_eq!(hints.cwd.as_deref(), Some("/tmp/foo"));
}
#[tokio::test]
async fn hint_path_rejects_pane_already_registered_to_another_session() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "prebound".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
..Default::default()
},
})
.await;
let result = auto_provision_with_explicit_pane(
&state,
"ses_hijacker",
"%17", "/tmp/freshproject",
)
.await;
assert!(
result.is_none(),
"hint path must refuse to hijack an already-registered pane, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"the victim session must survive intact"
);
let survivor = proto.sessions.get("prebound").unwrap();
assert!(
survivor.metadata.backend_session_id.is_none(),
"victim's metadata must not be rewritten with the hijacker's backend_session_id"
);
}
#[tokio::test]
async fn auto_provision_register_declines_if_pane_becomes_bound_before_apply() {
let state = crate::state::AppState::new_for_test();
*state.cached_assistant_panes.write().await = vec![pane_in("/tmp/freshproject", "%17")];
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "incumbent".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
..Default::default()
},
})
.await;
let project = crate::project_identity::ProjectIdentity {
project_dir: "/tmp/freshproject".into(),
canonical_repository: "/tmp/freshproject".into(),
};
let result =
register_auto_provisioned_session(&state, "ses_late_race", "%17", &project).await;
assert!(
result.is_none(),
"late pane ownership race must fail closed, got Some({result:?})"
);
let proto = state.protocol.read().await;
assert_eq!(
proto.sessions.len(),
1,
"auto-provision must not create a second session or evict the incumbent"
);
let incumbent = proto
.sessions
.get("incumbent")
.expect("incumbent session must survive");
assert_eq!(incumbent.pane.as_deref(), Some("%17"));
assert!(
incumbent.metadata.backend_session_id.is_none(),
"incumbent metadata must not be overwritten by late auto-provision"
);
}
#[tokio::test]
async fn auto_provision_reuses_a_name_held_only_by_history() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "freshproject".into(),
pane: Some("%old".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/freshproject".into()),
canonical_project_identity: Some("/tmp/freshproject".into()),
backend: Some("opencode".into()),
backend_session_id: Some("parked-session".into()),
..Default::default()
},
})
.await;
let owner = state.protocol.read().await.sessions["freshproject"].owner();
state
.apply_and_execute(crate::daemon_protocol::Event::DormantOwned {
owner,
expected_pane: Some("%old".into()),
observed_at: 30,
source: crate::daemon_protocol::DormancySource::Reaped,
})
.await;
let project = crate::project_identity::ProjectIdentity {
project_dir: "/tmp/freshproject".into(),
canonical_repository: "/tmp/freshproject".into(),
};
let result =
register_auto_provisioned_session(&state, "new-session", "%new", &project).await;
assert_eq!(result.as_deref(), Some("freshproject"));
let protocol = state.protocol.read().await;
assert!(!protocol.dormant_sessions.contains_key("freshproject"));
assert_eq!(
protocol.sessions["freshproject"]
.metadata
.backend_session_id
.as_deref(),
Some("new-session")
);
}
#[tokio::test]
async fn opencode_adoption_rejects_a_candidate_staged_for_fresh_launch() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "candidate".into(),
pane: Some("%17".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/project".into()),
..Default::default()
},
})
.await;
let candidate = {
let proto = state.protocol.read().await;
let session = &proto.sessions["candidate"];
OpenCodeAdoptionCandidate {
session_id: "candidate".into(),
backend: session.metadata.backend.clone(),
backend_session_id: session.metadata.backend_session_id.clone(),
session_start_credential: session.metadata.session_start_credential.clone(),
incarnation: session.metadata.session_incarnation,
}
};
state
.apply_and_execute(crate::daemon_protocol::Event::StageFreshLaunch {
id: "candidate".into(),
backend: "codex-cli".into(),
session_start_credential: Some("fresh-proof".into()),
expected_repair_reservation: None,
})
.await;
let adopted = commit_opencode_adoption_if_unchanged(&state, candidate, "ses_racing").await;
assert!(adopted.is_none());
let metadata = &state.protocol.read().await.sessions["candidate"].metadata;
assert_eq!(metadata.backend.as_deref(), Some("codex-cli"));
assert!(metadata.backend_session_id.is_none());
assert_eq!(
metadata.session_start_credential.as_deref(),
Some("fresh-proof")
);
}
#[test]
fn session_name_body_parses_force_reset_when_present() {
let body: SessionNameBody = serde_json::from_str(
r#"{"name":"s","worktree":true,"base_branch":"main","force_reset":true}"#,
)
.expect("body parses");
assert_eq!(
body.force_reset,
Some(true),
"force_reset=true must deserialize to Some(true)"
);
}
#[test]
fn session_name_body_force_reset_defaults_to_none() {
let body: SessionNameBody = serde_json::from_str(r#"{"name":"s"}"#).expect("body parses");
assert_eq!(
body.force_reset, None,
"omitted force_reset must deserialize to None (safe default)"
);
}
#[test]
fn restart_drops_destructive_intent_fires_for_force_reset_true() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","force_reset":true}"#).unwrap();
let warn = restart_drops_destructive_intent(&body);
assert!(
warn.is_some(),
"force_reset=true on the restart path must produce a warn message"
);
let msg = warn.unwrap();
assert!(
msg.contains("force_reset"),
"warn message must mention force_reset, got: {msg}"
);
}
#[test]
fn restart_drops_destructive_intent_fires_for_base_branch() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","base_branch":"main"}"#).unwrap();
let warn = restart_drops_destructive_intent(&body);
assert!(
warn.is_some(),
"base_branch on the restart path must produce a warn message — \
restart_session cannot act on it"
);
assert!(
warn.unwrap().contains("base_branch"),
"warn message must mention base_branch"
);
}
#[test]
fn restart_drops_destructive_intent_silent_when_no_opt_in() {
let body: SessionNameBody = serde_json::from_str(r#"{"name":"s"}"#).unwrap();
assert!(
restart_drops_destructive_intent(&body).is_none(),
"no opt-in supplied, no warn"
);
}
#[test]
fn restart_drops_destructive_intent_silent_when_force_reset_false() {
let body: SessionNameBody =
serde_json::from_str(r#"{"name":"s","force_reset":false}"#).unwrap();
assert!(
restart_drops_destructive_intent(&body).is_none(),
"force_reset=false is not an opt-in; no warn"
);
}
#[tokio::test]
async fn resolve_pane_to_session_accepts_bare_suffix() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let proto = state.protocol.read().await;
assert_eq!(
resolve_pane_to_session(&proto, "74").as_deref(),
Some("sess-a"),
"bare numeric suffix must resolve"
);
}
#[tokio::test]
async fn resolve_pane_to_session_accepts_percent_prefix() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let proto = state.protocol.read().await;
assert_eq!(
resolve_pane_to_session(&proto, "%74").as_deref(),
Some("sess-a"),
"%-prefixed form must also resolve (future %25-encoded callers)"
);
}
#[tokio::test]
async fn resolve_pane_to_session_none_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let proto = state.protocol.read().await;
assert!(resolve_pane_to_session(&proto, "999").is_none());
assert!(resolve_pane_to_session(&proto, "%999").is_none());
}
#[tokio::test]
async fn get_pending_replies_returns_404_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let (status, _) = get_pending_replies_inner(&state, "999".into()).await;
assert_eq!(
status,
StatusCode::NOT_FOUND,
"unknown pane must 404, never 200 + empty"
);
}
#[tokio::test]
async fn get_pending_replies_returns_200_for_known_pane() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sess-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta::default(),
})
.await;
let (status, body) = get_pending_replies_inner(&state, "74".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["count"].as_u64(), Some(0));
}
#[tokio::test]
async fn delete_pending_reply_returns_404_for_unknown_pane() {
let state = crate::state::AppState::new_for_test();
let (status, body) =
delete_pending_reply_inner(&state, "999".into(), "sender".into()).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(
body["error"].as_str().is_some(),
"404 response must include a JSON error field, got: {body}"
);
}
#[tokio::test]
async fn delete_pending_reply_returns_cleared_count_when_slot_existed() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "sender-a".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cleared"].as_u64(),
Some(1),
"one slot existed → cleared must be 1, got body: {body}"
);
}
#[tokio::test]
async fn delete_pending_reply_reports_cleared_zero_when_nothing_to_clear() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "ghost-sender".into()).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["cleared"].as_u64(),
Some(0),
"no matching slot → cleared must be 0, got body: {body}"
);
}
#[test]
fn clear_pending_reply_from_returns_removed_count() {
use crate::daemon_protocol::{DaemonState, Event, SessionMeta};
let mut state = DaemonState::new_for_model("d".into(), "h".into());
state.apply(Event::Register {
id: "sender".into(),
pane: Some("%1".into()),
metadata: SessionMeta {
networked: true,
..Default::default()
},
});
state.apply(Event::Register {
id: "target".into(),
pane: Some("%2".into()),
metadata: SessionMeta {
networked: true,
..Default::default()
},
});
state.apply(Event::Send {
from: "sender".into(),
to: "target".into(),
message: "x".into(),
expects_reply: true,
responds_to: None,
done: false,
});
assert_eq!(state.clear_pending_reply_from("target", "sender"), 1);
assert_eq!(state.clear_pending_reply_from("target", "sender"), 0);
assert_eq!(state.clear_pending_reply_from("ghost", "sender"), 0);
}
#[tokio::test]
async fn failed_api_delivery_clears_only_matching_sender_pending_reply() {
let state = crate::state::AppState::new_for_test();
{
let mut proto = state.protocol.write().await;
proto.pending_replies.insert(
"target".into(),
vec![
crate::daemon_protocol::PendingReplyEntry {
msg_id: 42,
from: "sender-a".into(),
message: "first".into(),
received_at: 0,
last_activity: 0,
in_progress: false,
},
crate::daemon_protocol::PendingReplyEntry {
msg_id: 42,
from: "sender-b".into(),
message: "second".into(),
received_at: 0,
last_activity: 0,
in_progress: false,
},
],
);
}
clear_pending_reply_for_failed_delivery(
&state,
&[crate::daemon_protocol::Effect::SendDelivered {
from: "sender-a".into(),
to: "target".into(),
method: "tmux".into(),
msg_id: 42,
http_delivery: None,
}],
)
.await;
let proto = state.protocol.read().await;
let pending = proto
.pending_replies
.get("target")
.expect("sender-b pending reply should remain");
assert_eq!(
pending.len(),
1,
"only matching sender/msg_id must be removed"
);
assert_eq!(pending[0].from, "sender-b");
assert_eq!(pending[0].msg_id, 42);
}
#[tokio::test]
async fn delete_pending_reply_end_to_end_through_axum_router() {
use axum::Router;
use axum::routing::delete;
use tokio::net::TcpListener;
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let app = Router::new()
.route(
"/api/pane/{pane}/pending-replies/{from}",
delete(delete_pending_reply),
)
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
let url = format!("http://{addr}/api/pane/99/pending-replies/sender-a");
let resp = client.delete(&url).send().await.unwrap();
assert_eq!(
resp.status().as_u16(),
200,
"DELETE on real pane must return 200, not silent 404"
);
{
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "sender-a"))
.unwrap_or(false);
assert!(
!still_there,
"pending-reply slot must be cleared after the DELETE"
);
}
let url2 = format!("http://{addr}/api/pane/%2599/pending-replies/sender-a");
let resp2 = client.delete(&url2).send().await.unwrap();
assert_eq!(
resp2.status().as_u16(),
200,
"%25-encoded `%` form must also route to the right pane"
);
let url3 = format!("http://{addr}/api/pane/%74/pending-replies/sender-a");
let resp3 = client.delete(&url3).send().await.unwrap();
assert_eq!(
resp3.status().as_u16(),
404,
"raw `%74` URL (the pre-fix CLI's bug) must 404, not silently match"
);
server.abort();
}
#[tokio::test]
async fn delete_pending_reply_handles_slash_containing_sender_id() {
use axum::Router;
use axum::routing::delete;
use tokio::net::TcpListener;
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "feat/646-test".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "feat/646-test".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
let app = Router::new()
.route(
"/api/pane/{pane}/pending-replies/{from}",
delete(delete_pending_reply),
)
.with_state(state.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let client = reqwest::Client::new();
let buggy_url = format!("http://{addr}/api/pane/99/pending-replies/feat/646-test");
let buggy_resp = client.delete(&buggy_url).send().await.unwrap();
assert_eq!(
buggy_resp.status().as_u16(),
404,
"raw `/` in sender_id must break route matching (not silently \
succeed); the CLI fix is to percent-encode it"
);
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("slot should still exist after a 404");
assert!(entries.iter().any(|e| e.from == "feat/646-test"));
}
let encoded_url = format!("http://{addr}/api/pane/99/pending-replies/feat%2F646-test");
let resp = client.delete(&encoded_url).send().await.unwrap();
assert_eq!(
resp.status().as_u16(),
200,
"percent-encoded sender_id must route to the handler and clear the slot"
);
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "feat/646-test"))
.unwrap_or(false);
assert!(
!still_there,
"slot from sender `feat/646-test` must be cleared"
);
server.abort();
}
#[tokio::test]
async fn delete_pending_reply_clears_stuck_slot_after_sender_renamed() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "sender-a".into(),
pane: Some("%74".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "receiver-b".into(),
pane: Some("%99".into()),
metadata: crate::daemon_protocol::SessionMeta {
networked: true,
..Default::default()
},
})
.await;
state
.apply_and_execute(crate::daemon_protocol::Event::Send {
from: "sender-a".into(),
to: "receiver-b".into(),
message: "do a thing".into(),
expects_reply: true,
responds_to: None,
done: false,
})
.await;
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("receiver should have a pending-reply bucket");
assert!(
entries.iter().any(|e| e.from == "sender-a"),
"sender-a slot should exist before clear"
);
}
state
.apply_and_execute(crate::daemon_protocol::Event::Rename {
old_id: "sender-a".into(),
new_id: "sender-renamed".into(),
})
.await;
{
let proto = state.protocol.read().await;
let entries = proto
.pending_replies
.get("receiver-b")
.expect("receiver bucket must survive rename of sender");
assert!(
entries.iter().any(|e| e.from == "sender-a"),
"sender-a slot must still be there after rename — this is \
the bug shape we're proving we can clear"
);
}
let (status, body) =
delete_pending_reply_inner(&state, "99".into(), "sender-a".into()).await;
assert_eq!(
status,
StatusCode::OK,
"clear-reply on real pane + real pending slot must return 200"
);
assert_eq!(
body["cleared"].as_u64(),
Some(1),
"cleared must report the removed slot count so the CLI is not lied to"
);
let proto = state.protocol.read().await;
let still_there = proto
.pending_replies
.get("receiver-b")
.map(|v| v.iter().any(|e| e.from == "sender-a"))
.unwrap_or(false);
assert!(
!still_there,
"sender-a slot must be cleared after DELETE /api/pane/99/pending-replies/sender-a"
);
}
#[tokio::test]
async fn prune_stale_sessions_dry_run_lists_stale() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "stale-s1".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/nonexistent".into()),
worktree_present: Some(false),
..Default::default()
},
})
.await;
let (status, body) = prune_stale_sessions(
State(state.clone()),
Json(PruneStaleBody { confirm: false }),
)
.await;
assert_eq!(status, StatusCode::OK);
let value = body.0;
assert_eq!(value["dry_run"], true);
assert_eq!(value["would_prune"], serde_json::json!(["stale-s1"]));
let proto = state.protocol.read().await;
assert!(proto.sessions.contains_key("stale-s1"));
}
#[tokio::test]
async fn prune_stale_sessions_confirm_removes_stale() {
let state = crate::state::AppState::new_for_test();
state
.apply_and_execute(crate::daemon_protocol::Event::Register {
id: "stale-s1".into(),
pane: Some("%1".into()),
metadata: crate::daemon_protocol::SessionMeta {
project_dir: Some("/tmp/nonexistent".into()),
worktree_present: Some(false),
..Default::default()
},
})
.await;
let (status, body) =
prune_stale_sessions(State(state.clone()), Json(PruneStaleBody { confirm: true }))
.await;
assert_eq!(status, StatusCode::OK);
let value = body.0;
assert_eq!(value["dry_run"], false);
assert_eq!(value["pruned"], serde_json::json!(["stale-s1"]));
let proto = state.protocol.read().await;
assert!(!proto.sessions.contains_key("stale-s1"));
}
}