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();
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,
"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(_) => {}
}
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 method == "http" {
"accepted"
} else {
"delivered"
};
let mut payload = json!({
"status": status,
"method": method,
"msg_id": msg_id,
});
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} \"...\""
);
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 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,
..
} => {
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,
},
)
.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(_)) {
false
} else {
*delivered
};
state
.log_message(
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::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 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(
&state,
&body.pane,
&body.message,
body.vim_mode,
target.inject_config,
target.tui_pattern,
)
.await
{
Ok(()) => (
StatusCode::OK,
Json(json!({ "status": "injected", "delivery": "tmux" })),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": e.to_string() })),
),
}
}
#[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)]
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 result = if body.keep_worktree.unwrap_or(false) {
crate::nostr_transport::kill_session_keep_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_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"
})),
);
}
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(_) => {
return (
StatusCode::ACCEPTED,
Json(json!({
"session": body.name,
"status": "start_in_progress"
})),
);
}
};
let name = body.name.clone();
let state2 = state.clone();
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, _msg_id, _) =
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;
tracing::info!("async session restart complete: {}", 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,
)
.await;
tracing::info!(
"async session start complete: {}, result: {result}",
body.name
);
});
(
StatusCode::ACCEPTED,
Json(json!({ "session": name, "status": "starting" })),
)
}
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, _) = 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 })))
}
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(_) => {
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>,
}
#[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 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 json!({
"delivered": false,
"outcome": "incomplete_legacy",
"error": "legacy backend metadata is incomplete; request an explicit fresh managed repair"
});
}
crate::daemon_protocol::BackendIdentityResolution::Ambiguous { .. } => {
return json!({
"delivered": false,
"outcome": "ambiguous",
"error": "multiple Local sessions have this backend identity; repair state before retrying"
});
}
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 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 json!({
"delivered": false,
"outcome": "ambiguous_attestation",
"error": "backend readiness is ambiguous across live panes",
});
}
crate::state::LocalBackendPaneAttestationRecordOutcome::Rejected => {
if state.settings.read().await.auto_register {
return json!({
"delivered": false,
"outcome": "attestation_rejected",
"error": "backend readiness pane attestation rejected",
});
}
}
}
}
}
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 json!({"delivered": false, "error": "no session with this backend_session_id"});
}
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 json!({"delivered": false, "error": "no session with this backend_session_id"});
}
} 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 json!({"delivered": false, "error": "no session with this backend_session_id"});
};
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 json!({"delivered": false, "error": "no session with this backend_session_id"});
};
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);
}
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,
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,
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,
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,
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,
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,
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,
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 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_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");
let proto = state.protocol.read().await;
assert!(proto.pending_replies.contains_key("oc-managed"));
server.abort();
}
#[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,
},
)
.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),
)),
},
)
.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)),
},
)
.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)),
},
)
.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!(response.get("outcome").is_none(), "got: {response}");
assert_eq!(response["error"], "no session with this backend_session_id");
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,
};
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,
};
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,
};
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,
},
)
.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"
));
}
#[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,
};
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,
};
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,
};
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"));
}
}