diff --git a/Cargo.lock b/Cargo.lock index 9ec1f746c..dcafa75d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1343,6 +1343,7 @@ dependencies = [ "freshell-terminal", "futures-util", "libc", + "rusqlite", "serde", "serde_json", "sha2", @@ -1452,6 +1453,7 @@ dependencies = [ "freshell-runtime-observability", "freshell-runtime-protocol", "freshell-sessions", + "freshell-supervisor", "freshell-terminal", "freshell-ws", "futures-util", @@ -1490,6 +1492,7 @@ dependencies = [ "freshell-sessions", "freshell-terminal", "libc", + "rusqlite", "serde", "serde_json", "sha2", diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 285ed67d4..5ab0fec2d 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -237,6 +237,15 @@ pub trait FreshAgentTransport: Send + Sync { async fn capture(&self, _max_bytes: usize) -> Result { Err("provider does not expose a hosted snapshot".into()) } + async fn snapshot(&self) -> Result { + Err("provider does not expose a hosted snapshot".into()) + } + + /// A registered zero-turn identity is transport-owned, not a durable native identity. + /// Only transports that can prove their current local registration opt in. + async fn registered_snapshot_identity(&self) -> Option { + None + } /// Whether this actor still owns a usable provider enclosure. Provider /// adapters may self-heal a child internally; they should report false /// only when no live owned session remains. @@ -871,6 +880,64 @@ impl FreshAgentHostActor { .map_err(ActorError::Transport) } + pub async fn snapshot(&self) -> Result { + if !self.transport.is_live().await { + return Err(ActorError::Transport("provider is not live".into())); + } + let snapshot = self + .transport + .snapshot() + .await + .map_err(ActorError::Transport)?; + if !self.transport.is_live().await { + return Err(ActorError::Transport( + "provider exited during snapshot read".into(), + )); + } + let profile = self.profile().await; + let provider = if profile.provider == freshell_runtime_protocol::FreshProvider::Kilroy { + "claude" + } else { + profile.provider.as_str() + }; + let session_type = match profile.provider { + FreshProvider::Claude => "freshclaude", + FreshProvider::Codex => "freshcodex", + FreshProvider::Opencode => "freshopencode", + FreshProvider::Kilroy => "kilroy", + }; + let registered = + if profile.native_session_id.is_none() && profile.provider == FreshProvider::Opencode { + self.transport.registered_snapshot_identity().await + } else { + None + }; + // Recheck after the registration read: materialization must win over a placeholder. + let profile = self.profile().await; + let expected = profile + .native_session_id + .as_deref() + .or(registered.as_deref()); + if expected.is_none() + || snapshot["threadId"].as_str() != expected + || snapshot["provider"].as_str() != Some(provider) + || snapshot["sessionType"].as_str() != Some(session_type) + { + return Err(ActorError::NativeIdentityMismatch); + } + // Snapshots use the existing native-history reply allowance, including envelope overhead. + if serde_json::to_vec(&snapshot) + .map_err(|error| ActorError::Transport(error.to_string()))? + .len() + > freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES - 4096 + { + return Err(ActorError::Transport( + "provider snapshot exceeds history reply frame limit".into(), + )); + } + Ok(snapshot) + } + pub async fn record_event(&self, event: AgentEvent) -> Result { let mut state = self.state.lock().await; let sequence = diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index 7d9612e38..5bcafe8d1 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -1,6 +1,192 @@ use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; +struct SnapshotTransport { + value: Value, + live: std::sync::atomic::AtomicBool, + exit_during_read: bool, + initial_native: Option, + registered: Option, + pause: Option<(Arc, Arc)>, +} + +#[async_trait] +impl FreshAgentTransport for SnapshotTransport { + async fn start(&self, _: &FreshAgentProfile) -> Result { + Ok(TransportStart { + native_session_id: self.initial_native.clone(), + }) + } + async fn dispatch( + &self, + _: &RequestId, + _: &str, + _: &FreshAgentProfile, + ) -> Result { + panic!("snapshot must not dispatch") + } + async fn resolve_permission(&self, _: &str, _: Value) -> Result<(), DispatchFailure> { + panic!("snapshot must not resolve") + } + async fn interrupt(&self) -> Result<(), String> { + panic!("snapshot must not interrupt") + } + async fn stop(self: Arc) -> Result<(), String> { + Ok(()) + } + fn take_event_stream(&self) -> Option> { + None + } + async fn is_live(&self) -> bool { + self.live.load(Ordering::SeqCst) + } + async fn registered_snapshot_identity(&self) -> Option { + self.registered.clone() + } + async fn snapshot(&self) -> Result { + if let Some((entered, release)) = &self.pause { + entered.notify_one(); + release.notified().await; + } + if self.exit_during_read { + self.live.store(false, Ordering::SeqCst); + } + Ok(self.value.clone()) + } +} + +#[tokio::test] +async fn snapshot_read_preserves_actor_state_and_rejects_wrong_identity_size_or_liveness() { + for scenario in [ + "live", + "large", + "wrong-thread", + "wrong-provider", + "wrong-type", + "oversized", + "not-live", + "exit-during-read", + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({ + "threadId":if scenario == "wrong-thread" { "different-thread" } else { "snapshot-native" }, + "provider":if scenario == "wrong-provider" { "codex" } else { "claude" }, + "sessionType":if scenario == "wrong-type" { "freshopencode" } else { "freshclaude" }, "status":"idle", + "turns":match scenario { "oversized" => "x".repeat(freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES), "large" => "x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES), _ => "retained".into() }, + }), + live: std::sync::atomic::AtomicBool::new(scenario != "not-live"), + exit_during_read: scenario == "exit-during-read", + initial_native: Some("snapshot-native".into()), + registered: None, + pause: None, + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Claude, "snapshot-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let result = actor.snapshot().await; + assert_eq!( + result.is_ok(), + matches!(scenario, "live" | "large"), + "{scenario}" + ); + if let Ok(snapshot) = result { + assert_eq!(snapshot["threadId"], "snapshot-native"); + if scenario == "large" { + assert_eq!( + snapshot["turns"].as_str().unwrap().len(), + 2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + ); + } + } + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before, + "{scenario}" + ); + } +} + +#[tokio::test] +async fn registered_pre_native_snapshot_requires_exact_opencode_proof_and_current_materialization() +{ + for scenario in [ + "exact", + "default-deny", + "foreign-placeholder", + "other-provider", + "materialized-during-read", + ] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"threadId":"freshopencode-owned", "provider":"opencode", "sessionType":"freshopencode", "turns":[]}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: None, + registered: if scenario == "default-deny" { + None + } else { + Some( + if scenario == "foreign-placeholder" { + "freshopencode-foreign" + } else { + "freshopencode-owned" + } + .into(), + ) + }, + pause: (scenario == "materialized-during-read") + .then(|| (entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile( + if scenario == "other-provider" { + FreshProvider::Claude + } else { + FreshProvider::Opencode + }, + "owned", + None, + ), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let read = tokio::spawn({ + let actor = actor.clone(); + async move { actor.snapshot().await } + }); + if scenario == "materialized-during-read" { + entered.notified().await; + actor + .observe_native_identity("ses_materialized".into()) + .await + .unwrap(); + release.notify_one(); + } + assert_eq!( + read.await.unwrap().is_ok(), + scenario == "exact", + "{scenario}" + ); + if scenario != "materialized-during-read" { + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before + ); + } + } +} + struct OperationTransport { operations: std::sync::Mutex>, supported: bool, diff --git a/crates/freshell-agent-runtime/src/snapshot_projection.rs b/crates/freshell-agent-runtime/src/snapshot_projection.rs index 9bf6c583c..a0e556c62 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection.rs @@ -23,14 +23,35 @@ const BOOLEAN_CAPABILITIES: &[&str] = &[ /// Rewrites every recognized snapshot in `payload`, including snapshots nested /// in a `freshAgent.event` envelope. Non-snapshot provider events are untouched. pub fn project_hosted_snapshot(payload: &mut Value, provider: &str, session_type: &str) { - let known_pair = matches!( + visit( + payload, + provider, + session_type, + known_pair(provider, session_type), + ); +} + +fn known_pair(provider: &str, session_type: &str) -> bool { + matches!( (provider, session_type), ("claude", "freshclaude") | ("claude", "kilroy") | ("codex", "freshcodex") | ("opencode", "freshopencode") - ); - visit(payload, provider, session_type, known_pair); + ) +} + +/// The existing REST snapshot has no event `type` field. +pub fn project_hosted_rest_snapshot(snapshot: &mut Value, provider: &str, session_type: &str) { + if let Some(object) = snapshot.as_object_mut() { + let identity_matches = object.get("provider").and_then(Value::as_str) == Some(provider) + && object.get("sessionType").and_then(Value::as_str) == Some(session_type); + project_capabilities( + object, + provider, + known_pair(provider, session_type) && identity_matches, + ); + } } fn visit(value: &mut Value, provider: &str, session_type: &str, known_pair: bool) { diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index b580f5f78..8a7fe29fa 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -72,6 +72,7 @@ async-trait = "0.1" serde = { workspace = true } serde_json = { workspace = true } sha2 = "0.10" +rusqlite = { version = "0.31", features = ["bundled", "blob"] } # ``/`` balanced-tag segmentation for opencode assistant text # (`itemsFromAssistantTextPart`/`normalizeBalancedThinkTags`, normalize.ts:100-189) needs a # backreference (`<(thinking|think)...>...`) to match only same-name open/close pairs -- diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 7840528c1..2bd6567f0 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5117,6 +5117,24 @@ impl FreshClaudeState { self.sessions.lock().await.contains_key(&key) } + pub(crate) async fn local_snapshot_owner( + &self, + native_id: &str, + ) -> Option { + let key = self.resolve_session_key(native_id).await?; + let sessions = self.sessions.lock().await; + let session = sessions.get(&key)?; + let canonical = session.cli_session_id.as_deref().unwrap_or(native_id); + crate::ownership_lane::current_local_snapshot_owner( + &self.ownership, + &self.ownership_stamps, + PROVIDER, + canonical, + &key, + session.child.id(), + ) + } + // ── freshAgent.undo / freshAgent.redo (kata 1wxv Task 4; fork-at-point) ──── /// Decision 6: pending cards inside undone turns are CANCELLED, never silently @@ -12031,6 +12049,50 @@ rl.on('line', (line) => { } } + #[tokio::test(flavor = "multi_thread")] + async fn local_snapshot_owner_requires_current_native_and_presentation_registration() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + for kind in [SessionType::Freshclaude, SessionType::Kilroy] { + let (mut state, mut rx) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + state.set_ownership(registry.clone()); + let mut create = dedup_create_msg("local-snapshot-owner"); + create.session_type = kind; + state.handle_create(create, None).await; + let created = + await_claude_created_and_session_init(&mut rx, "local-snapshot-owner").await; + let presentation = created["sessionId"].as_str().unwrap(); + let native = FRESH_CREATE_DURABLE_ID; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + while state.local_snapshot_owner(native).await.is_none() { + assert!( + tokio::time::Instant::now() < deadline, + "native owner never published" + ); + tokio::task::yield_now().await; + } + let expected = registry.observe("claude", native); + let native_owner = state.local_snapshot_owner(native).await; + let presentation_owner = state.local_snapshot_owner(presentation).await; + let unknown_owner = state.local_snapshot_owner("different-native-thread").await; + // Map presence alone cannot override a missing retained ownership stamp. + let stamp = crate::ownership_lane::take_retained_stamp(&state.ownership_stamps, native) + .unwrap(); + let unstamped = state.local_snapshot_owner(native).await; + crate::ownership_lane::restore_retained_stamp(&state.ownership_stamps, native, stamp); + let mut kill = kill_msg(presentation); + kill.session_type = kind; + state.handle_kill(kill).await; + assert_eq!(native_owner, Some(expected.clone())); + assert_eq!(presentation_owner, Some(expected)); + assert!(unknown_owner.is_none()); + assert!(unstamped.is_none()); + assert!(state.local_snapshot_owner(native).await.is_none()); + } + assert_eq!(env.spawn_count(), 2); + } + /// Drain `rx` until the `freshAgent.created` (or `.create.failed`) frame for /// `request_id` arrives (mirrors codex's `await_created`). async fn await_claude_created( diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index 6402c7fe0..80405c865 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -435,133 +435,152 @@ fn parse_transcript_turns(thread_id: &str, transcript: &str) -> Vec { let Ok(obj) = serde_json::from_str::(line) else { continue; }; - let role = match obj.get("type").and_then(Value::as_str) { - Some("user") => "user", - Some("assistant") => "assistant", - _ => continue, - }; - // Real transcripts flag synthetic/subagent lines (ledger A5): skip them. - if [ - "isMeta", - "isSidechain", - "isCompactSummary", - "isVisibleInTranscriptOnly", - ] - .iter() - .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) - { - continue; + if let Some(turn) = parse_transcript_turn(&obj, thread_id, turns.len()) { + turns.push(turn); } - let msg = obj.get("message"); - let blocks: Vec = match msg { + } + turns +} + +pub(crate) fn parse_transcript_turn(obj: &Value, thread_id: &str, ordinal: usize) -> Option { + parse_transcript_turn_indexed(obj, thread_id, ordinal, None) +} + +pub(crate) fn parse_transcript_turn_indexed( + obj: &Value, + thread_id: &str, + ordinal: usize, + index_key: Option<&str>, +) -> Option { + let role = match obj.get("type").and_then(Value::as_str) { + Some("user") => "user", + Some("assistant") => "assistant", + _ => return None, + }; + // Real transcripts flag synthetic/subagent lines (ledger A5): skip them. + if [ + "isMeta", + "isSidechain", + "isCompactSummary", + "isVisibleInTranscriptOnly", + ] + .iter() + .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) + { + return None; + } + let msg = obj.get("message"); + let blocks: Vec = match msg { + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], + Some(Value::Object(m)) => match m.get("content") { + Some(Value::Array(arr)) => arr.clone(), Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], - Some(Value::Object(m)) => match m.get("content") { - Some(Value::Array(arr)) => arr.clone(), - Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], - _ => continue, - }, - _ => continue, - }; + _ => return None, + }, + _ => return None, + }; - let ordinal = turns.len(); - let line_uuid = obj - .get("uuid") - .and_then(Value::as_str) - .filter(|s| !s.is_empty()); - // kata 1wxv: real message uuids are the rollback-addressable turn identity; - // the synthetic {thread}:{ordinal} stays as the fallback for uuid-less lines. - let turn_id = line_uuid - .map(str::to_string) - .unwrap_or_else(|| format!("{thread_id}:{ordinal}")); - let mut items: Vec = Vec::new(); - for (j, block) in blocks.iter().enumerate() { - let item_id = format!("{turn_id}-i{j}"); - match block.get("type").and_then(Value::as_str) { - Some("text") => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - items.push(json!({ "id": item_id, "kind": "text", "text": text })); - } - } - Some("thinking") => { - let text = block - .get("thinking") - .or_else(|| block.get("text")) - .and_then(Value::as_str) - .unwrap_or(""); - items.push(json!({ "id": item_id, "kind": "thinking", "text": text })); - } - Some("tool_use") => { - let tool_use_id = block - .get("id") - .and_then(Value::as_str) - .unwrap_or(item_id.as_str()) - .to_string(); - let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); - let mut item = Map::new(); - item.insert("id".into(), json!(item_id)); - item.insert("kind".into(), json!("tool_use")); - item.insert("toolUseId".into(), json!(tool_use_id)); - item.insert("name".into(), json!(name)); - if let Some(input) = block.get("input") { - item.insert("input".into(), input.clone()); - } - items.push(Value::Object(item)); + let line_uuid = obj + .get("uuid") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + // kata 1wxv: real message uuids are the rollback-addressable turn identity; + // the synthetic {thread}:{ordinal} stays as the fallback for uuid-less lines. + let turn_id = line_uuid + .map(str::to_string) + .unwrap_or_else(|| format!("{thread_id}:{ordinal}")); + let mut items: Vec = Vec::new(); + for (j, block) in blocks.iter().enumerate() { + let j = index_key + .and_then(|key| block.get(key)) + .and_then(Value::as_u64) + .map(|index| index as usize) + .unwrap_or(j); + let item_id = format!("{turn_id}-i{j}"); + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + items.push(json!({ "id": item_id, "kind": "text", "text": text })); } - Some("tool_result") => { - let tool_use_id = block - .get("tool_use_id") - .and_then(Value::as_str) - .unwrap_or(item_id.as_str()) - .to_string(); - let is_error = block - .get("is_error") - .and_then(Value::as_bool) - .unwrap_or(false); - items.push(json!({ - "id": item_id, - "kind": "tool_result", - "toolUseId": tool_use_id, - "content": tool_result_text(block), - "isError": is_error, - })); + } + Some("thinking") => { + let text = block + .get("thinking") + .or_else(|| block.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + items.push(json!({ "id": item_id, "kind": "thinking", "text": text })); + } + Some("tool_use") => { + let tool_use_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or(item_id.as_str()) + .to_string(); + let name = block.get("name").and_then(Value::as_str).unwrap_or("tool"); + let mut item = Map::new(); + item.insert("id".into(), json!(item_id)); + item.insert("kind".into(), json!("tool_use")); + item.insert("toolUseId".into(), json!(tool_use_id)); + item.insert("name".into(), json!(name)); + if let Some(input) = block.get("input") { + item.insert("input".into(), input.clone()); } - _ => {} + items.push(Value::Object(item)); } + Some("tool_result") => { + let tool_use_id = block + .get("tool_use_id") + .and_then(Value::as_str) + .unwrap_or(item_id.as_str()) + .to_string(); + let is_error = block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + items.push(json!({ + "id": item_id, + "kind": "tool_result", + "toolUseId": tool_use_id, + "content": tool_result_text(block), + "isError": is_error, + })); + } + _ => {} } - if items.is_empty() { - continue; - } + } + if items.is_empty() { + return None; + } - let summary = summarize(&items); - let mut turn = Map::new(); - turn.insert("id".into(), json!(turn_id)); - turn.insert("turnId".into(), json!(turn_id)); - if let Some(message_id) = msg - .and_then(|m| m.get("id")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - turn.insert("messageId".into(), json!(message_id)); - } - turn.insert("ordinal".into(), json!(ordinal)); - turn.insert("source".into(), json!("durable")); - turn.insert("role".into(), json!(role)); - if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) { - turn.insert("timestamp".into(), json!(ts)); - } - if let Some(model) = msg - .and_then(|m| m.get("model")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - { - turn.insert("model".into(), json!(model)); - } - turn.insert("summary".into(), json!(summary)); - turn.insert("summaryKind".into(), json!(SUMMARY_KIND_ECHO)); - turn.insert("items".into(), json!(items)); - turns.push(Value::Object(turn)); + let summary = summarize(&items); + let mut turn = Map::new(); + turn.insert("id".into(), json!(turn_id)); + turn.insert("turnId".into(), json!(turn_id)); + if let Some(message_id) = msg + .and_then(|m| m.get("id")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + turn.insert("messageId".into(), json!(message_id)); + } + turn.insert("ordinal".into(), json!(ordinal)); + turn.insert("source".into(), json!("durable")); + turn.insert("role".into(), json!(role)); + if let Some(ts) = obj.get("timestamp").and_then(Value::as_str) { + turn.insert("timestamp".into(), json!(ts)); + } + if let Some(model) = msg + .and_then(|m| m.get("model")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + turn.insert("model".into(), json!(model)); } - turns + turn.insert("summary".into(), json!(summary)); + turn.insert("summaryKind".into(), json!(SUMMARY_KIND_ECHO)); + turn.insert("items".into(), json!(items)); + Some(Value::Object(turn)) } /// Flatten a tool_result block's content (string, or array of text blocks) to a string. diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 8381b2f85..06fe77a32 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -84,6 +84,7 @@ use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySi mod controls; mod metadata; +pub(crate) mod native_history; /// Unified agent names (Task 2): the ambient `CODEX_HOME` fallback for the /// durability-driven pending bind when the app-server's own initialize @@ -5775,6 +5776,25 @@ impl FreshCodexState { .is_some_and(|s| !s.exited.load(Ordering::SeqCst)) } + pub(crate) async fn local_snapshot_owner( + &self, + native_id: &str, + ) -> Option { + let sessions = self.sessions.lock().await; + let session = sessions.get(native_id)?; + if session.exited.load(Ordering::SeqCst) { + return None; + } + crate::ownership_lane::current_local_snapshot_owner( + &self.ownership, + &self.ownership_stamps, + PROVIDER, + native_id, + native_id, + session.sidecar_pid, + ) + } + /// Handle a `freshAgent.attach` for codex (reload-rehydrate). Decision table: /// /// | State | Action | @@ -7561,7 +7581,8 @@ impl FreshCodexState { /// `ensureRuntime` cold-starts a sidecar for a never-seen thread /// (`adapter.ts:762-799,1083-1086`); this GET is FULLY side-effect-free — /// a tracked thread serves from its live runtime, and an untracked one - /// answers the EMPTY snapshot (or the typed ownership refusals) with no + /// reads its verified saved rollout (or serves an empty snapshot when absent, + /// or the typed ownership refusals) with no /// spawn, no `thread/resume`, and no coordinator claim. Cold resume flows /// only through the explicit lifecycle commands (`freshAgent.create`/ /// `attach` with `sessionRef`, generation-fenced). See @@ -7576,15 +7597,15 @@ impl FreshCodexState { ) -> Result { let (client, active_turn_present) = match self.snapshot_runtime_for(thread_id).await { Ok(resolved) => resolved, - // kata b8ke Task 5: an untracked session serves the EMPTY - // snapshot read-only — never a cold-start spawn. + // An untracked read may use the selected durable rollout, never a + // cold-start spawn. Ownership refusal remains authoritative above it. Err(CodexSnapshotError::UntrackedReadonly { ownership }) => { // b8ke ext r17 F3: the response derives from the ONE // observation captured at the decision — no second look // (pre-r17 a start/handoff beginning between the two // observations returned 200 "vacant" paired to the active // transition's generation, the impossible half-state). - return Ok(self.empty_readonly_snapshot(thread_id, &ownership)); + return self.saved_readonly_snapshot(thread_id, &ownership).await; } Err(other) => return Err(other), }; @@ -7808,7 +7829,7 @@ impl FreshCodexState { // Untracked: SIDE-EFFECT-FREE contract (kata b8ke; round-2 review — // the vacant-session cold-start is REMOVED). `ownership_snapshot`'s // Vacant default (unwired coordinator) makes the untracked arm the - // empty-snapshot path with no special case. + // saved-history/empty-snapshot path with no special case. let ownership = self.ownership_snapshot(PROVIDER, thread_id); match ownership.state { freshell_ownership::OwnershipState::Vacant => { @@ -7843,7 +7864,40 @@ impl FreshCodexState { } } - /// kata b8ke Task 5: the side-effect-free EMPTY snapshot for an untracked + /// Read disk only after the ownership lookup permits an untracked read. + /// The captured ownership observation also supplies the response fence; + /// reading the rollout never claims, resumes, or registers a runtime. + async fn saved_readonly_snapshot( + &self, + thread_id: &str, + ownership: &freshell_ownership::OwnershipSnapshot, + ) -> Result { + let Some(mut snapshot) = self.exact_saved_snapshot(thread_id).await? else { + return Ok(self.empty_readonly_snapshot(thread_id, ownership)); + }; + snapshot["extensions"]["codex"]["ownerEpoch"] = json!(ownership.epoch); + snapshot["extensions"]["codex"]["ownerGeneration"] = json!(ownership.generation); + Ok(snapshot) + } + + pub(crate) async fn exact_saved_snapshot( + &self, + thread_id: &str, + ) -> Result, CodexSnapshotError> { + let sessions_root = + codex_home_from_env().map(|home| std::path::PathBuf::from(home).join("sessions")); + let id = thread_id.to_string(); + tokio::task::spawn_blocking(move || { + let Some(path) = sessions_root.and_then(|root| locate_thread_rollout(&root, &id)) else { return Ok(None); }; + native_history::read_rollout(&path, &id) + .and_then(|snapshot| crate::native_history::readonly_snapshot("codex", snapshot)).map(Some) + }).await.map_err(|error| error.to_string()).and_then(|result| result).map_err(|error| { + tracing::warn!(event = "freshagent.codex.saved_history.read_failed", thread_id, error = %error); + CodexSnapshotError::Protocol(error) + }) + } + + /// kata b8ke Task 5: the side-effect-free EMPTY snapshot for an absent /// session — the same JSON shape a never-started historical session /// serves (empty rows + `status`/`sessionType` facts, /// [`build_codex_snapshot_json`] over an empty raw payload) plus the @@ -8735,14 +8789,15 @@ pub enum CodexSnapshotError { Protocol(String), /// kata b8ke Task 5 (round-2 review): the GET is side-effect-free, so an /// UNTRACKED session with a Vacant coordinator key is served read-only — - /// the caller answers the EMPTY snapshot (with owner state); no spawn, no + /// the caller reads the saved rollout or answers an empty snapshot (with + /// owner state); no spawn, no /// `thread/resume`, no `ensure_session_resumable` call, no claim. Cold /// resume belongs ONLY to the explicit lifecycle commands /// (`freshAgent.create`/`freshAgent.attach` with `sessionRef`, /// generation-fenced). UntrackedReadonly { /// b8ke ext r17 F3: the ONE coordinator observation that chose the - /// untracked-vacant answer — the caller's empty snapshot derives + /// untracked-vacant answer — the caller's read-only snapshot derives /// from THIS observation (opencode parity), never a second look /// that could pair a mid-transition generation with "vacant". ownership: freshell_ownership::OwnershipSnapshot, diff --git a/crates/freshell-freshagent/src/codex/native_history.rs b/crates/freshell-freshagent/src/codex/native_history.rs new file mode 100644 index 000000000..d6a059857 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/native_history.rs @@ -0,0 +1,728 @@ +use serde_json::{json, Value}; +use std::{ + collections::{HashSet, VecDeque}, + io::Read, + path::Path, +}; + +pub(crate) fn read(home: &Path, id: &str) -> Result { + let path = super::locate_thread_rollout(&home.join(".codex/sessions"), id) + .ok_or("saved native session not found")?; + read_rollout(&path, id) +} + +const MESSAGE_DIGEST: &str = "nativeHistoryMessageDigest"; + +pub(super) fn read_rollout(path: &Path, id: &str) -> Result { + let retention = crate::native_history::Retention::new(); + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let extent = file.metadata().map_err(|e| e.to_string())?.len(); + let source = + crate::native_history::Records::new(std::io::BufReader::new(file.take(extent)), &retention); + let mut omitted_turns = 0; + let mut omitted_items = 0; + let mut omitted_rows = 0; + let mut retired = RetiredTurns::default(); + let mut turns = Vec::new(); + let mut turn = NativeTurn::new(0); + for (line, row) in source.enumerate() { + retain_native_turns( + &mut turns, + &mut turn, + &mut omitted_turns, + &mut omitted_items, + &mut omitted_rows, + &mut retired, + &retention, + ); + let Some((row, omitted)) = row.map_err(|e| e.to_string())? else { + continue; + }; + let payload = &row["payload"]; + match row["type"].as_str() { + Some("turn_context") => { + if let Some(next) = payload["turn_id"].as_str() { + activate_turn(&mut turns, &mut turn, Some(next), false); + } + } + Some("response_item") => { + let item_type = payload["type"].as_str().unwrap_or(""); + if matches!( + item_type, + "function_call_output" | "custom_tool_call_output" | "tool_search_output" + ) { + omitted_items += omitted; + let call_id = payload["call_id"].as_str(); + let items = turn.value["items"].as_array_mut().unwrap(); + if let Some(call) = items + .iter_mut() + .rev() + .find(|item| item["id"].as_str() == call_id) + { + let output = if item_type == "tool_search_output" { + &payload["tools"] + } else { + &payload["output"] + }; + if call["type"] == "commandExecution" { + call["aggregatedOutput"] = json!(output + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| output.to_string())); + } else { + call["contentItems"] = output_content(output); + } + call["status"] = json!("completed"); + } else { + // A persisted output can outlive its invocation after compaction. + let item = json!({"id":call_id.map(str::to_owned).unwrap_or_else(|| format!("native-line-{line}")),"type":"dynamicToolCall", + "tool":payload["name"].as_str().unwrap_or("tool output"),"status":"completed", + "contentItems":output_content(if item_type == "tool_search_output" { &payload["tools"] } else { &payload["output"] })}); + upsert_item(&mut turn.value, item); + } + continue; + } + if let Some(mut item) = normalize_item(payload) { + stamp_message_digest(&mut item, payload, &retention); + omitted_items += omitted; + item["id"] = payload + .get("call_id") + .filter(|id| id.is_string()) + .or_else(|| payload.get("id").filter(|id| id.is_string())) + .cloned() + .unwrap_or_else(|| json!(format!("native-line-{line}"))); + if item["type"] == "userMessage" + && turn.starts_new_input(&item, MessageSource::Response) + { + advance_turn(&mut turns, &mut turn); + } + upsert_transcript_item(&mut turn, item, MessageSource::Response); + } + } + Some("event_msg") => { + if payload["type"] != "task_started" + && payload["turn_id"] + .as_str() + .is_some_and(|id| !turn.has_id(id) && retired.ids.contains(id)) + { + // A delayed mirror/completion of an omitted task must not + // hijack the current native task or become its latest answer. + continue; + } + if payload["type"] == "task_started" { + activate_turn(&mut turns, &mut turn, payload["turn_id"].as_str(), true); + continue; + } + let mut item = if payload["type"] == "item_completed" { + normalize_completed(&payload["item"]) + } else { + normalize_message_event(payload, line) + .or_else(|| normalize_legacy_event(payload)) + }; + if let Some(item) = item.as_mut() { + stamp_message_digest(item, payload, &retention); + } + let finished = matches!( + payload["type"].as_str(), + Some("task_complete" | "turn_aborted") + ); + if item.is_none() && !finished { + continue; + } + // A delayed completion still belongs to its recorded turn. + let previous = payload["turn_id"] + .as_str() + .and_then(|id| turns.iter().position(|turn| turn.has_id(id))); + let target = if let Some(previous) = previous { + &mut turns[previous] + } else { + if let Some(id) = payload["turn_id"].as_str() { + activate_turn(&mut turns, &mut turn, Some(id), false); + } else if item.as_ref().is_some_and(|item| { + item["type"] == "userMessage" + && turn.starts_new_input(item, MessageSource::Event) + }) { + advance_turn(&mut turns, &mut turn); + } + &mut turn + }; + if let Some(item) = item { + omitted_items += omitted; + upsert_transcript_item( + target, + item, + if payload["type"] == "task_complete" { + MessageSource::Completion + } else { + MessageSource::Event + }, + ); + } + target.bytes = 0; + target.finished |= finished; + } + _ => {} + } + } + retain_native_turns( + &mut turns, + &mut turn, + &mut omitted_turns, + &mut omitted_items, + &mut omitted_rows, + &mut retired, + &retention, + ); + if turn.has_items() { + turns.push(turn); + } + let mut ordinal = omitted_rows; + let offsets: Vec<_> = turns + .iter() + .map(|turn| { + let rows = super::build_codex_turn_json(&turn.value, 0) + .expect("native turn projects") + .len(); + let offset = ( + turn.value["id"].as_str().unwrap().to_owned(), + ordinal, + turn.skipped_rows, + ); + ordinal += rows + turn.skipped_rows; + offset + }) + .collect(); + let part_indices: Vec<_> = turns + .iter() + .flat_map(|turn| { + let native = turn.value["id"].as_str().unwrap().to_owned(); + let index_key = &retention.index_key; + turn.value["items"] + .as_array() + .unwrap() + .iter() + .filter(|item| item["type"] == "userMessage") + .filter_map(move |item| { + Some(( + native.clone(), + item["id"].as_str()?.to_owned(), + item["content"] + .as_array()? + .iter() + .enumerate() + .map(|(index, part)| { + part.get(index_key) + .and_then(Value::as_u64) + .map(|index| index as usize) + .unwrap_or(index) + }) + .collect::>(), + )) + }) + }) + .collect(); + let turns: Vec<_> = turns.into_iter().map(|turn| turn.value).collect(); + let mut snapshot = super::build_codex_snapshot_json( + id, + &json!({"thread":{"id":id,"status":"idle","turns":turns}}), + false, + None, + None, + false, + )?; + for turn in snapshot["turns"].as_array_mut().unwrap() { + let id = turn["turnId"].as_str().unwrap(); + if let Some((native, ordinal, skipped)) = offsets.iter().find(|(native, _, _)| { + id == native + || id + .strip_prefix(native) + .is_some_and(|suffix| suffix.starts_with(":row-")) + }) { + let row = id + .strip_prefix(native) + .and_then(|suffix| suffix.strip_prefix(":row-")) + .and_then(|row| row.parse::().ok()) + .unwrap_or(0); + turn["ordinal"] = json!(ordinal + skipped + row); + if *skipped > 0 { + turn["id"] = json!(format!("{native}:row-{}", skipped + row)); + turn["turnId"] = turn["id"].clone(); + } + } + } + for turn in snapshot["turns"].as_array_mut().unwrap() { + let turn_id = turn["turnId"].as_str().unwrap().to_owned(); + for item in turn["items"].as_array_mut().unwrap() { + if let Some((item_id, index)) = + item["id"].as_str().and_then(|id| id.rsplit_once(":part:")) + { + if let Some(original) = part_indices + .iter() + .find(|(native, id, _)| { + id == item_id + && (turn_id == *native + || turn_id + .strip_prefix(native) + .is_some_and(|suffix| suffix.starts_with(":row-"))) + }) + .and_then(|(_, _, indices)| indices.get(index.parse::().ok()?)) + { + item["id"] = json!(format!("{item_id}:part:{original}")); + } + } + } + } + crate::native_history::finish_retention( + "codex", + &mut snapshot, + omitted_turns, + omitted_items, + Some(&retention), + )?; + Ok(snapshot) +} + +fn retain_native_turns( + turns: &mut Vec, + current: &mut NativeTurn, + omitted_turns: &mut usize, + omitted_items: &mut usize, + omitted_rows: &mut usize, + retired: &mut RetiredTurns, + retention: &crate::native_history::Retention, +) { + // The original message digest still identifies mirrors after their display + // bodies leave the window. Bound the saved copy used to restore a later + // durable source as well as the visible items. + if let Some(mirror) = current.mirror.as_mut() { + crate::native_history::omit_large_bodies(&mut mirror.item, retention); + } + let items = current.value["items"].as_array_mut().unwrap(); + let mut bytes: usize = items + .iter() + .map(|item| serde_json::to_vec(item).unwrap().len()) + .sum(); + if bytes > crate::native_history::RETAINED_TURN_BYTES { + for item in items.iter_mut() { + crate::native_history::omit_large_bodies(item, retention); + } + bytes = items + .iter() + .map(|item| serde_json::to_vec(item).unwrap().len()) + .sum(); + } + while bytes > crate::native_history::RETAINED_TURN_BYTES && items.len() > 1 { + let role = super::classify_codex_item_role(items[0]["type"].as_str().unwrap_or("")); + let next_role = super::classify_codex_item_role(items[1]["type"].as_str().unwrap_or("")); + current.skipped_rows += usize::from(role != next_role); + bytes -= serde_json::to_vec(&items.remove(0)).unwrap().len(); + *omitted_items += 1; + } + let mut bytes: usize = turns + .iter_mut() + .map(|turn| { + if turn.bytes == 0 { + turn.bytes = serde_json::to_vec(&turn.value).unwrap().len() + + turn + .mirror + .as_ref() + .map_or(0, |mirror| serde_json::to_vec(&mirror.item).unwrap().len()); + } + turn.bytes + }) + .sum(); + while bytes > crate::native_history::RETAINED_TURN_BYTES && !turns.is_empty() { + let removed = turns.remove(0); + if removed.named { + retired.insert(removed.value["id"].as_str().unwrap()); + } + bytes -= removed.bytes; + *omitted_rows += super::build_codex_turn_json(&removed.value, 0) + .expect("retained native turn projects") + .len() + + removed.skipped_rows; + *omitted_turns += 1; + } +} + +/// Keep exact recent task identities after their bodies leave the display +/// window. The identity window is bounded by the same retained byte budget. +#[derive(Default)] +struct RetiredTurns { + ids: HashSet, + order: VecDeque, + bytes: usize, +} +impl RetiredTurns { + fn insert(&mut self, id: &str) { + if !self.ids.insert(id.to_owned()) { + return; + } + self.bytes += id.len(); + self.order.push_back(id.to_owned()); + while self.bytes > crate::native_history::RETAINED_TURN_BYTES { + let id = self.order.pop_front().unwrap(); + self.bytes -= id.len(); + self.ids.remove(&id); + } + } +} + +struct NativeTurn { + index: usize, + skipped_rows: usize, + bytes: usize, + value: Value, + mirror: Option, + named: bool, + started: bool, + finished: bool, +} + +impl NativeTurn { + fn new(index: usize) -> Self { + Self { + index, + skipped_rows: 0, + bytes: 0, + value: json!({"id":format!("native-history-{index}"),"items":[]}), + mirror: None, + named: false, + started: false, + finished: false, + } + } + + fn has_id(&self, id: &str) -> bool { + self.value["id"] == id + } + + fn has_items(&self) -> bool { + !self.value["items"].as_array().unwrap().is_empty() + } + + fn starts_new_input(&self, item: &Value, source: MessageSource) -> bool { + if self.finished { + return true; + } + let items = self.value["items"].as_array().unwrap(); + if items.is_empty() { + return false; + } + // Only the still-pending input prefix can contain a mirrored user record. + // A new input after provider output starts a task even if an exit omitted + // its completion; its later task/context records bind the pending identity. + !(items.iter().all(|item| item["type"] == "userMessage") + && self + .mirror + .as_ref() + .is_some_and(|previous| previous.matches(item, source))) + } +} + +fn activate_turn( + turns: &mut Vec, + turn: &mut NativeTurn, + id: Option<&str>, + starts_task: bool, +) { + if id.is_some_and(|id| turn.has_id(id)) { + turn.named = true; + turn.started |= starts_task; + return; + } + // A pre-context message belongs to the still-unnamed task. Bind its identity + // without losing its mirror; completed or newly started tasks never share it. + let can_bind = !(turn.named || turn.finished || starts_task && turn.started); + let can_start_current = id.is_none() && !turn.started && !turn.finished; + if !can_bind && !can_start_current { + advance_turn(turns, turn); + } + if let Some(id) = id { + turn.value["id"] = json!(id); + turn.named = true; + } + turn.started |= starts_task; +} + +fn advance_turn(turns: &mut Vec, turn: &mut NativeTurn) { + let has_items = turn.has_items(); + let previous = std::mem::replace(turn, NativeTurn::new(turn.index + usize::from(has_items))); + if has_items { + turns.push(previous); + } +} + +struct MessageMirror { + item: Value, + sources: Vec, +} + +impl MessageMirror { + fn matches(&self, item: &Value, source: MessageSource) -> bool { + !self.sources.contains(&source) + && self.item["type"] == item["type"] + && message_text(&self.item) == message_text(item) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MessageSource { + Response, + Event, + Completion, +} + +fn stamp_message_digest( + item: &mut Value, + payload: &Value, + retention: &crate::native_history::Retention, +) { + if matches!(item["type"].as_str(), Some("userMessage" | "agentMessage")) { + if let Some(digest) = payload.get(&retention.fingerprint_key) { + item[MESSAGE_DIGEST] = digest.clone(); + } + } +} + +fn normalize_message_event(payload: &Value, line: usize) -> Option { + let (kind, field) = match payload["type"].as_str()? { + "user_message" => ("userMessage", "message"), + "agent_message" => ("agentMessage", "message"), + "task_complete" => ("agentMessage", "last_agent_message"), + _ => return None, + }; + let text = payload[field].as_str().filter(|text| !text.is_empty())?; + let mut item = json!({"id":format!("native-line-{line}"),"type":kind}); + if kind == "userMessage" { + item["content"] = json!([{"type":"input_text","text":text}]); + } else { + item["text"] = json!(text); + } + Some(item) +} + +fn message_text(item: &Value) -> Option { + if matches!(item["type"].as_str(), Some("userMessage" | "agentMessage")) { + if let Some(digest) = item[MESSAGE_DIGEST].as_str() { + return Some(digest.to_owned()); + } + } + match item["type"].as_str()? { + "userMessage" => Some(text_parts(&item["content"])), + "agentMessage" => Some(item["text"].as_str().unwrap_or("").to_owned()), + _ => None, + } +} + +fn upsert_transcript_item(turn: &mut NativeTurn, item: Value, source: MessageSource) { + if message_text(&item).is_none() { + upsert_item(&mut turn.value, item); + return; + } + if let Some(previous) = turn.mirror.as_mut() { + if previous.matches(&item, source) { + // A message may be recorded as a response, an agent event, and a task + // completion. Each source mirrors this occurrence once; repeated + // messages from the same source start a new occurrence. + previous.sources.push(source); + let old_id = previous.item["id"].clone(); + if source == MessageSource::Response { + previous.item = item; + } + if let Some(existing) = turn.value["items"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|old| old["id"] == old_id) + { + if source == MessageSource::Response { + *existing = previous.item.clone(); + } + } else { + // A newer durable copy must remain visible when retention has + // already removed the earlier copy of this occurrence. + upsert_item(&mut turn.value, previous.item.clone()); + } + return; + } + } + turn.mirror = Some(MessageMirror { + item: item.clone(), + sources: vec![source], + }); + upsert_item(&mut turn.value, item); +} + +fn normalize_item(item: &Value) -> Option { + match item["type"].as_str()? { + "message" => match item["role"].as_str()? { + "user" => Some(json!({"type":"userMessage","content":item["content"]})), + "assistant" => Some(json!({"type":"agentMessage","text":text_parts(&item["content"])})), + _ => None, + }, + "agent_message" => Some(json!({"type":"agentMessage","text":text_parts(&item["content"])})), + "reasoning" => Some( + json!({"type":"reasoning","summary":[text_parts(&item["summary"])],"content":[text_parts(&item["content"])]}), + ), + "function_call" | "custom_tool_call" => Some( + json!({"type":"dynamicToolCall","tool":item["name"],"namespace":item["namespace"], + "arguments":if item["type"] == "custom_tool_call" { &item["input"] } else { &item["arguments"] },"status":"inProgress"}), + ), + "tool_search_call" => Some( + json!({"type":"dynamicToolCall","tool":"tool_search","arguments":item["arguments"],"status":"inProgress"}), + ), + "local_shell_call" => Some( + json!({"type":"commandExecution","command":command_text(&item["action"]["command"]), + "cwd":item["action"]["working_directory"],"status":item["status"]}), + ), + "web_search_call" => Some( + json!({"type":"webSearch","query":item["action"]["query"],"action":item["action"]}), + ), + "image_generation_call" => Some( + json!({"type":"imageGeneration","status":item["status"],"result":item["result"],"revisedPrompt":item["revised_prompt"]}), + ), + "compaction" | "context_compaction" => Some(json!({"type":"contextCompaction"})), + _ => None, + } +} + +fn text_parts(parts: &Value) -> String { + parts + .as_array() + .into_iter() + .flatten() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n") +} + +fn upsert_item(turn: &mut Value, item: Value) { + let items = turn["items"].as_array_mut().unwrap(); + if let Some(existing) = items.iter_mut().find(|old| old["id"] == item["id"]) { + if existing["type"] == item["type"] { + existing + .as_object_mut() + .unwrap() + .extend(item.as_object().unwrap().clone()); + } else { + *existing = item; + } + } else { + items.push(item); + } +} + +fn output_content(output: &Value) -> Value { + if let Some(parts) = output.as_array() { + json!(parts + .iter() + .map(|part| match part["type"].as_str() { + Some("input_text" | "text") => json!({"type":"inputText","text":part["text"]}), + Some("input_image") => json!({"type":"inputImage","imageUrl":part["image_url"]}), + _ => part.clone(), + }) + .collect::>()) + } else { + json!([{"type":"inputText","text":output.as_str().map(str::to_owned).unwrap_or_else(|| output.to_string())}]) + } +} + +fn command_text(command: &Value) -> String { + command.as_str().map(str::to_owned).unwrap_or_else(|| { + command + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join(" ") + }) +} + +fn normalize_completed(raw: &Value) -> Option { + let native_type = raw["type"].as_str()?; + if native_type == "FunctionCallOutput" { + return Some( + json!({"id":raw["id"],"type":"dynamicToolCall","tool":raw["name"], + "namespace":raw["namespace"],"status":"completed","contentItems":output_content(&raw["output"])}), + ); + } + // Message/reasoning response records already carry the same durable content; + // their event ids can differ. Action events are the authoritative rich display form. + let wire_type = match native_type { + "CommandExecution" => "commandExecution", + "DynamicToolCall" => "dynamicToolCall", + "McpToolCall" => "mcpToolCall", + "FileChange" => "fileChange", + "WebSearch" => "webSearch", + "ImageGeneration" => "imageGeneration", + "ImageView" => "imageView", + "CollabAgentToolCall" => "collabAgentToolCall", + "Plan" => "plan", + "ContextCompaction" => "contextCompaction", + "EnteredReviewMode" => "enteredReviewMode", + "ExitedReviewMode" => "exitedReviewMode", + _ => return None, + }; + let mut item = raw.clone(); + item["type"] = json!(wire_type); + for (native, wire) in [ + ("aggregated_output", "aggregatedOutput"), + ("exit_code", "exitCode"), + ("content_items", "contentItems"), + ("revised_prompt", "revisedPrompt"), + ("saved_path", "savedPath"), + ("sender_thread_id", "senderThreadId"), + ("receiver_thread_ids", "receiverThreadIds"), + ("agents_states", "agentsStates"), + ("reasoning_effort", "reasoningEffort"), + ] { + if let Some(value) = item.as_object_mut()?.remove(native) { + item[wire] = value; + } + } + if item["status"] == "in_progress" { + item["status"] = json!("inProgress"); + } + if wire_type == "commandExecution" { + item["command"] = json!(command_text(&raw["command"])); + } + if wire_type == "fileChange" { + if let Some(changes) = raw["changes"].as_object() { + item["changes"] = json!(changes + .iter() + .map(|(path, change)| { + let mut change = change.clone(); + change["path"] = json!(path); + change + }) + .collect::>()); + } + } + Some(item) +} + +fn normalize_legacy_event(raw: &Value) -> Option { + let kind = raw["type"].as_str()?; + let mut item = match kind { + "patch_apply_end" => { + let mut native = raw.clone(); + native["type"] = json!("FileChange"); + normalize_completed(&native)? + } + "mcp_tool_call_end" => json!({"type":"mcpToolCall","server":raw["invocation"]["server"], + "tool":raw["invocation"]["tool"],"arguments":raw["invocation"]["arguments"], + "status":if raw["result"].get("Err").is_some() {"failed"} else {"completed"}, + "result":raw["result"]["Ok"],"error":raw["result"]["Err"]}), + "web_search_end" => json!({"type":"webSearch","query":raw["query"],"action":raw["action"]}), + "image_generation_end" => { + let mut native = raw.clone(); + native["type"] = json!("ImageGeneration"); + normalize_completed(&native)? + } + _ => return None, + }; + item["id"] = raw["call_id"].clone(); + Some(item) +} diff --git a/crates/freshell-freshagent/src/hosted_rest.rs b/crates/freshell-freshagent/src/hosted_rest.rs index dc30b335d..4e8fd4a9d 100644 --- a/crates/freshell-freshagent/src/hosted_rest.rs +++ b/crates/freshell-freshagent/src/hosted_rest.rs @@ -106,8 +106,29 @@ pub enum HostedRestCaptureError { Unavailable, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostedRestSnapshot { + pub session_id: String, + pub provider: String, + pub session_type: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostedRestSnapshotError { + OwnershipUnavailable, + ManagedUnavailable, +} + #[async_trait] pub trait HostedFreshAgentRestGateway: Send + Sync { + /// None leaves genuinely unmanaged threads on their existing read path. + /// A hosted read failure must never fall back to saved history as live truth. + async fn snapshot( + &self, + _request: HostedRestSnapshot, + ) -> Result, HostedRestSnapshotError> { + Err(HostedRestSnapshotError::OwnershipUnavailable) + } async fn create_agent( self: std::sync::Arc, request: HostedRestCreate, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index b8ba20c5e..13d6aaa9f 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -46,6 +46,7 @@ pub mod layout_store; pub mod layout_tree; pub mod model_capabilities; pub mod naming; +pub mod native_history; pub mod opencode_ws; pub mod pane_ops; mod pane_resize; @@ -1211,6 +1212,36 @@ pub mod ownership_lane { .cloned() } + pub(crate) fn current_local_snapshot_owner( + registry: &Option>, + stamps: &OwnershipStamps, + provider: &str, + native_id: &str, + runtime_key: &str, + pid: Option, + ) -> Option { + let Some(registry) = registry else { + return Some(freshell_ownership::OwnershipSnapshot { + epoch: 0, + generation: 0, + state: freshell_ownership::OwnershipState::Vacant, + }); + }; + let stamp = peek_retained_stamp(stamps, native_id)?; + let current = registry.observe(provider, native_id); + if current.epoch != stamp.epoch + || current.generation != stamp.generation + || stamp.owner.kind != RuntimeOwnerKind::FreshAgent + || stamp.owner.live_session_key.as_deref() != Some(runtime_key) + || stamp.owner.pid != pid + || pid.is_some_and(partial_pid_confirmed_dead) + || !matches!(¤t.state, freshell_ownership::OwnershipState::Live { owner, .. } if owner == &stamp.owner) + { + return None; + } + Some(current) + } + /// The fenced stop claim for an explicit kill: the lane's believed /// runtime identity plus the `(epoch, generation)` its `commit_live` /// stamped — with the wire pair a delayed client carried taking @@ -3019,6 +3050,24 @@ impl FreshAgentState { // ── GET /api/fresh-agent/threads/freshopencode/opencode/:threadId (Batch D PR-5) ── + pub(crate) async fn local_snapshot_owner( + &self, + native_id: &str, + ) -> Option { + // A shared daemon alone does not prove this conversation belongs to the local lane. + self.ownership.as_ref()?; + let manager = self.opencode.lock().await.clone()?; + manager.base_url().await?; + ownership_lane::current_local_snapshot_owner( + &self.ownership, + &self.ownership_stamps, + PROVIDER, + native_id, + native_id, + None, + ) + } + /// Build a `FreshAgentSnapshotSchema`-shaped JSON snapshot for an opencode session /// (`adapter.ts getSnapshot`, `adapter.ts:574-592` + `normalizeOpencodeSnapshot`, /// `normalize.ts:357-405`). `thread_id` is treated as the durable `ses_*` id (the id a @@ -8439,6 +8488,85 @@ mod tests { assert_eq!(snapshot["latestTurnId"], turns[1]["turnId"]); } + #[tokio::test] + async fn local_snapshot_owner_requires_opencode_session_claim_not_shared_daemon() { + let state = state_with_fixed_session_http( + json!({"id":"ses_local","time":{"updated":2}}), + json!([{ "info":{"id":"answer","role":"assistant"}, "parts":[{"type":"text","text":"Owned local answer"}] }]), + ).await; + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let state = state.with_ownership(registry.clone()); + assert!(state.local_snapshot_owner("ses_local").await.is_none()); + let mut ticket = match ownership_lane::begin_lane_claim( + &state.ownership, + "opencode", + "ses_local", + "owned-snapshot", + None, + "test", + 0, + ) { + ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("expected local claim"), + }; + ownership_lane::commit_lane_claim( + &state.ownership, + &state.ownership_stamps, + None, + "opencode", + "ses_local", + &mut ticket, + "ses_local", + None, + ) + .unwrap(); + assert_eq!( + state.local_snapshot_owner("ses_local").await, + Some(registry.observe("opencode", "ses_local")) + ); + let snapshot = state + .get_opencode_snapshot("ses_local", None) + .await + .unwrap(); + assert_eq!( + snapshot["turns"][0]["items"][0]["text"], + "Owned local answer" + ); + assert!(state + .local_snapshot_owner("ses_unregistered") + .await + .is_none()); + let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_handoff( + "opencode", + "ses_local", + freshell_ownership::RuntimeOwnerKind::Terminal, + "snapshot-handoff", + None, + "test", + freshell_ownership::now_epoch_ms(), + ) else { + panic!("expected handoff") + }; + assert!(state.local_snapshot_owner("ses_local").await.is_none()); + assert_eq!( + registry.commit_live( + "opencode", + "ses_local", + "snapshot-handoff", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::Terminal, + terminal_id: Some("foreign-terminal".into()), + live_session_key: None, + pid: None, + ownership_id: None + } + ), + freshell_ownership::CommitOutcome::Committed + ); + assert!(state.local_snapshot_owner("ses_local").await.is_none()); + } + /// Fix Task #3: a session id this process never created/attached to via any WS/REST /// pane (a stand-in for a HISTORICAL session opened from the sidebar) still serves a /// snapshot -- `get_opencode_snapshot` has no "is this in a live pane map" gate; it diff --git a/crates/freshell-freshagent/src/native_history.rs b/crates/freshell-freshagent/src/native_history.rs new file mode 100644 index 000000000..79ed5a058 --- /dev/null +++ b/crates/freshell-freshagent/src/native_history.rs @@ -0,0 +1,455 @@ +//! Read a selected native transcript without creating a provider runtime. +use rusqlite::{Connection, OpenFlags, OptionalExtension}; +use serde_json::{json, Value}; +use std::{collections::VecDeque, io::Read, path::Path}; + +#[path = "native_history_source.rs"] +mod source; +pub(crate) use source::{Records, Retention}; +pub(crate) const RETAINED_TURN_BYTES: usize = MAX_HISTORY_BYTES as usize / 4; + +pub const MAX_HISTORY_BYTES: u64 = 16 * 1024 * 1024; + +pub fn read(provider: &str, home: &Path, session_id: &str) -> Result { + if session_id.is_empty() || session_id.contains(['/', '\\']) { + return Err("invalid native session identity".into()); + } + let snapshot = match provider { + "claude" | "kilroy" => read_claude(home, session_id, provider)?, + "codex" => crate::codex::native_history::read(home, session_id)?, + "opencode" => read_opencode(home, session_id)?, + _ => return Err("native history reader does not support this provider".into()), + }; + readonly_snapshot(provider, snapshot) +} + +pub(crate) fn readonly_snapshot(provider: &str, mut snapshot: Value) -> Result { + if let Some(capabilities) = snapshot["capabilities"].as_object_mut() { + for value in capabilities.values_mut() { + if value.is_boolean() { + *value = json!(false); + } + } + } + snapshot["status"] = json!("idle"); + let wire_provider = if provider == "kilroy" { + "claude" + } else { + provider + }; + snapshot["extensions"][wire_provider]["ownerKind"] = json!("vacant"); + snapshot["extensions"][wire_provider]["nativeHistoryAvailable"] = json!(true); + finish_retention(provider, &mut snapshot, 0, 0, None)?; + Ok(snapshot) +} + +/// A display window, measured in serialized bytes, preserving original ordinals. +pub(crate) struct RetainedTurns { + values: VecDeque<(Value, usize)>, + bytes: usize, + pub(crate) omitted: usize, + retention: Retention, +} + +impl RetainedTurns { + pub(crate) fn new(retention: &Retention) -> Self { + Self { + values: VecDeque::new(), + bytes: 0, + omitted: 0, + retention: retention.clone(), + } + } + pub(crate) fn push(&mut self, mut value: Value) { + if serde_json::to_vec(&value).unwrap().len() > RETAINED_TURN_BYTES { + omit_large_bodies(&mut value, &self.retention); + } + let size = serde_json::to_vec(&value) + .expect("JSON value serializes") + .len(); + self.bytes += size; + self.values.push_back((value, size)); + while self.bytes > RETAINED_TURN_BYTES && self.values.len() > 1 { + self.bytes -= self.values.pop_front().unwrap().1; + self.omitted += 1; + } + } + pub(crate) fn values(self) -> Vec { + self.values.into_iter().map(|(value, _)| value).collect() + } +} + +/// Preserve the item and native control metadata while omitting a display body. +/// Used when a single task/message is larger than the retained window. +pub(crate) fn omit_large_bodies(value: &mut Value, retention: &Retention) { + match value { + Value::Object(object) => { + for (key, value) in object { + if matches!( + key.as_str(), + "text" + | "thinking" + | "content" + | "input" + | "output" + | "arguments" + | "result" + | "aggregatedOutput" + | "contentItems" + ) { + compact_body(value, retention); + } else { + omit_large_bodies(value, retention); + } + } + } + Value::Array(array) => { + for value in array { + omit_large_bodies(value, retention); + } + } + _ => {} + } +} + +fn compact_body(value: &mut Value, retention: &Retention) { + let limit = RETAINED_TURN_BYTES / 1024; + match value { + Value::String(text) if text.len() > limit => { + let mut start = text.len() - limit; + while !text.is_char_boundary(start) { + start += 1; + } + *text = format!("{}\n{}", retention.marker("body"), &text[start..]); + } + Value::Array(array) => { + for value in array { + if value.is_object() { + // A content block owns native type/link metadata. Compact + // its display fields without replacing the block itself. + omit_large_bodies(value, retention); + } else { + compact_body(value, retention); + } + } + } + // Structured tool input/result bodies can contain many small fields. + // Keep the established omission policy for these bodies; the containing + // call and its native identity remain available. + Value::Object(_) if serde_json::to_vec(value).unwrap().len() > limit => { + *value = json!({"Retained history":retention.marker("body")}); + } + _ => {} + } +} + +pub(crate) fn finish_retention( + provider: &str, + snapshot: &mut Value, + omitted_turns: usize, + omitted_items: usize, + retention: Option<&Retention>, +) -> Result<(), String> { + let provider = if provider == "kilroy" { + "claude" + } else { + provider + }; + let old = &snapshot["extensions"][provider]["nativeHistoryRetention"]; + let mut omitted_turns = + omitted_turns + old["omittedNativeTurns"].as_u64().unwrap_or(0) as usize; + let omitted_items = omitted_items + old["omittedItems"].as_u64().unwrap_or(0) as usize; + let omitted_bodies = old["omittedBodies"].as_u64().unwrap_or(0) as usize + + retention + .map(|retention| retention.finish(snapshot)) + .unwrap_or(0); + // Reserve framing and retention metadata before the helper's stdout boundary. + while serde_json::to_vec(snapshot) + .map_err(|e| e.to_string())? + .len() + > MAX_HISTORY_BYTES as usize - 4096 + { + let key = if snapshot["rolledBackTurns"] + .as_array() + .is_some_and(|turns| turns.len() > 1) + { + "rolledBackTurns" + } else { + "turns" + }; + let turns = snapshot[key] + .as_array_mut() + .ok_or("native history has no retained display turns")?; + if turns.len() <= 1 { + return Err("native history metadata exceeds display budget".into()); + } + let ordinal = turns[0]["ordinal"].clone(); + let native_id = turns[0]["turnId"].as_str().unwrap_or("").to_owned(); + let native_id = native_id + .rsplit_once(":row-") + .filter(|(_, row)| row.parse::().is_ok()) + .map(|(id, _)| id.to_owned()) + .unwrap_or(native_id); + let before = turns.len(); + if provider == "codex" { + turns.retain(|turn| { + let id = turn["turnId"].as_str().unwrap_or(""); + id != native_id + && !id + .strip_prefix(&native_id) + .is_some_and(|suffix| suffix.starts_with(":row-")) + }); + } else { + turns.retain(|turn| turn["ordinal"] != ordinal); + } + omitted_turns += usize::from(turns.len() != before); + } + if omitted_turns + omitted_items + omitted_bodies > 0 { + snapshot["extensions"][provider]["nativeHistoryRetention"] = json!({ + "partial":true, "omittedNativeTurns":omitted_turns, "omittedItems":omitted_items, + "omittedBodies":omitted_bodies, + "firstTurnId":snapshot["turns"].as_array().and_then(|turns| turns.first()).map(|turn| turn["turnId"].clone()), + "lastTurnId":snapshot["turns"].as_array().and_then(|turns| turns.last()).map(|turn| turn["turnId"].clone()), + }); + tracing::info!( + provider, + omitted_turns, + omitted_items, + omitted_bodies, + "freshagent.native_history.retained_window" + ); + } + Ok(()) +} + +fn read_opencode(home: &Path, id: &str) -> Result { + read_opencode_path(&home.join(".local/share/opencode/opencode.db"), id) +} + +pub(crate) fn read_opencode_path(path: &Path, id: &str) -> Result { + let companions_absent = + || !path.with_extension("db-wal").exists() && !path.with_extension("db-shm").exists(); + // A clean WAL close removes its companions. SQLite otherwise needs a writable + // directory even for READ_ONLY. Only the companion-free snapshot is immutable; + // existing WAL uses SQLite's normal transaction so committed rows remain visible. + let metadata = std::fs::metadata(path).map_err(|e| e.to_string())?; + let fingerprint = ( + metadata.len(), + metadata.modified().map_err(|e| e.to_string())?, + ); + let immutable = companions_absent(); + let connection = if immutable { + let absolute = path.canonicalize().map_err(|e| e.to_string())?; + let uri_path = absolute + .to_str() + .ok_or("native database path is not UTF-8")? + .replace('%', "%25") + .replace('?', "%3F") + .replace('#', "%23"); + Connection::open_with_flags( + format!("file:{uri_path}?immutable=1"), + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_URI, + ) + } else { + Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + } + .map_err(|e| e.to_string())?; + connection + .busy_timeout(std::time::Duration::from_secs(2)) + .map_err(|e| e.to_string())?; + // One read transaction includes committed WAL rows and keeps messages and parts consistent. + connection + .execute_batch("BEGIN") + .map_err(|e| e.to_string())?; + // Older native schemas predate revert; inspect capabilities without migrating the store. + let mut has_revert = false; + let mut columns = connection + .prepare("PRAGMA table_info(session)") + .map_err(|e| e.to_string())?; + for name in columns + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| e.to_string())? + { + has_revert |= name.map_err(|e| e.to_string())? == "revert"; + } + let session_query = if has_revert { + "SELECT title, time_updated, revert FROM session WHERE id = ?1" + } else { + "SELECT title, time_updated, NULL FROM session WHERE id = ?1" + }; + let mut info: Value = connection.query_row( + session_query, [id], + |row| Ok(json!({"id":id,"title":row.get::<_, String>(0)?,"time":{"updated":row.get::<_, i64>(1)?}, + "revert":row.get::<_, Option>(2)?.and_then(|text| serde_json::from_str::(&text).ok())}))) + .optional().map_err(|e| e.to_string())?.ok_or("saved native session not found")?; + if info["revert"].is_null() { + info.as_object_mut().unwrap().remove("revert"); + } + let retention = Retention::new(); + let mut active = RetainedTurns::new(&retention); + let mut rolled_back = RetainedTurns::new(&retention); + let mut omitted_parts = 0; + let pointer = info.pointer("/revert/messageID").and_then(Value::as_str); + let mut after_revert = false; + let mut statement = connection + .prepare("SELECT id FROM message WHERE session_id = ?1 ORDER BY time_created, id") + .map_err(|e| e.to_string())?; + let rows = statement + .query_map([id], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + for (ordinal, row) in rows.enumerate() { + let message_id = row.map_err(|e| e.to_string())?; + after_revert |= pointer == Some(message_id.as_str()); + let (mut message, mut source_omissions) = + read_sql_json(&connection, "message", &message_id, &retention)?; + message["id"] = json!(message_id); + let mut parts = RetainedTurns::new(&retention); + let mut statement = connection.prepare("SELECT id FROM part WHERE session_id = ?1 AND message_id = ?2 ORDER BY time_created, id").map_err(|e| e.to_string())?; + let rows = statement + .query_map([id, &message_id], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + for row in rows { + let part_id = row.map_err(|e| e.to_string())?; + let (mut part, omitted) = read_sql_json(&connection, "part", &part_id, &retention)?; + source_omissions += omitted; + part["id"] = json!(part_id); + parts.push(part); + } + omitted_parts += parts.omitted; + let message = json!({"info":message,"parts":parts.values()}); + if let Some(mut turn) = crate::opencode_message_turn_json(&message, ordinal) { + omitted_parts += source_omissions; + if after_revert { + turn["rolledBack"] = json!(true); + turn["restorable"] = json!(false); + rolled_back.push(turn); + } else { + active.push(turn); + } + } + } + if immutable { + let current = std::fs::metadata(path).map_err(|e| e.to_string())?; + if !companions_absent() + || ( + current.len(), + current.modified().map_err(|e| e.to_string())?, + ) != fingerprint + { + return Err( + "native database changed during history read; retry the history read".into(), + ); + } + } + let omitted = active.omitted + rolled_back.omitted; + let active = active.values(); + let rolled_back = rolled_back.values(); + let mut snapshot = crate::build_opencode_snapshot_json(id, &info, &json!([]), None); + snapshot["latestTurnId"] = active + .last() + .map(|turn| turn["turnId"].clone()) + .unwrap_or(Value::Null); + snapshot["turns"] = json!(active); + if !rolled_back.is_empty() { + snapshot["rolledBackTurns"] = json!(rolled_back); + } + finish_retention( + "opencode", + &mut snapshot, + omitted, + omitted_parts, + Some(&retention), + )?; + Ok(snapshot) +} + +/// SQLite TEXT can itself be huge; read exact UTF-8 bytes in chunks inside the +/// same read transaction instead of allocating an entire row before clipping. +fn read_sql_json( + connection: &Connection, + table: &str, + id: &str, + retention: &Retention, +) -> Result<(Value, usize), String> { + let query = if table == "message" { + "SELECT rowid FROM message WHERE id=?1" + } else { + "SELECT rowid FROM part WHERE id=?1" + }; + let rowid = connection + .query_row(query, [id], |row| row.get::<_, i64>(0)) + .map_err(|e| e.to_string())?; + let blob = connection + .blob_open(rusqlite::DatabaseName::Main, table, "data", rowid, true) + .map_err(|e| e.to_string())?; + source::bounded_value( + std::io::BufReader::with_capacity(64 * 1024, blob), + retention, + ) + .map_err(|e| e.to_string()) +} + +fn read_claude(home: &Path, id: &str, provider: &str) -> Result { + let path = crate::claude_snapshot::find_transcript(&home.join(".claude"), id) + .ok_or("saved native session not found")?; + let file = std::fs::File::open(path).map_err(|e| e.to_string())?; + let metadata = file.metadata().map_err(|e| e.to_string())?; + let extent = metadata.len(); + let retention = Retention::new(); + let source = Records::new(std::io::BufReader::new(file.take(extent)), &retention); + let mut turns = RetainedTurns::new(&retention); + let mut ordinal = 0; + let mut omitted_items = 0; + for record in source { + let Some((record, omitted)) = record.map_err(|e| e.to_string())? else { + continue; + }; + if let Some(turn) = crate::claude_snapshot::parse_transcript_turn_indexed( + &record, + id, + ordinal, + Some(&retention.index_key), + ) { + omitted_items += omitted; + turns.push(turn); + ordinal += 1; + } + } + let omitted = turns.omitted; + let turns = turns.values(); + let revision = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + let mut snapshot = crate::claude_snapshot::build_claude_snapshot_json( + if provider == "kilroy" { + "kilroy" + } else { + "freshclaude" + }, + id, + "", + revision, + None, + ); + snapshot["latestTurnId"] = turns + .last() + .map(|turn| turn["turnId"].clone()) + .unwrap_or(Value::Null); + snapshot["turns"] = json!(turns); + finish_retention( + provider, + &mut snapshot, + omitted, + omitted_items, + Some(&retention), + )?; + Ok(snapshot) +} diff --git a/crates/freshell-freshagent/src/native_history_source.rs b/crates/freshell-freshagent/src/native_history_source.rs new file mode 100644 index 000000000..ca09d002a --- /dev/null +++ b/crates/freshell-freshagent/src/native_history_source.rs @@ -0,0 +1,601 @@ +//! Bounded display strings over an exact, read-only native JSON source. +//! +//! This adapter leaves JSON structure, native identities and small values intact. +//! Large bodies are consumed through EOF without materializing them. Their digest +//! remains available to the native message mirror matcher until display projection. +use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::io::{self, BufRead, Read}; +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + rc::Rc, +}; + +const STRING_BYTES: usize = super::RETAINED_TURN_BYTES; + +/// Only tokens created by this read are interpreted as omissions. Source text +/// cannot accidentally acquire the meaning of an internal display marker. +#[derive(Clone)] +pub(crate) struct Retention { + prefix: String, + pub(crate) index_key: String, + pub(crate) fingerprint_key: String, +} +impl Retention { + pub(crate) fn new() -> Self { + let prefix = format!("FRESHELL_NATIVE_HISTORY_{}:", uuid::Uuid::new_v4()); + Self { + index_key: format!("{prefix}index"), + fingerprint_key: format!("{prefix}fingerprint"), + prefix, + } + } + pub(crate) fn marker(&self, reason: &str) -> String { + format!("{}{};", self.prefix, reason) + } + pub(crate) fn finish(&self, value: &mut Value) -> usize { + match value { + Value::String(text) => { + if !text.contains(&self.prefix) { + return 0; + } + let mut output = String::with_capacity(text.len()); + let mut count = 0; + let mut start = 0; + while let Some(relative) = text[start..].find(&self.prefix) { + let begin = start + relative; + let Some(end) = text[begin..].find(';').map(|end| begin + end + 1) else { + break; + }; + output.push_str(&text[start..begin]); + let reason = &text[begin + self.prefix.len()..end - 1]; + if reason == "body" + || reason == "collection" + || reason.len() == 64 && reason.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + output.push_str("[Content omitted from retained history]"); + count += 1; + } else { + output.push_str(&text[begin..end]); + } + start = end; + } + output.push_str(&text[start..]); + *text = output; + count + } + Value::Array(values) => values.iter_mut().map(|value| self.finish(value)).sum(), + Value::Object(values) => { + values.remove(&self.index_key); + values.remove(&self.fingerprint_key); + values + .iter_mut() + .map(|(key, value)| { + let count = self.finish(value); + if key == "summary" { + 0 + } else { + count + } + }) + .sum() + } + _ => 0, + } + } +} + +// Hash original decoded Codex message text as it streams, before array or +// body retention. Event copies join the same text into one string; display +// previews alone cannot establish whether those records mirror one message. +#[derive(Default)] +struct MessageCapture { + hash: RefCell, + active: Cell, + part: Cell, + started: Cell, + parts: Cell, +} +impl MessageCapture { + fn append(&self, bytes: &[u8]) { + if !self.started.replace(true) { + if self.part.get() && self.parts.get() > 0 { + self.hash.borrow_mut().update(b"\n"); + } + self.parts.set(self.parts.get() + 1); + } + self.hash.borrow_mut().update(bytes); + } +} + +pub(crate) struct DisplaySource { + input: R, + pending: Vec, + offset: usize, + key: String, + retention: Retention, + capture: Rc, +} + +impl DisplaySource { + fn new(input: R, retention: Retention, capture: Rc) -> Self { + Self { + input, + pending: Vec::new(), + offset: 0, + key: String::new(), + retention, + capture, + } + } + + fn byte(&mut self) -> io::Result> { + let byte = self.input.fill_buf()?.first().copied(); + if byte.is_some() { + self.input.consume(1); + } + Ok(byte) + } + + fn token(&mut self) -> io::Result<()> { + self.pending.clear(); + self.offset = 0; + let Some(first) = self.byte()? else { + return Ok(()); + }; + self.pending.push(first); + if first != b'"' { + return Ok(()); + } + let mut escaped = false; + let mut large = false; + let mut hash = StringDigest::new(self.capture.clone()); + loop { + let Some(byte) = self.byte()? else { + // A partial final JSONL record remains malformed for the parser. + return Ok(()); + }; + if byte == b'"' && !escaped { + break; + } + if !large && self.pending.len() < STRING_BYTES { + self.pending.push(byte); + } else { + if !large { + for byte in &self.pending[1..] { + hash.byte(*byte)?; + } + large = true; + } + hash.byte(byte)?; + } + escaped = byte == b'\\' && !escaped; + } + while self + .input + .fill_buf()? + .first() + .is_some_and(u8::is_ascii_whitespace) + { + self.input.consume(1); + } + let is_key = self.input.fill_buf()?.first() == Some(&b':'); + if large { + if is_key + || matches!( + self.key.as_str(), + "id" | "uuid" + | "parentUuid" + | "turn_id" + | "call_id" + | "tool_use_id" + | "sessionId" + | "threadId" + | "session_id" + ) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "native history identity is oversized", + )); + } + let (digest, tail) = hash.finish()?; + self.pending = + serde_json::to_vec(&format!("{}\n{tail}", self.retention.marker(&digest))) + .map_err(io::Error::other)?; + } else { + self.pending.push(b'"'); + if !is_key && self.capture.active.get() { + let text: String = + serde_json::from_slice(&self.pending).map_err(io::Error::other)?; + self.capture.append(text.as_bytes()); + } + if is_key { + self.key = serde_json::from_slice(&self.pending).map_err(io::Error::other)?; + } + } + Ok(()) + } +} + +// Compare decoded body text even when mirrors use different JSON escaping. +struct StringDigest { + hash: Sha256, + bytes: Vec, + escape: Vec, + high_surrogate: Option, + tail: Vec, + capture: Rc, +} +impl StringDigest { + fn new(capture: Rc) -> Self { + Self { + hash: Sha256::new(), + bytes: Vec::with_capacity(8192), + escape: Vec::new(), + high_surrogate: None, + tail: Vec::new(), + capture, + } + } + fn byte(&mut self, byte: u8) -> io::Result<()> { + if self.escape.is_empty() { + if byte == b'\\' { + self.escape.push(byte); + } else { + self.bytes.push(byte); + } + } else if self.escape.len() == 1 { + if byte == b'u' { + self.escape.push(byte); + } else { + let decoded = match byte { + b'"' | b'\\' | b'/' => byte, + b'b' => 8, + b'f' => 12, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid native JSON escape", + )) + } + }; + self.bytes.push(decoded); + self.escape.clear(); + } + } else { + self.escape.push(byte); + if self.escape.len() == 6 { + let hex = std::str::from_utf8(&self.escape[2..]).map_err(io::Error::other)?; + let code = u32::from_str_radix(hex, 16).map_err(io::Error::other)?; + self.escape.clear(); + if (0xd800..=0xdbff).contains(&code) { + self.high_surrogate = Some(code); + } else { + let code = if let Some(high) = self.high_surrogate.take() { + if !(0xdc00..=0xdfff).contains(&code) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid native Unicode surrogate", + )); + } + 0x10000 + ((high - 0xd800) << 10) + code - 0xdc00 + } else { + code + }; + let character = char::from_u32(code).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "invalid native Unicode escape") + })?; + let mut encoded = [0; 4]; + self.bytes + .extend_from_slice(character.encode_utf8(&mut encoded).as_bytes()); + } + } + } + if self.bytes.len() >= 8192 { + self.flush(); + } + Ok(()) + } + fn flush(&mut self) { + self.hash.update(&self.bytes); + if self.capture.active.get() { + self.capture.append(&self.bytes); + } + self.tail.extend_from_slice(&self.bytes); + if self.tail.len() > super::RETAINED_TURN_BYTES / 1024 { + self.tail + .drain(..self.tail.len() - super::RETAINED_TURN_BYTES / 1024); + } + self.bytes.clear(); + } + fn finish(mut self) -> io::Result<(String, String)> { + if !self.escape.is_empty() || self.high_surrogate.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "incomplete native JSON escape", + )); + } + self.flush(); + while self.tail.first().is_some_and(|byte| byte & 0xc0 == 0x80) { + self.tail.remove(0); + } + Ok(( + format!("{:x}", self.hash.finalize()), + String::from_utf8(self.tail).map_err(io::Error::other)?, + )) + } +} + +impl Read for DisplaySource { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + let mut written = 0; + while written < output.len() { + if self.offset == self.pending.len() { + self.token()?; + if self.pending.is_empty() { + break; + } + } + let count = (output.len() - written).min(self.pending.len() - self.offset); + output[written..written + count] + .copy_from_slice(&self.pending[self.offset..self.offset + count]); + written += count; + self.offset += count; + } + Ok(written) + } +} + +/// Scan JSONL one captured record at a time. Malformed native records do not +/// prevent earlier or subsequent complete durable records from being displayed. +pub(crate) struct Records { + input: R, + retention: Retention, +} +impl Records { + pub(crate) fn new(input: R, retention: &Retention) -> Self { + Self { + input, + retention: retention.clone(), + } + } +} +struct Line<'a, R> { + input: &'a mut R, + complete: &'a Cell, +} +impl Read for Line<'_, R> { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if self.complete.get() || output.is_empty() { + return Ok(0); + } + let input = self.input.fill_buf()?; + let count = input + .iter() + .position(|byte| *byte == b'\n') + .map(|end| end + 1) + .unwrap_or(input.len()) + .min(output.len()); + output[..count].copy_from_slice(&input[..count]); + if count > 0 && input[count - 1] == b'\n' { + self.complete.set(true); + } + self.input.consume(count); + Ok(count) + } +} +impl Iterator for Records { + type Item = io::Result>; + fn next(&mut self) -> Option { + match self.input.fill_buf() { + Ok([]) => return None, + Err(error) => return Some(Err(error)), + _ => {} + } + let complete = Cell::new(false); + let line = Line { + input: &mut self.input, + complete: &complete, + }; + let result = bounded_value(std::io::BufReader::new(line), &self.retention); + if !complete.get() { + if let Err(error) = self.input.skip_until(b'\n') { + return Some(Err(error)); + } + } + Some(match result { + Ok(record) => Ok(Some(record)), + Err(error) + if error.is_io() && error.io_error_kind() != Some(io::ErrorKind::InvalidData) => + { + Err(io::Error::other(error)) + } + Err(_) => Ok(None), + }) + } +} + +pub(crate) fn bounded_value( + reader: impl BufRead, + retention: &Retention, +) -> Result<(Value, usize), serde_json::Error> { + let omitted = Rc::new(Cell::new(0)); + let capture = Rc::new(MessageCapture::default()); + let mut deserializer = serde_json::Deserializer::from_reader(DisplaySource::new( + reader, + retention.clone(), + capture.clone(), + )); + let mut value = BoundedValue { + omitted: omitted.clone(), + retention: retention.clone(), + capture: capture.clone(), + path: Vec::new(), + } + .deserialize(&mut deserializer)?; + deserializer.end()?; + if capture.parts.get() > 0 { + if let Some(payload) = value.get_mut("payload").and_then(Value::as_object_mut) { + payload.insert( + retention.fingerprint_key.clone(), + Value::String(format!("{:x}", capture.hash.borrow().clone().finalize())), + ); + } + } + Ok((value, omitted.get())) +} + +/// serde's visitor bounds collections as they are decoded, before a large +/// array or arbitrary tool result can allocate the complete source tree. +struct BoundedValue { + omitted: Rc>, + retention: Retention, + capture: Rc, + path: Vec, +} +impl BoundedValue { + fn child(&self, key: String) -> Self { + let mut path = self.path.clone(); + path.push(key); + Self { + omitted: self.omitted.clone(), + retention: self.retention.clone(), + capture: self.capture.clone(), + path, + } + } +} +impl<'de> DeserializeSeed<'de> for BoundedValue { + type Value = Value; + fn deserialize>(self, deserializer: D) -> Result { + let message = self.path.len() == 2 + && self.path[0] == "payload" + && matches!(self.path[1].as_str(), "message" | "last_agent_message"); + let part = self.path.len() == 4 + && self.path[0] == "payload" + && self.path[1] == "content" + && self.path[3] == "text"; + self.capture.active.set(message || part); + self.capture.part.set(part); + self.capture.started.set(false); + let capture = self.capture.clone(); + let result = deserializer.deserialize_any(self); + capture.active.set(false); + result + } +} +impl<'de> Visitor<'de> for BoundedValue { + type Value = Value; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a native JSON value") + } + fn visit_bool(self, value: bool) -> Result { + Ok(Value::Bool(value)) + } + fn visit_i64(self, value: i64) -> Result { + Ok(Value::from(value)) + } + fn visit_u64(self, value: u64) -> Result { + Ok(Value::from(value)) + } + fn visit_f64(self, value: f64) -> Result { + Ok(Value::from(value)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_str(self, value: &str) -> Result { + Ok(Value::String(value.into())) + } + fn visit_seq>(self, mut sequence: A) -> Result { + let mut values = VecDeque::new(); + let mut bytes = 0; + let mut index = 0; + loop { + let Some(mut value) = sequence.next_element_seed(self.child(index.to_string()))? else { + break; + }; + if self.path == ["message", "content"] || self.path == ["payload", "content"] { + if let Some(object) = value.as_object_mut() { + object.insert(self.retention.index_key.clone(), Value::from(index)); + } + } + index += 1; + if serde_json::to_vec(&value) + .map_err(serde::de::Error::custom)? + .len() + > super::RETAINED_TURN_BYTES + { + super::omit_large_bodies(&mut value, &self.retention); + } + let size = serde_json::to_vec(&value) + .map_err(serde::de::Error::custom)? + .len(); + bytes += size; + values.push_back((value, size)); + while bytes > super::RETAINED_TURN_BYTES && values.len() > 1 { + bytes -= values.pop_front().unwrap().1; + self.omitted.set(self.omitted.get() + 1); + } + } + Ok(Value::Array( + values.into_iter().map(|(value, _)| value).collect(), + )) + } + fn visit_map>(self, mut object: A) -> Result { + let mut values = Map::new(); + let mut bytes = 0; + while let Some(key) = object.next_key::()? { + // Control/identity containers are kept even when an arbitrary body + // filled the preview. Do not lose a task_complete or tool link. + let control = matches!( + key.as_str(), + "payload" + | "content" + | "text" + | "last_agent_message" + | "message" + | "item" + | "info" + | "state" + | "id" + | "uuid" + | "parentUuid" + | "type" + | "role" + | "status" + | "turn_id" + | "call_id" + | "tool_use_id" + | "sessionId" + | "threadId" + | "session_id" + | "name" + | "tool" + | "server" + ); + if bytes > super::RETAINED_TURN_BYTES * 2 && !control { + object.next_value::()?; + self.omitted.set(self.omitted.get() + 1); + values.insert(key, Value::String(self.retention.marker("collection"))); + } else { + let value = object.next_value_seed(self.child(key.clone()))?; + bytes += key.len() + + serde_json::to_vec(&value) + .map_err(serde::de::Error::custom)? + .len(); + values.insert(key, value); + } + } + Ok(Value::Object(values)) + } +} diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index da7a843e6..4f86533bd 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -28,8 +28,9 @@ //! //! All three providers are served: **freshcodex/codex** asks its live runtime //! slice — SIDE-EFFECT-FREE (kata b8ke Task 5): a thread this process tracks -//! serves from the live runtime, an untracked one answers the EMPTY snapshot -//! (with the additive owner-state fields), and a session another runtime owns +//! serves from the live runtime, an untracked one reads its exact saved rollout +//! (or an empty snapshot when absent, with the additive owner-state fields), and +//! a session another runtime owns //! or a transition holds answers the typed 409 envelope — never a spawn or a //! resume; **freshopencode/opencode** (b8ke delta review F4) is the same //! side-effect-free contract against the shared `opencode serve` daemon: the @@ -93,6 +94,92 @@ impl SnapshotState { claude, } } + async fn local_owner( + &self, + session_type: &str, + provider: &str, + native_id: &str, + ) -> Option { + match (session_type, provider) { + ("freshcodex", "codex") => self.codex.local_snapshot_owner(native_id).await, + ("freshclaude" | "kilroy", "claude") => { + self.claude.local_snapshot_owner(native_id).await + } + ("freshopencode", "opencode") => self.opencode.local_snapshot_owner(native_id).await, + _ => None, + } + } + + async fn exact_saved_history( + &self, + session_type: &str, + provider: &str, + native_id: &str, + ) -> Option { + let snapshot = match (session_type, provider) { + ("freshcodex", "codex") => self + .codex + .exact_saved_snapshot(native_id) + .await + .ok() + .flatten()?, + ("freshclaude" | "kilroy", "claude") => { + let rollback = self.claude.load_rollback_record(native_id).await; + let saved = crate::claude_snapshot::get_claude_snapshot( + session_type, + native_id, + rollback.as_ref(), + ) + .await + .ok()?; + crate::native_history::readonly_snapshot(provider, saved).ok()? + } + ("freshopencode", "opencode") => { + let id = native_id.to_owned(); + let path = + freshell_sessions::parse::default_opencode_data_home().join("opencode.db"); + tokio::task::spawn_blocking(move || { + crate::native_history::read_opencode_path(&path, &id).and_then(|snapshot| { + crate::native_history::readonly_snapshot("opencode", snapshot) + }) + }) + .await + .ok()? + .ok()? + } + _ => return None, + }; + (snapshot["threadId"].as_str() == Some(native_id) + && snapshot["provider"].as_str() == Some(provider) + && snapshot["sessionType"].as_str() == Some(session_type)) + .then_some(snapshot) + } + + async fn saved_history_or_unavailable( + &self, + session_type: &str, + provider: &str, + native_id: &str, + ) -> Response { + let saved = self + .exact_saved_history(session_type, provider, native_id) + .await; + tracing::debug!( + event = "fresh_agent.snapshot.saved_history_fallback", + session_type, + provider, + native_id, + available = saved.is_some(), + "Live snapshot unavailable; read exact saved history" + ); + match saved { + Some(snapshot) => Json(snapshot).into_response(), + None => fail( + StatusCode::SERVICE_UNAVAILABLE, + "Managed conversation snapshot unavailable".into(), + ), + } + } } /// The pre-bound snapshot sub-router. @@ -116,7 +203,40 @@ async fn get_snapshot( } let cwd = query.get("cwd").cloned(); - match (session_type.as_str(), provider.as_str()) { + let local_owner = state + .local_owner(&session_type, &provider, &thread_id) + .await; + if local_owner.is_none() + && VALID_SESSION_TYPES.contains(&session_type.as_str()) + && VALID_PROVIDERS.contains(&provider.as_str()) + { + if let Some(gateway) = state.opencode.hosted_rest_gateway() { + match gateway + .snapshot(crate::hosted_rest::HostedRestSnapshot { + session_id: thread_id.clone(), + provider: provider.clone(), + session_type: session_type.clone(), + }) + .await + { + Ok(Some(snapshot)) => return Json(snapshot).into_response(), + Ok(None) => {} + Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable) => { + return fail( + StatusCode::SERVICE_UNAVAILABLE, + "Managed conversation snapshot unavailable".into(), + ); + } + Err(crate::hosted_rest::HostedRestSnapshotError::OwnershipUnavailable) => { + return state + .saved_history_or_unavailable(&session_type, &provider, &thread_id) + .await + } + } + } + } + + let response = match (session_type.as_str(), provider.as_str()) { ("freshcodex", "codex") => match state.codex.get_snapshot(&thread_id, cwd.as_deref()).await { Ok(snapshot) => Json(snapshot).into_response(), @@ -148,7 +268,9 @@ async fn get_snapshot( CodexSnapshotError::HandoffInProgress { generation } => (None, *generation), _ => unreachable!("the match above names only the typed refusals"), }; - snapshot_error_response(&thread_id, owner_kind, generation) + // The provider already refused this read using the current ownership fence. + // Preserve that refusal rather than replacing it with saved history below. + return snapshot_error_response(&thread_id, owner_kind, generation); } // Defensive depth: `get_snapshot` already folds this into its // `Ok` (the empty snapshot), so this arm is unreachable today — @@ -190,7 +312,7 @@ async fn get_snapshot( } _ => unreachable!("the match above names only the typed refusals"), }; - snapshot_error_response(&thread_id, owner_kind, generation) + return snapshot_error_response(&thread_id, owner_kind, generation); } } } @@ -255,7 +377,21 @@ async fn get_snapshot( "FRESH_AGENT_RUNTIME_UNAVAILABLE", ) } + }; + // A local read never publishes the authority of an owner that changed while it awaited the provider. + if let Some(before) = local_owner { + if state + .local_owner(&session_type, &provider, &thread_id) + .await + .as_ref() + != Some(&before) + { + return state + .saved_history_or_unavailable(&session_type, &provider, &thread_id) + .await; + } } + response } fn fail(status: StatusCode, message: String) -> Response { @@ -370,6 +506,68 @@ mod tests { headers } + struct SnapshotGateway; + + #[async_trait::async_trait] + impl crate::hosted_rest::HostedFreshAgentRestGateway for SnapshotGateway { + async fn create_agent( + self: Arc, + _: crate::hosted_rest::HostedRestCreate, + ) -> Result { + panic!("GET must not create") + } + async fn send_agent( + &self, + _: crate::hosted_rest::HostedRestSend, + ) -> Result { + panic!("GET must not send") + } + async fn snapshot( + &self, + request: crate::hosted_rest::HostedRestSnapshot, + ) -> Result, crate::hosted_rest::HostedRestSnapshotError> + { + if request.session_id == "unavailable-host" { + return Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable); + } + Ok(Some( + json!({"threadId":request.session_id,"status":"running", + "provider":request.provider,"sessionType":request.session_type, + "turns":[{"turnId":"owned-live-turn"}]}), + )) + } + } + + #[tokio::test] + async fn existing_snapshot_get_reads_hosted_truth_and_never_falls_back_on_host_failure() { + for (id, expected) in [ + ("owned-host", StatusCode::OK), + ("unavailable-host", StatusCode::SERVICE_UNAVAILABLE), + ] { + let state = snapshot_state(); + state + .opencode + .set_hosted_rest_gateway(Arc::new(SnapshotGateway)) + .unwrap(); + let response = get_snapshot( + State(state), + Path(("freshcodex".into(), "codex".into(), id.into())), + Query(HashMap::new()), + headers_with_token("tok"), + ) + .await; + assert_eq!(response.status(), expected); + if expected == StatusCode::OK { + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["status"], "running"); + assert_eq!(value["turns"][0]["turnId"], "owned-live-turn"); + } + } + } + #[tokio::test] async fn missing_auth_header_is_401() { let resp = get_snapshot( @@ -881,6 +1079,175 @@ mod tests { ); } + async fn codex_route_json(app: &Router, id: &str) -> (StatusCode, serde_json::Value) { + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/fresh-agent/threads/freshcodex/codex/{id}")) + .header("x-auth-token", "tok") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + #[tokio::test] + async fn untracked_codex_route_reads_exact_saved_rollout_without_starting_or_writing() { + let _guard = crate::codex::tests::ENV_LOCK.lock().await; + let home = tempfile::tempdir().unwrap(); + let sessions = home.path().join("sessions/2026/03/01"); + std::fs::create_dir_all(&sessions).unwrap(); + let transcript = format!( + "{}{}\n", + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl"), + include_str!("../../../test/fixtures/managed-native-history/codex-tools.jsonl") + .lines() + .skip(1) + .collect::>() + .join("\n") + ); + let rollout = sessions.join("rollout-session-activity.jsonl"); + std::fs::write(&rollout, &transcript).unwrap(); + // A filename containing a requested identity does not prove ownership. + std::fs::write(sessions.join("rollout-foreign-session.jsonl"), &transcript).unwrap(); + let modified = std::fs::metadata(&rollout).unwrap().modified().unwrap(); + let old_home = std::env::var_os("CODEX_HOME"); + let old_cmd = std::env::var_os("CODEX_CMD"); + std::env::set_var("CODEX_HOME", home.path()); + std::env::set_var("CODEX_CMD", "/definitely/not/a/codex-binary"); + let probe = tempfile::tempdir().unwrap(); + let marker = probe.path().join("spawned"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let script = probe.path().join("codex-probe"); + std::fs::write( + &script, + format!("#!/bin/sh\ntouch '{}'\nexit 0\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::env::set_var("CODEX_CMD", script); + } + let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let mut codex = codex_state(); + codex.set_ownership(ownership.clone()); + let app = router(SnapshotState::new( + Arc::new("tok".into()), + codex, + opencode_state(), + claude_state(), + )); + let mut results = Vec::new(); + for id in ["session-activity", "foreign-session", "genuinely-absent"] { + results.push(codex_route_json(&app, id).await); + } + assert_eq!( + ownership.observe("codex", "session-activity").state, + freshell_ownership::OwnershipState::Vacant + ); + let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_start( + "codex", + "session-activity", + freshell_ownership::RuntimeOwnerKind::Terminal, + "read-test", + None, + "test", + 1_000, + ) else { + panic!("test grants starting ownership") + }; + let starting = codex_route_json(&app, "session-activity").await; + assert_eq!( + ownership.commit_live( + "codex", + "session-activity", + "read-test", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::Terminal, + terminal_id: Some("terminal-reader-test".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + ); + let terminal_owned = codex_route_json(&app, "session-activity").await; + for (key, old) in [("CODEX_HOME", old_home), ("CODEX_CMD", old_cmd)] { + match old { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + let (status, snapshot) = &results[0]; + assert_eq!(*status, StatusCode::OK, "{snapshot}"); + let items: Vec<_> = snapshot["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert!( + items.iter().any(|item| item["text"] == "Sanitized prompt"), + "{snapshot}" + ); + assert!(items + .iter() + .any(|item| item["text"] == "Sanitized completion")); + assert!(items.iter().any(|item| item["kind"] == "dynamic_tool" + && item["contentItems"][0]["text"] == "Patch saved")); + assert!(items + .iter() + .any(|item| item["kind"] == "command" && item["output"] == "/workspace")); + assert!(items.iter().any(|item| item["kind"] == "mcp_tool")); + assert_eq!( + snapshot["extensions"]["codex"]["nativeHistoryAvailable"], + true + ); + assert_eq!(snapshot["extensions"]["codex"]["ownerKind"], "vacant"); + assert!(snapshot["extensions"]["codex"]["ownerEpoch"].is_number()); + assert!(snapshot["capabilities"] + .as_object() + .unwrap() + .values() + .filter(|value| value.is_boolean()) + .all(|value| value == false)); + for (status, absent) in &results[1..] { + assert_eq!(*status, StatusCode::OK); + assert_eq!( + absent["turns"], + json!([]), + "must not select a foreign rollout" + ); + } + assert_eq!(std::fs::read_to_string(&rollout).unwrap(), transcript); + assert_eq!( + std::fs::metadata(&rollout).unwrap().modified().unwrap(), + modified + ); + assert_eq!(std::fs::read_dir(home.path()).unwrap().count(), 1); + assert!(!marker.exists(), "snapshot GET must not start the provider"); + for (status, refusal) in [starting, terminal_owned] { + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(refusal["code"], "RESTORE_UNAVAILABLE"); + assert!( + refusal.get("turns").is_none(), + "saved data cannot bypass ownership" + ); + } + } + #[tokio::test] async fn codex_snapshot_success_returns_200_with_camelcase_body() { let (transport, peer) = freshell_codex::new_channel_transport(); diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index da635b303..085c80ca8 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -5,15 +5,15 @@ //! supervisor's registry-backed `OwnedRuntimeHandle` boundary. use freshell_runtime_protocol::{ - read_frame, write_frame, AcknowledgeViewProjectionRequest, AdminCommand, AdminReply, - AdminResult, ControlRole, Envelope, FreshAgentCapture, FreshAgentCaptureRequest, - FreshAgentCompactRequest, FreshAgentForkRequest, FreshAgentInterruptRequest, - FreshAgentReadEventsRequest, FreshAgentResolveRequest, FreshAgentRollbackDirection, - FreshAgentRollbackMode, FreshAgentRollbackRequest, FreshAgentSendRequest, IncidentId, - IncidentSummaryRequest, LaunchRequest, LossIncidentSummary, ManagedRolloutMode, MigrationPlan, - MigrationPlanRequest, NoticeDeliveryState, NoticeId, NoticeReceiptRequest, - PendingNoticesRequest, PendingViewProjectionsRequest, RecoverRequest, RecoveryProbeRequest, - RecoveryTrigger, RepairAudit, RepairRequest, RequestId, RuntimeError, RuntimeErrorCode, + write_frame, AcknowledgeViewProjectionRequest, AdminCommand, AdminReply, AdminResult, + ControlRole, Envelope, FreshAgentCapture, FreshAgentCaptureRequest, FreshAgentCompactRequest, + FreshAgentForkRequest, FreshAgentInterruptRequest, FreshAgentReadEventsRequest, + FreshAgentResolveRequest, FreshAgentRollbackDirection, FreshAgentRollbackMode, + FreshAgentRollbackRequest, FreshAgentSendRequest, IncidentId, IncidentSummaryRequest, + LaunchRequest, LossIncidentSummary, ManagedRolloutMode, MigrationPlan, MigrationPlanRequest, + NoticeDeliveryState, NoticeId, NoticeReceiptRequest, PendingNoticesRequest, + PendingViewProjectionsRequest, RecoverRequest, RecoveryProbeRequest, RecoveryTrigger, + RepairAudit, RepairRequest, RequestId, RuntimeError, RuntimeErrorCode, RuntimeInventorySnapshot, RuntimeMetricsRequest, RuntimeMetricsSnapshot, RuntimeNotice, RuntimeView, SoulId, StopOutcome, StopRequest, TerminalInputRequest, TerminalReadOutputRequest, TerminalResizeRequest, UpdateLimitsRequest, UpdateLimitsResult, UpdateViewVisibilityRequest, @@ -747,6 +747,42 @@ impl RuntimeClient { } } + pub async fn fresh_agent_snapshot( + &self, + soul_id: SoulId, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentReadSnapshotRequest { + soul_id, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentReadSnapshot(request), + ) + .await + { + Ok(AdminResult::FreshAgentSnapshot(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentReadSnapshot(request), + ) + .await? + { + AdminResult::FreshAgentSnapshot(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -812,6 +848,42 @@ impl RuntimeClient { } } + pub async fn fresh_agent_history( + &self, + soul_id: SoulId, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentReadHistoryRequest { + soul_id, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentReadHistory(request), + ) + .await + { + Ok(AdminResult::FreshAgentHistory(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentReadHistory(request), + ) + .await? + { + AdminResult::FreshAgentHistory(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn metrics( &self, soul_id: SoulId, @@ -964,6 +1036,14 @@ impl RuntimeClient { body: AdminCommand, ) -> Result { let mut stream = UnixStream::connect(self.socket_path.as_ref()).await?; + let reply_limit = if matches!( + body, + AdminCommand::FreshAgentReadHistory(_) | AdminCommand::FreshAgentReadSnapshot(_) + ) { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; let envelope = Envelope { protocol_version: CONTROL_PROTOCOL_VERSION, request_id, @@ -974,9 +1054,10 @@ impl RuntimeClient { write_frame(&mut stream, &envelope) .await .map_err(|error| ClientError::Protocol(error.to_string()))?; - let reply: AdminReply = read_frame(&mut stream) - .await - .map_err(|error| ClientError::Protocol(error.to_string()))?; + let reply: AdminReply = + freshell_runtime_protocol::read_frame_with_limit(&mut stream, reply_limit) + .await + .map_err(|error| ClientError::Protocol(error.to_string()))?; reply.result.map_err(runtime_error) } } @@ -988,9 +1069,317 @@ fn runtime_error(error: RuntimeError) -> ClientError { #[cfg(test)] mod tests { use super::*; - use freshell_runtime_protocol::{write_frame, AdminReply, AdminResult, InstallationId}; + use freshell_runtime_protocol::{ + read_frame, write_frame, AdminReply, AdminResult, InstallationId, + }; use tokio::net::UnixListener; + #[derive(Clone, Copy, Debug)] + enum FreshAgentReadKind { + Snapshot, + History, + } + + impl FreshAgentReadKind { + async fn read( + self, + client: &RuntimeClient, + soul: SoulId, + ) -> Result { + match self { + Self::Snapshot => client.fresh_agent_snapshot(soul).await, + Self::History => client.fresh_agent_history(soul).await, + } + } + + fn reply(self, value: serde_json::Value) -> AdminResult { + match self { + Self::Snapshot => AdminResult::FreshAgentSnapshot(value), + Self::History => AdminResult::FreshAgentHistory(value), + } + } + + fn assert_request(self, envelope: &Envelope, epoch: u64) { + let (soul, actual_epoch) = match (&envelope.body, self) { + (AdminCommand::FreshAgentReadSnapshot(read), Self::Snapshot) => { + (&read.soul_id, read.expected_control_epoch) + } + (AdminCommand::FreshAgentReadHistory(read), Self::History) => { + (&read.soul_id, read.expected_control_epoch) + } + _ => panic!("expected the original exact read operation: {envelope:?}"), + }; + assert_eq!(soul, &SoulId::parse("owned-read-soul").unwrap()); + assert_eq!(actual_epoch, Some(epoch)); + } + } + + fn epoch_health() -> AdminResult { + AdminResult::Health { + control_epoch: 2, + installation_id: InstallationId::new(), + } + } + + fn source_snapshot(kind: FreshAgentReadKind) -> serde_json::Value { + let live = matches!(kind, FreshAgentReadKind::Snapshot); + serde_json::json!({ + "threadId":"original-native", "provider":"codex", "sessionType":"freshcodex", + "turns":[{"text":"saved source".repeat(200_000)}], + "capabilities":{"send":live,"interrupt":live}, + "extensions":{"codex":{"nativeHistoryAvailable":true, + "ownerKind":if live {"live"} else {"vacant"}, "statusFromLiveState":live}} + }) + } + + async fn scripted_fresh_agent_read( + kind: FreshAgentReadKind, + replies: Vec>, + ) -> ( + Result, + Vec>, + Option, + ) { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (stop, mut stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let mut replies = replies.into_iter(); + let mut observed = Vec::new(); + loop { + let (mut stream, _) = tokio::select! { + _ = &mut stopped => break, + accepted = listener.accept() => accepted.unwrap(), + }; + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let reply = AdminReply { + request_id: envelope.request_id.clone(), + result: replies + .next() + .expect("unexpected additional control request"), + }; + observed.push(envelope); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &reply, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + } + observed + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let result = kind + .read(&client, SoulId::parse("owned-read-soul").unwrap()) + .await; + let cached_epoch = *client.control_epoch.read().await; + // End and join the owned listener even when an unfixed read returns early. + let _ = stop.send(()); + let observed = server.await.unwrap(); + for envelope in &observed { + assert_eq!(envelope.protocol_version, CONTROL_PROTOCOL_VERSION); + assert_eq!(envelope.role, ControlRole::Web); + assert_eq!(envelope.auth.as_deref(), Some("0123456789abcdef")); + } + (result, observed, cached_epoch) + } + + fn assert_refreshed_read(kind: FreshAgentReadKind, requests: &[Envelope]) { + assert_eq!(requests.len(), 3); + kind.assert_request(&requests[0], 1); + assert!(matches!(requests[1].body, AdminCommand::Health)); + kind.assert_request(&requests[2], 2); + } + + fn assert_cached_read( + kind: FreshAgentReadKind, + requests: &[Envelope], + cached: Option, + ) { + assert_eq!(requests.len(), 1); + kind.assert_request(&requests[0], 1); + assert_eq!(cached, Some(1)); + } + + async fn assert_read_epoch_refresh_preserves_source(kind: FreshAgentReadKind) { + let expected = source_snapshot(kind); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![ + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old controller", + )), + Ok(epoch_health()), + Ok(kind.reply(expected.clone())), + ], + ) + .await; + assert_refreshed_read(kind, &requests); + assert_eq!(cached, Some(2)); + assert_eq!(actual.unwrap(), expected); + } + + #[tokio::test] + async fn fresh_agent_reads_snapshot_refresh_preserves_live_source_and_large_history() { + assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::Snapshot).await; + } + + #[tokio::test] + async fn fresh_agent_reads_history_refresh_preserves_read_only_source_and_large_history() { + assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::History).await; + } + + #[tokio::test] + async fn fresh_agent_reads_stop_after_second_stale_epoch() { + for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![ + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old controller", + )), + Ok(epoch_health()), + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "changed again", + )), + ], + ) + .await; + assert_refreshed_read(kind, &requests); + assert_eq!(cached, Some(2)); + assert!( + matches!(actual, Err(ClientError::Runtime(RuntimeErrorCode::StaleControlEpoch, message)) if message == "changed again") + ); + } + } + + #[tokio::test] + async fn fresh_agent_reads_do_not_retry_other_runtime_errors() { + for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for code in [ + RuntimeErrorCode::HostUnreachable, + RuntimeErrorCode::OwnershipMismatch, + ] { + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![Err(RuntimeError::new(code, "original refusal"))], + ) + .await; + assert_cached_read(kind, &requests, cached); + assert!( + matches!(actual, Err(ClientError::Runtime(actual_code, message)) if actual_code == code && message == "original refusal") + ); + } + } + } + + #[tokio::test] + async fn fresh_agent_reads_propagate_health_error_without_retrying_the_read() { + for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![ + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old controller", + )), + Err(RuntimeError::new( + RuntimeErrorCode::UnauthorizedRole, + "health refused", + )), + ], + ) + .await; + assert_eq!(requests.len(), 2); + kind.assert_request(&requests[0], 1); + assert!(matches!(requests[1].body, AdminCommand::Health)); + assert_eq!(cached, Some(1)); + assert!( + matches!(actual, Err(ClientError::Runtime(RuntimeErrorCode::UnauthorizedRole, message)) if message == "health refused") + ); + } + } + + #[tokio::test] + async fn fresh_agent_reads_reject_unexpected_initial_health_and_retry_results() { + for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for stage in 0..3 { + let mut replies = Vec::new(); + if stage > 0 { + replies.push(Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old controller", + ))); + } + if stage > 1 { + replies.push(Ok(epoch_health())); + } + replies.push(Ok(AdminResult::Inventory(Vec::new()))); + let (actual, requests, cached) = scripted_fresh_agent_read(kind, replies).await; + assert_eq!(requests.len(), stage + 1); + kind.assert_request(&requests[0], 1); + if stage > 0 { + assert!(matches!(requests[1].body, AdminCommand::Health)); + } + if stage > 1 { + kind.assert_request(&requests[2], 2); + } + assert_eq!(cached, Some(if stage > 1 { 2 } else { 1 })); + assert!(matches!(actual, Err(ClientError::UnexpectedResult))); + } + } + } + + #[tokio::test] + async fn fresh_agent_reads_keep_healthy_live_and_history_provenance_without_refresh() { + for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + let expected = source_snapshot(kind); + let (actual, requests, cached) = + scripted_fresh_agent_read(kind, vec![Ok(kind.reply(expected.clone()))]).await; + assert_cached_read(kind, &requests, cached); + assert_eq!(actual.unwrap(), expected); + } + } + + #[tokio::test] + async fn snapshot_reply_preserves_large_history_over_the_control_socket() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("control.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let expected = serde_json::json!({"threadId":"native-large", "turns":[{"text":"x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}); + let sent = expected.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + assert!(matches!( + envelope.body, + AdminCommand::FreshAgentReadSnapshot(_) + )); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(AdminResult::FreshAgentSnapshot(sent)), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let actual = client + .fresh_agent_snapshot(SoulId::parse("large-soul").unwrap()) + .await; + let _ = server.await.unwrap(); + assert_eq!(actual.unwrap(), expected); + } + #[tokio::test] async fn health_authenticates_and_caches_epoch() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 9cf498b06..445c5019e 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -12,6 +12,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; pub const CONTROL_PROTOCOL_VERSION: u32 = 1; pub const MAX_CONTROL_FRAME_BYTES: usize = 1024 * 1024; +pub const MAX_NATIVE_HISTORY_FRAME_BYTES: usize = 32 * 1024 * 1024; type HmacSha256 = Hmac; @@ -2302,6 +2303,22 @@ pub struct FreshAgentReadEventsRequest { pub expected_control_epoch: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentReadHistoryRequest { + pub soul_id: SoulId, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentReadSnapshotRequest { + pub soul_id: SoulId, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalResizeRequest { @@ -2591,6 +2608,8 @@ pub enum AdminCommand { FreshAgentResolve(FreshAgentResolveRequest), FreshAgentInterrupt(FreshAgentInterruptRequest), FreshAgentReadEvents(FreshAgentReadEventsRequest), + FreshAgentReadHistory(FreshAgentReadHistoryRequest), + FreshAgentReadSnapshot(FreshAgentReadSnapshotRequest), RuntimeMetrics(RuntimeMetricsRequest), ProbeRecovery(RecoveryProbeRequest), Recover(RecoverRequest), @@ -2629,6 +2648,9 @@ pub struct RuntimeView { pub terminal_resume_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub fresh_agent_session_id: Option, + /// Original Fresh create request, persisted as the soul creation seed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fresh_agent_create_request_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub fresh_agent_session_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2741,6 +2763,8 @@ pub enum AdminResult { FreshAgentCapture(FreshAgentCapture), FreshAgentInterrupted, FreshAgentEvents(AgentEventBatch), + FreshAgentHistory(serde_json::Value), + FreshAgentSnapshot(serde_json::Value), RuntimeMetrics(RuntimeMetrics), RecoveryProbe(RecoveryProbe), Recovery(RecoveryResult), @@ -2840,6 +2864,9 @@ pub enum HostCommand { incarnation_id: IncarnationId, max_bytes: u32, }, + FreshAgentReadSnapshot { + incarnation_id: IncarnationId, + }, FreshAgentResolve { incarnation_id: IncarnationId, decision_id: String, @@ -2903,6 +2930,7 @@ pub enum HostResult { FreshAgentCapture(FreshAgentCapture), FreshAgentEvents(AgentEventBatch), RuntimeMetrics(RuntimeMetrics), + FreshAgentSnapshot(serde_json::Value), Status { host_boot_id: HostBootId, worker_pid: Option, @@ -2953,7 +2981,7 @@ fn hex_lower(bytes: &[u8]) -> String { #[derive(Debug, thiserror::Error)] pub enum FrameError { - #[error("control frame exceeds {MAX_CONTROL_FRAME_BYTES} bytes")] + #[error("control frame exceeds its byte budget")] TooLarge, #[error("control frame I/O failed: {0}")] Io(#[from] std::io::Error), @@ -2962,12 +2990,24 @@ pub enum FrameError { } pub async fn write_frame(writer: &mut W, value: &T) -> Result<(), FrameError> +where + W: AsyncWrite + Unpin, + T: Serialize, +{ + write_frame_with_limit(writer, value, MAX_CONTROL_FRAME_BYTES).await +} + +pub async fn write_frame_with_limit( + writer: &mut W, + value: &T, + limit: usize, +) -> Result<(), FrameError> where W: AsyncWrite + Unpin, T: Serialize, { let bytes = serde_json::to_vec(value)?; - if bytes.len() > MAX_CONTROL_FRAME_BYTES { + if bytes.len() > limit { return Err(FrameError::TooLarge); } writer @@ -2979,6 +3019,14 @@ where } pub async fn read_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, + T: DeserializeOwned, +{ + read_frame_with_limit(reader, MAX_CONTROL_FRAME_BYTES).await +} + +pub async fn read_frame_with_limit(reader: &mut R, limit: usize) -> Result where R: AsyncRead + Unpin, T: DeserializeOwned, @@ -2986,7 +3034,7 @@ where let mut len = [0u8; 4]; reader.read_exact(&mut len).await?; let len = u32::from_be_bytes(len) as usize; - if len > MAX_CONTROL_FRAME_BYTES { + if len > limit { return Err(FrameError::TooLarge); } let mut bytes = vec![0; len]; diff --git a/crates/freshell-server/Cargo.toml b/crates/freshell-server/Cargo.toml index 41f46e241..5dfc48388 100644 --- a/crates/freshell-server/Cargo.toml +++ b/crates/freshell-server/Cargo.toml @@ -138,6 +138,9 @@ regex = "1" # lock's own unix-only implementation. libc = "0.2" +[target.'cfg(unix)'.dev-dependencies] +freshell-supervisor = { path = "../freshell-supervisor" } + [dev-dependencies] # `ServiceExt::oneshot` for in-process axum router handler tests (`sessions.rs`). tower = { version = "0.5", features = ["util"] } diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index e8460bd55..25c281b2d 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -50,6 +50,17 @@ pub(crate) struct HostedFreshAgentProxy { naming: OnceLock>, } +struct RollbackInvocation { + provider: AgentProvider, + session_id: String, + session_type: SessionType, + request_id: String, + direction: FreshAgentRollbackDirection, + mode: Option, + turn_id: Option, + cwd: Option, +} + impl HostedFreshAgentProxy { pub(crate) fn from_opt_in( client: Option, @@ -125,7 +136,7 @@ impl HostedFreshAgentProxy { } } HostedFreshAgentCommand::Send(message) => { - let provider = message.provider.clone(); + let provider = message.provider; let session_id = message.session_id.clone(); let session_type = message.session_type; let request_id = message @@ -258,7 +269,7 @@ impl HostedFreshAgentProxy { } } HostedFreshAgentCommand::Fork(message) => { - let provider = message.provider.clone(); + let provider = message.provider; let session_id = message.session_id.clone(); let session_type = message.session_type; let request_id = message @@ -322,29 +333,29 @@ impl HostedFreshAgentProxy { } } HostedFreshAgentCommand::Undo(message) => { - self.rollback( - message.provider, - message.session_id, - message.session_type, - message.request_id, - FreshAgentRollbackDirection::Undo, - message.mode, - message.turn_id, - message.cwd, - ) + self.rollback(RollbackInvocation { + provider: message.provider, + session_id: message.session_id, + session_type: message.session_type, + request_id: message.request_id, + direction: FreshAgentRollbackDirection::Undo, + mode: message.mode, + turn_id: message.turn_id, + cwd: message.cwd, + }) .await; } HostedFreshAgentCommand::Redo(message) => { - self.rollback( - message.provider, - message.session_id, - message.session_type, - message.request_id, - FreshAgentRollbackDirection::Redo, - message.mode, - message.turn_id, - message.cwd, - ) + self.rollback(RollbackInvocation { + provider: message.provider, + session_id: message.session_id, + session_type: message.session_type, + request_id: message.request_id, + direction: FreshAgentRollbackDirection::Redo, + mode: message.mode, + turn_id: message.turn_id, + cwd: message.cwd, + }) .await; } } @@ -633,17 +644,17 @@ impl HostedFreshAgentProxy { } } - async fn rollback( - &self, - provider: AgentProvider, - session_id: String, - session_type: SessionType, - request_id: String, - direction: FreshAgentRollbackDirection, - mode: Option, - turn_id: Option, - cwd: Option, - ) { + async fn rollback(&self, invocation: RollbackInvocation) { + let RollbackInvocation { + provider, + session_id, + session_type, + request_id, + direction, + mode, + turn_id, + cwd, + } = invocation; let parsed_request_id = RequestId::parse(request_id); let result = match ( parsed_request_id, @@ -689,7 +700,7 @@ impl HostedFreshAgentProxy { session_id: &str, ) -> Option { let public_provider = provider_wire(provider); - let runtime_provider = fresh_provider(&Some(provider.clone()), session_type)?; + let runtime_provider = fresh_provider(&Some(*provider), session_type)?; if let Some(soul) = self .aliases .lock() @@ -1018,6 +1029,7 @@ fn fresh_provider_wire(provider: &FreshProvider) -> &'static str { } } +#[cfg(any(test, feature = "managed-fresh-agent-fixtures"))] fn parse_fixture_modes(raw: &str) -> Result, String> { const MODES: [&str; 4] = ["freshclaude", "kilroy", "freshcodex", "freshopencode"]; if raw.is_empty() { diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index eb832740a..1495dcc3f 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -4,13 +4,130 @@ use super::*; #[async_trait::async_trait] impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { + async fn snapshot( + &self, + request: freshell_freshagent::hosted_rest::HostedRestSnapshot, + ) -> Result, freshell_freshagent::hosted_rest::HostedRestSnapshotError> + { + use freshell_freshagent::hosted_rest::HostedRestSnapshotError::{ + ManagedUnavailable, OwnershipUnavailable, + }; + let (provider, session_type) = + rest_agent_identity(&request.provider, &request.session_type) + .map_err(|_| OwnershipUnavailable)?; + let runtime_provider = + fresh_provider(&Some(provider), session_type).ok_or(OwnershipUnavailable)?; + let alias_key = (request.provider.clone(), request.session_id.clone()); + let inventory = match self.client.inventory().await { + Ok(inventory) => inventory, + Err(_) => { + return Err(if self.aliases.lock().await.contains_key(&alias_key) { + ManagedUnavailable + } else { + OwnershipUnavailable + }) + } + }; + let view = inventory.iter().rev().find(|view| { + view.provider.as_deref() == Some(runtime_provider.as_str()) + && (view.fresh_agent_session_id.as_deref() == Some(&request.session_id) + || view.native_session_id.as_deref() == Some(&request.session_id)) + }); + let Some(view) = view else { + return Ok(None); + }; + self.aliases + .lock() + .await + .insert(alias_key, view.soul_id.clone()); + let (mut snapshot, history_only) = match self + .client + .fresh_agent_snapshot(view.soul_id.clone()) + .await + { + Ok(snapshot) => (snapshot, false), + Err(error) => { + tracing::warn!(soul_id = %view.soul_id, code = ?error.runtime_code(), "fresh_agent.hosted_snapshot_unavailable"); + ( + self.client + .fresh_agent_history(view.soul_id.clone()) + .await + .map_err(|_| ManagedUnavailable)?, + true, + ) + } + }; + // A reply cannot cross a native identity, owner incarnation or source change. + let current = self + .client + .inventory() + .await + .map_err(|_| ManagedUnavailable)?; + let latest = current + .iter() + .rev() + .find(|row| row.soul_id == view.soul_id) + .ok_or(ManagedUnavailable)?; + if !same_snapshot_source(view, latest) + || snapshot["sessionType"].as_str() != Some(request.session_type.as_str()) + || snapshot["provider"].as_str() != Some(request.provider.as_str()) + { + return Err(ManagedUnavailable); + } + if let Some(native) = view.native_session_id.as_deref() { + if snapshot["threadId"].as_str() != Some(native) { + return Err(ManagedUnavailable); + } + } else { + // The actor has proven its exact local registration. Expose the existing + // gateway alias only while OpenCode still has an empty, live zero-turn session. + if history_only + || runtime_provider != FreshProvider::Opencode + || snapshot["turns"] + .as_array() + .is_none_or(|turns| !turns.is_empty()) + || snapshot["extensions"]["opencode"]["statusFromLiveState"] != true + || snapshot["threadId"].as_str().is_none_or(str::is_empty) + { + return Err(ManagedUnavailable); + } + let public = view + .fresh_agent_session_id + .as_deref() + .ok_or(ManagedUnavailable)?; + snapshot["threadId"] = serde_json::json!(public); + snapshot["sessionId"] = serde_json::json!(public); + } + if history_only { + // Owned native history is display-only, regardless of adapter defaults. + snapshot["status"] = serde_json::json!("idle"); + if let Some(capabilities) = snapshot["capabilities"].as_object_mut() { + for value in capabilities.values_mut() { + if value.is_boolean() { + *value = serde_json::json!(false); + } + } + } + snapshot["extensions"][&request.provider]["ownerKind"] = serde_json::json!("vacant"); + snapshot["extensions"][&request.provider]["nativeHistoryAvailable"] = + serde_json::json!(true); + snapshot["extensions"][&request.provider]["statusFromLiveState"] = + serde_json::json!(false); + } + freshell_agent_runtime::snapshot_projection::project_hosted_rest_snapshot( + &mut snapshot, + &request.provider, + &request.session_type, + ); + Ok(Some(snapshot)) + } async fn create_agent( self: Arc, request: HostedRestCreate, ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; - let runtime_provider = fresh_provider(&Some(provider.clone()), session_type).ok_or(())?; + let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { request_id: request.request_id, session_type, @@ -23,7 +140,7 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { observed_generation: None, permission_mode: request.permission_mode, plugins: request.plugins, - provider: Some(provider.clone()), + provider: Some(provider), resume_session_id: None, sandbox: request.sandbox, session_ref: request.native_session_id.map(|session_id| SessionLocator { @@ -103,6 +220,22 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { } } +fn same_snapshot_source( + before: &freshell_runtime_protocol::RuntimeView, + after: &freshell_runtime_protocol::RuntimeView, +) -> bool { + before.incarnation_id == after.incarnation_id + && before.native_session_id == after.native_session_id + && before.provider == after.provider + && before.intent_revision == after.intent_revision + && before.host_boot_id == after.host_boot_id + && before.execution_generation == after.execution_generation + && before.fresh_agent_session_id == after.fresh_agent_session_id + && before.fresh_agent_session_type == after.fresh_agent_session_type + && before.recovery_state == after.recovery_state + && before.desired_state == after.desired_state +} + async fn wait_for_completion( client: &RuntimeClient, soul: SoulId, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 391855dba..4eb2eb7ef 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -445,12 +445,14 @@ fn hosted_snapshot_capabilities_are_intersected_for_every_provider_and_envelope_ ("freshAgent.session.snapshot", true), ("freshAgent.snapshot", false), ("freshAgent.snapshot", true), + ("rest", false), ] { let snapshot = serde_json::json!({ "type": snapshot_type, "provider": provider, "sessionType": session_type, "sessionId": "native-session", + "threadId": "native-session", "capabilities": advertised_capabilities(), "rollback": { "canRedo": true, @@ -471,11 +473,20 @@ fn hosted_snapshot_capabilities_are_intersected_for_every_provider_and_envelope_ }; rewrite_presentation_id(&mut payload, "public-session"); - freshell_agent_runtime::snapshot_projection::project_hosted_snapshot( - &mut payload, - provider, - session_type, - ); + if snapshot_type == "rest" { + payload.as_object_mut().unwrap().remove("type"); + freshell_agent_runtime::snapshot_projection::project_hosted_rest_snapshot( + &mut payload, + provider, + session_type, + ); + } else { + freshell_agent_runtime::snapshot_projection::project_hosted_snapshot( + &mut payload, + provider, + session_type, + ); + } let projected = if nested { &payload["event"] } else { &payload }; let capabilities = &projected["capabilities"]; @@ -516,7 +527,15 @@ fn hosted_snapshot_capabilities_are_intersected_for_every_provider_and_envelope_ "redo targets must disappear when redo cannot dispatch" ); } - assert_eq!(projected["sessionId"], "public-session"); + assert_eq!(projected["threadId"], "native-session"); + assert_eq!( + projected["sessionId"], + if snapshot_type == "rest" { + "native-session" + } else { + "public-session" + } + ); } } } @@ -569,3 +588,743 @@ fn hosted_snapshot_unknown_capability_payloads_fail_closed_without_falsifying_kn assert!(projected["rollback"].get("redoableTurnIds").is_none()); } } + +#[tokio::test] +async fn rollback_commands_keep_direction_target_and_request_fences() { + for direction in [ + FreshAgentRollbackDirection::Undo, + FreshAgentRollbackDirection::Redo, + ] { + let root = tempfile::tempdir().unwrap(); + let socket = root.path().join("rollback.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let soul = SoulId::new(); + let expected_soul = soul.clone(); + let server = tokio::spawn(async move { + for index in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health if index == 0 => AdminResult::Health { + control_epoch: 77, + installation_id: InstallationId::new(), + }, + AdminCommand::FreshAgentRollback(rollback) if index == 1 => { + assert_eq!(request.request_id.as_str(), "rollback-ui-request"); + assert_eq!(rollback.soul_id, expected_soul); + assert_eq!(rollback.direction, direction); + assert_eq!(rollback.mode, FreshAgentRollbackMode::ToTurn); + assert_eq!(rollback.turn_id.as_deref(), Some("turn-selected")); + assert_eq!(rollback.cwd.as_deref(), Some("/workspace")); + assert_eq!(rollback.expected_control_epoch, Some(77)); + AdminResult::FreshAgentCommand { + state: freshell_runtime_protocol::CommandState::Completed, + } + } + other => panic!("unexpected rollback request {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let (broadcast, _) = broadcast::channel(16); + let proxy = Arc::new(HostedFreshAgentProxy { + client: RuntimeClient::new(&socket, "0123456789abcdef"), + broadcast: Arc::new(broadcast), + aliases: Mutex::new(HashMap::from([( + ("codex".into(), "public-thread".into()), + soul, + )])), + presentation_ids: Mutex::new(HashMap::new()), + pollers: Mutex::new(HashSet::new()), + fixture_modes: HashSet::new(), + naming: OnceLock::new(), + }); + let message = serde_json::json!({"provider":"codex","sessionId":"public-thread","sessionType":"freshcodex", + "requestId":"rollback-ui-request","mode":"toTurn","turnId":"turn-selected","cwd":"/workspace"}); + let command = match direction { + FreshAgentRollbackDirection::Undo => { + HostedFreshAgentCommand::Undo(serde_json::from_value(message).unwrap()) + } + FreshAgentRollbackDirection::Redo => { + HostedFreshAgentCommand::Redo(serde_json::from_value(message).unwrap()) + } + }; + proxy.handle(command).await; + server.await.unwrap(); + } +} + +static SNAPSHOT_OUTAGE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +struct SnapshotTestEnv(Vec<(&'static str, Option)>); +impl SnapshotTestEnv { + fn isolate(root: &Path) -> Self { + let keys = [ + "HOME", + "CODEX_HOME", + "CLAUDE_CONFIG_DIR", + "CLAUDE_HOME", + "XDG_DATA_HOME", + "CODEX_CMD", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let saved = Self( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var("HOME", root.join("empty-home")); + std::env::set_var("CODEX_HOME", root.join("configured-codex")); + std::env::set_var("CLAUDE_CONFIG_DIR", root.join("configured-claude")); + std::env::remove_var("CLAUDE_HOME"); + std::env::set_var("XDG_DATA_HOME", root.join("configured-xdg")); + std::env::set_var("CODEX_CMD", "/never-start-a-provider-for-saved-history"); + saved + } +} +impl Drop for SnapshotTestEnv { + fn drop(&mut self) { + for (key, value) in &self.0 { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} + +fn snapshot_outage_proxy(socket: &Path) -> Arc { + Arc::new(HostedFreshAgentProxy { + client: RuntimeClient::new(socket, "0123456789abcdef"), + broadcast: Arc::new(broadcast::channel(16).0), + aliases: Mutex::new(HashMap::new()), + presentation_ids: Mutex::new(HashMap::new()), + pollers: Mutex::new(HashSet::new()), + fixture_modes: HashSet::new(), + naming: OnceLock::new(), + }) +} + +async fn snapshot_route_value( + app: &axum::Router, + session_type: &str, + provider: &str, + native: &str, +) -> (axum::http::StatusCode, serde_json::Value) { + use tower::ServiceExt; + let reply = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri(format!( + "/api/fresh-agent/threads/{session_type}/{provider}/{native}" + )) + .header("x-auth-token", "tok") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = reply.status(); + let bytes = axum::body::to_bytes(reply.into_body(), 32 * 1024 * 1024) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +#[tokio::test] +async fn real_gateway_pre_native_opencode_projects_only_current_empty_owned_registration() { + use freshell_freshagent::hosted_rest::HostedRestSnapshot; + for scenario in [ + "empty", + "nonempty", + "wrong-provider", + "materialized", + "owner-changed", + ] { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("owned.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let mut server = tokio::spawn(async move { + let mut inventories = 0; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + inventories += 1; + AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"owned-soul", "incarnationId":if scenario == "owner-changed" && inventories == 2 {"new-owner"} else {"owned-owner"}, + "launchState":"running", "cleanupState":"none", "intentRevision":1, "executionGeneration":1, + "desiredState":"running", "recoveryState":"live", "durabilityState":"unknown", "allocationState":"allocated", + "evidenceRevision":0, "successfulRecoveriesInWindow":0, "provider":"opencode", "freshAgentSessionId":"managed-opencode-public", + "nativeSessionId":if scenario == "materialized" && inventories == 2 {Some("ses_new")} else {None} + })).unwrap()]) + } + AdminCommand::FreshAgentReadSnapshot(read) => { + assert_eq!(read.soul_id.as_str(), "owned-soul"); + AdminResult::FreshAgentSnapshot( + serde_json::json!({"threadId":"freshopencode-host-registered", "provider":if scenario == "wrong-provider" {"codex"} else {"opencode"}, + "sessionType":"freshopencode", "status":"idle", "turns":if scenario == "nonempty" {serde_json::json!([{"turnId":"native"}])} else {serde_json::json!([])}, + "capabilities":{"send":true,"interrupt":false,"approvals":false,"questions":false,"fork":false}, "extensions":{"opencode":{"statusFromLiveState":true}}}), + ) + } + other => panic!("unexpected zero-turn read {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + if inventories == 2 { + break; + } + } + }); + let proxy = snapshot_outage_proxy(&socket); + let result = proxy + .snapshot(HostedRestSnapshot { + session_id: "managed-opencode-public".into(), + provider: "opencode".into(), + session_type: "freshopencode".into(), + }) + .await; + let joined = tokio::time::timeout(Duration::from_secs(1), &mut server).await; + if joined.is_err() { + server.abort(); + let _ = server.await; + } + joined + .expect("owned socket fixture must finish") + .expect("owned socket fixture protocol must succeed"); + assert_eq!(result.is_ok(), scenario == "empty", "{scenario}"); + if let Ok(Some(snapshot)) = result { + assert_eq!(snapshot["threadId"], "managed-opencode-public"); + assert_eq!(snapshot["capabilities"]["send"], true); + } + } +} + +#[tokio::test] +async fn real_gateway_managed_snapshot_failure_reads_owned_history_and_refuses_wrong_local_store() { + let _lock = SNAPSHOT_OUTAGE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let _env = SnapshotTestEnv::isolate(dir.path()); + let local_path = dir + .path() + .join("configured-codex/sessions/rollout-session-activity.jsonl"); + std::fs::create_dir_all(local_path.parent().unwrap()).unwrap(); + std::fs::write( + &local_path, + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl"), + ) + .unwrap(); + let local_opencode_path = dir.path().join("configured-xdg/opencode/opencode.db"); + std::fs::create_dir_all(local_opencode_path.parent().unwrap()).unwrap(); + let db = rusqlite::Connection::open(&local_opencode_path).unwrap(); + db.execute_batch("CREATE TABLE session(id TEXT PRIMARY KEY,title TEXT,time_updated INTEGER); CREATE TABLE message(id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,data TEXT); CREATE TABLE part(id TEXT PRIMARY KEY,session_id TEXT,message_id TEXT,time_created INTEGER,data TEXT); INSERT INTO session VALUES('ses_owned','Wrong local',2);").unwrap(); + db.execute("INSERT INTO message VALUES('wrong-message','ses_owned',1,?1)", [serde_json::json!({"id":"wrong-message","role":"assistant","time":{"created":1,"completed":2}}).to_string()]).unwrap(); + db.execute("INSERT INTO part VALUES('wrong-part','ses_owned','wrong-message',1,?1)", [serde_json::json!({"id":"wrong-part","type":"text","text":"Wrong local OpenCode source"}).to_string()]).unwrap(); + drop(db); + let local_before = std::fs::read(&local_path).unwrap(); + let opencode_before = std::fs::read(&local_opencode_path).unwrap(); + for (provider, session_type, native, history_available) in [ + ("codex", "freshcodex", "session-activity", true), + ("codex", "freshcodex", "session-activity", false), + ("opencode", "freshopencode", "ses_owned", true), + ("opencode", "freshopencode", "ses_owned", false), + ] { + let socket = dir + .path() + .join(format!("owned-{provider}-{history_available}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let mut server = tokio::spawn(async move { + let mut read_history = false; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let mut done = false; + let result = match request.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }), + AdminCommand::Inventory => { + done = read_history; + Ok(AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"owned-soul", "incarnationId":"owned-incarnation", "launchState":"running", "cleanupState":"none", + "intentRevision":1, "executionGeneration":1, "desiredState":"running", "recoveryState":"live", + "durabilityState":"unknown", "allocationState":"allocated", "evidenceRevision":0, "successfulRecoveriesInWindow":0, + "provider":provider, "nativeSessionId":native, "freshAgentSessionId":"managed-public", + "freshAgentSessionType":session_type + })).unwrap()])) + } + AdminCommand::FreshAgentReadSnapshot(read) => { + assert_eq!(read.soul_id.as_str(), "owned-soul"); + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::HostUnreachable, + "hosted fresh-agent command failed", + )) + } + AdminCommand::FreshAgentReadHistory(read) => { + assert_eq!(read.soul_id.as_str(), "owned-soul"); + read_history = true; + done = !history_available; + if history_available { + Ok(AdminResult::FreshAgentHistory(serde_json::json!({ + "threadId":native, "provider":provider, "sessionType":session_type, "status":"idle", + "turns":[{"turnId":"owned-volume-answer"}], "capabilities":{"send":false}, + "extensions":{(provider):{"ownerKind":"vacant", "nativeHistoryAvailable":true}} + }))) + } else { + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::HostUnreachable, + "owned history unavailable", + )) + } + } + other => panic!("unexpected read {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result, + }, + ) + .await + .unwrap(); + if done { + break; + } + } + }); + let (broadcast, _) = broadcast::channel(16); + let broadcast = Arc::new(broadcast); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast.clone()); + owner + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); + let app = freshell_freshagent::snapshot::router( + freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({}), + ), + owner, + freshell_freshagent::FreshClaudeState::new(broadcast), + ), + ); + let (status, value) = snapshot_route_value(&app, session_type, provider, native).await; + // Join only the owned fixture; old behavior never requests history. + let joined = tokio::time::timeout(Duration::from_millis(500), &mut server).await; + if joined.is_err() { + server.abort(); + let _ = server.await; + } + joined + .expect("gateway must request history from the same owned soul") + .expect("owned history fixture protocol must succeed"); + assert_eq!( + status, + if history_available { + axum::http::StatusCode::OK + } else { + axum::http::StatusCode::SERVICE_UNAVAILABLE + } + ); + if history_available { + assert_eq!(value["turns"][0]["turnId"], "owned-volume-answer"); + assert_eq!(value["threadId"], native); + assert_eq!(value["provider"], provider); + assert_eq!(value["sessionType"], session_type); + assert_eq!(value["extensions"][provider]["ownerKind"], "vacant"); + assert_eq!( + value["extensions"][provider]["nativeHistoryAvailable"], + true + ); + assert_eq!(value["extensions"][provider]["statusFromLiveState"], false); + assert_eq!(value["capabilities"]["send"], false); + assert!(!value.to_string().contains("Wrong local OpenCode source")); + } + } + assert_eq!(std::fs::read(&local_path).unwrap(), local_before); + assert_eq!( + std::fs::read(&local_opencode_path).unwrap(), + opencode_before + ); + let proxy = snapshot_outage_proxy(&dir.path().join("absent-supervisor.sock")); + proxy.aliases.lock().await.insert( + ("codex".into(), "session-activity".into()), + SoulId::parse("owned-soul").unwrap(), + ); + let (broadcast, _) = broadcast::channel(16); + let broadcast = Arc::new(broadcast); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast.clone()); + owner.set_hosted_rest_gateway(proxy).unwrap(); + let app = + freshell_freshagent::snapshot::router(freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({}), + ), + owner, + freshell_freshagent::FreshClaudeState::new(broadcast), + )); + assert_eq!( + snapshot_route_value(&app, "freshcodex", "codex", "session-activity") + .await + .0, + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); +} + +#[tokio::test] +async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_authority() { + let _lock = SNAPSHOT_OUTAGE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let _env = SnapshotTestEnv::isolate(dir.path()); + let socket = dir.path().join("owned-supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + drop(listener); + std::fs::remove_file(&socket).unwrap(); // Only this fixture's socket becomes unavailable. + let codex_path = dir + .path() + .join("configured-codex/sessions/rollout-session-activity.jsonl"); + let claude_path = dir + .path() + .join("configured-claude/projects/fixture/44444444-4444-4444-8444-444444444444.jsonl"); + let db_path = dir.path().join("configured-xdg/opencode/opencode.db"); + for path in [&codex_path, &claude_path, &db_path] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + } + std::fs::write( + &codex_path, + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl"), + ) + .unwrap(); + std::fs::write( + &claude_path, + include_str!("../../../test/fixtures/managed-native-history/claude.jsonl"), + ) + .unwrap(); + let db = rusqlite::Connection::open(&db_path).unwrap(); + db.execute_batch("CREATE TABLE session(id TEXT PRIMARY KEY,title TEXT,time_updated INTEGER); CREATE TABLE message(id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,data TEXT); CREATE TABLE part(id TEXT PRIMARY KEY,session_id TEXT,message_id TEXT,time_created INTEGER,data TEXT); INSERT INTO session VALUES('ses_saved','Saved',2);").unwrap(); + db.execute("INSERT INTO message VALUES('message-one','ses_saved',1,?1)", [serde_json::json!({"id":"message-one","role":"assistant","time":{"created":1,"completed":2}}).to_string()]).unwrap(); + db.execute("INSERT INTO part VALUES('part-one','ses_saved','message-one',1,?1)", [serde_json::json!({"id":"part-one","type":"text","text":"Saved native OpenCode answer"}).to_string()]).unwrap(); + drop(db); + let paths = [&codex_path, &claude_path, &db_path]; + let before: Vec<_> = paths + .iter() + .map(|path| { + ( + std::fs::read(path).unwrap(), + std::fs::metadata(path).unwrap().modified().unwrap(), + ) + }) + .collect(); + let broadcast = Arc::new(broadcast::channel(64).0); + let codex = freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({}), + ); + let claude = freshell_freshagent::FreshClaudeState::new(broadcast.clone()); + let opencode = freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast); + let proxy = snapshot_outage_proxy(&socket); + opencode.set_hosted_rest_gateway(proxy.clone()).unwrap(); + let app = + freshell_freshagent::snapshot::router(freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + codex.clone(), + opencode, + claude.clone(), + )); + for (kind, provider, native, text) in [ + ( + "freshcodex", + "codex", + "session-activity", + "Sanitized completion", + ), + ( + "freshclaude", + "claude", + "44444444-4444-4444-8444-444444444444", + "Saved native Claude answer", + ), + ( + "kilroy", + "claude", + "44444444-4444-4444-8444-444444444444", + "Saved native Claude answer", + ), + ( + "freshopencode", + "opencode", + "ses_saved", + "Saved native OpenCode answer", + ), + ] { + let (status, value) = snapshot_route_value(&app, kind, provider, native).await; + assert_eq!(status, axum::http::StatusCode::OK, "{kind}: {value}"); + assert_eq!(value["threadId"], native); + assert_eq!(value["sessionType"], kind); + assert_eq!(value["provider"], provider); + assert!( + value["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .any(|item| item["text"] == text), + "{kind}: {value}" + ); + assert_eq!( + value["extensions"][provider]["nativeHistoryAvailable"], + true + ); + assert_ne!(value["extensions"][provider]["statusFromLiveState"], true); + assert!(value["capabilities"] + .as_object() + .unwrap() + .values() + .filter(|v| v.is_boolean()) + .all(|v| v == false)); + } + for (kind, provider, id) in [ + ("freshcodex", "codex", "managed-freshcodex-unresolved"), + ("freshcodex", "codex", "foreign-session"), + ("freshclaude", "claude", "missing-native"), + ("freshopencode", "opencode", "ses_missing"), + ] { + assert_eq!( + snapshot_route_value(&app, kind, provider, id).await.0, + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + } + // Exact-looking names cannot substitute for a readable, matching native source. + let wrong_codex = codex_path.with_file_name("rollout-wrong-native.jsonl"); + std::fs::write(&wrong_codex, std::fs::read(&codex_path).unwrap()).unwrap(); + let unreadable_claude = + claude_path.with_file_name("55555555-5555-4555-8555-555555555555.jsonl"); + std::fs::write(&unreadable_claude, [0xff, 0xfe]).unwrap(); + for (kind, provider, id) in [ + ("freshcodex", "codex", "wrong-native"), + ( + "freshclaude", + "claude", + "55555555-5555-4555-8555-555555555555", + ), + ] { + assert_eq!( + snapshot_route_value(&app, kind, provider, id).await.0, + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + } + for (path, (bytes, modified)) in paths.iter().zip(before) { + assert_eq!(std::fs::read(path).unwrap(), bytes); + assert_eq!( + std::fs::metadata(path).unwrap().modified().unwrap(), + modified + ); + } + std::fs::write(&db_path, b"not a SQLite database").unwrap(); + assert_eq!( + snapshot_route_value(&app, "freshopencode", "opencode", "ses_saved") + .await + .0, + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(std::fs::read(&db_path).unwrap(), b"not a SQLite database"); + assert!(!codex.has_live_session("session-activity").await); + assert!( + !claude + .has_live_session("44444444-4444-4444-8444-444444444444") + .await + ); + assert!(proxy.pollers.lock().await.is_empty()); +} + +#[tokio::test] +async fn real_gateway_outage_keeps_a_registered_local_provider_snapshot_live() { + let _lock = SNAPSHOT_OUTAGE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let _env = SnapshotTestEnv::isolate(dir.path()); + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + r#"{"threadStartThreadId":"local-owned-thread"}"#, + ); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + let (tx, mut rx) = broadcast::channel(64); + let broadcast = Arc::new(tx); + let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let mut codex = freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({"freshAgent":{"enabled":true}}), + ); + codex.set_ownership(ownership.clone()); + codex.handle_create(serde_json::from_value(serde_json::json!({"requestId":"local-provider-start","sessionType":"freshcodex","provider":"codex"})).unwrap(), None).await; + let created: serde_json::Value = tokio::time::timeout(Duration::from_secs(20), async { + loop { + let frame: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.created" || frame["type"] == "freshAgent.create.failed" + { + break frame; + } + } + }) + .await + .unwrap(); + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let native = created["sessionId"].as_str().unwrap(); + let saved_path = dir + .path() + .join("configured-codex/sessions") + .join(format!("rollout-{native}.jsonl")); + std::fs::create_dir_all(saved_path.parent().unwrap()).unwrap(); + let saved = include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl") + .replace("session-activity", native); + std::fs::write(&saved_path, &saved).unwrap(); + let before = ownership.observe("codex", native); + let socket = dir.path().join("owned-supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + drop(listener); + std::fs::remove_file(&socket).unwrap(); + let opencode = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast.clone()); + opencode + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); + let app = + freshell_freshagent::snapshot::router(freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + codex.clone(), + opencode, + freshell_freshagent::FreshClaudeState::new(broadcast), + )); + let (status, value) = snapshot_route_value(&app, "freshcodex", "codex", native).await; + let after = ownership.observe("codex", native); + // Park the real read after its local runtime capture, then change ownership. + let reached = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + codex.set_snapshot_pause_after_capture_for_tests(Arc::new({ + let reached = reached.clone(); + let release = release.clone(); + move |_| { + let reached = reached.clone(); + let release = release.clone(); + Box::pin(async move { + reached.notify_one(); + release.notified().await; + }) + } + })); + let held = tokio::spawn({ + let app = app.clone(); + let native = native.to_owned(); + async move { snapshot_route_value(&app, "freshcodex", "codex", &native).await } + }); + tokio::time::timeout(Duration::from_secs(10), reached.notified()) + .await + .unwrap(); + let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_handoff( + "codex", + native, + freshell_ownership::RuntimeOwnerKind::Terminal, + "snapshot-outage-handoff", + None, + "test", + freshell_ownership::now_epoch_ms(), + ) else { + panic!("expected owned handoff") + }; + release.notify_one(); + let (held_status, held_value) = tokio::time::timeout(Duration::from_secs(10), held) + .await + .unwrap() + .unwrap(); + codex.clear_snapshot_pause_after_capture_for_tests(); + // The captured runtime is still registered but its retained generation is stale. + ownership.fail( + "codex", + native, + "snapshot-outage-handoff", + generation, + false, + ); + let (stale_status, stale_value) = + snapshot_route_value(&app, "freshcodex", "codex", native).await; + // Always join teardown of the one provider this fixture created, including RED. + codex.handle_kill(serde_json::from_value(serde_json::json!({"sessionId":native,"sessionType":"freshcodex","provider":"codex"})).unwrap()).await; + assert!(!codex.has_live_session(native).await); + assert_eq!(std::fs::read_to_string(&saved_path).unwrap(), saved); + assert_eq!(status, axum::http::StatusCode::OK, "{value}"); + assert_eq!(before, after); + assert_eq!(value["threadId"], native); + assert_eq!(value["capabilities"]["send"], true); + assert_ne!(value["extensions"]["codex"]["nativeHistoryAvailable"], true); + assert!( + value["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .any(|item| item["text"] == "Fixture turn"), + "{value}" + ); + // A provider's typed handoff refusal stays authoritative even when saved history exists. + assert_eq!( + held_status, + axum::http::StatusCode::CONFLICT, + "{held_value}" + ); + assert_eq!(held_value["code"], "RESTORE_UNAVAILABLE"); + assert_eq!(held_value["ownerGeneration"], generation); + assert!(held_value.get("ownerKind").is_none()); + assert!(held_value.get("turns").is_none()); + assert!(held_value.get("capabilities").is_none()); + // An unconfirmed runtime without a provider refusal still reads only saved history. + assert_eq!(stale_status, axum::http::StatusCode::OK, "{stale_value}"); + assert_eq!(stale_value["threadId"], native); + assert_eq!( + stale_value["extensions"]["codex"]["nativeHistoryAvailable"], + true + ); + assert_ne!( + stale_value["extensions"]["codex"]["statusFromLiveState"], + true + ); + assert_eq!(stale_value["capabilities"]["send"], false); +} diff --git a/crates/freshell-server/src/managed_mcp_capability.rs b/crates/freshell-server/src/managed_mcp_capability.rs index d5d993c79..dcd4e937a 100644 --- a/crates/freshell-server/src/managed_mcp_capability.rs +++ b/crates/freshell-server/src/managed_mcp_capability.rs @@ -703,11 +703,9 @@ fn stage_provider_root( let ephemeral = temporary.join("ephemeral"); private_directory(&ephemeral)?; if let Some(prior_stage) = prior_stage { - for name in ["inline-config.json"] { - let source = prior_stage.join("ephemeral").join(name); - if source.is_file() { - copy_ephemeral_file(&source, &ephemeral.join(name))?; - } + let source = prior_stage.join("ephemeral").join("inline-config.json"); + if source.is_file() { + copy_ephemeral_file(&source, &ephemeral.join("inline-config.json"))?; } } else { if let Some(raw) = opencode_input.inline_config { diff --git a/crates/freshell-server/src/managed_runtime.rs b/crates/freshell-server/src/managed_runtime.rs index e24ab0fec..138aa6c3e 100644 --- a/crates/freshell-server/src/managed_runtime.rs +++ b/crates/freshell-server/src/managed_runtime.rs @@ -940,6 +940,7 @@ mod tests { terminal_create_request_id: Some("create-stable".into()), terminal_resume_session_id: Some("request-time-session".into()), fresh_agent_session_id: None, + fresh_agent_create_request_id: None, fresh_agent_session_type: None, fresh_agent_runtime_variant: None, project_key: Some("project-test".into()), diff --git a/crates/freshell-server/src/managed_runtime_api.rs b/crates/freshell-server/src/managed_runtime_api.rs index 34a106eaa..882528e24 100644 --- a/crates/freshell-server/src/managed_runtime_api.rs +++ b/crates/freshell-server/src/managed_runtime_api.rs @@ -333,6 +333,10 @@ pub fn router(state: ManagedRuntimeApiState) -> Router { .route("/api/runtime/readiness", get(runtime_readiness)) .route("/api/runtime/souls", get(list_souls)) .route("/api/runtime/souls/{soul_id}", get(get_soul)) + .route( + "/api/runtime/souls/{soul_id}/history", + get(read_native_history), + ) .route("/api/runtime/souls/{soul_id}/retry", post(retry_soul)) .route("/api/runtime/souls/{soul_id}/stop", post(stop_soul)) .route("/api/runtime/souls/{soul_id}/limits", patch(update_limits)) @@ -369,7 +373,7 @@ async fn runtime_readiness( } let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client.inventory_snapshot().await { Ok(snapshot) => Json(json!({ @@ -392,7 +396,7 @@ async fn list_souls( } let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client.inventory_snapshot().await { Ok(mut snapshot) => { @@ -430,7 +434,7 @@ async fn get_soul( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let snapshot = match client.inventory_snapshot().await { Ok(snapshot) => snapshot, @@ -479,7 +483,7 @@ async fn retry_soul( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let Some(revision) = body.expected_intent_revision else { return bad_request("expectedIntentRevision is required"); @@ -490,7 +494,7 @@ async fn retry_soul( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .recover_expected_with_request_id( @@ -506,6 +510,28 @@ async fn retry_soul( } } +async fn read_native_history( + State(state): State, + headers: HeaderMap, + AxumPath(raw): AxumPath, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let soul = match SoulId::parse(raw) { + Ok(soul) => soul, + Err(error) => return bad_request(error.to_string()), + }; + let client = match runtime_client(&state) { + Ok(client) => client, + Err(rejection) => return rejection.into_response(), + }; + match client.fresh_agent_history(soul).await { + Ok(snapshot) => Json(snapshot).into_response(), + Err(error) => client_error(error), + } +} + async fn stop_soul( State(state): State, headers: HeaderMap, @@ -517,7 +543,7 @@ async fn stop_soul( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let Some(revision) = body.expected_intent_revision else { return bad_request("expectedIntentRevision is required"); @@ -528,7 +554,7 @@ async fn stop_soul( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .stop_expected_with_request_id(request_id, soul, Some(revision)) @@ -562,7 +588,7 @@ async fn update_limits( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let Some(revision) = body.expected_intent_revision else { return bad_request("expectedIntentRevision is required"); @@ -573,7 +599,7 @@ async fn update_limits( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .update_limits_with_request_id( @@ -608,7 +634,7 @@ async fn incident_summary( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client.incident_summary(incident_id).await { Ok(summary) => Json(summary).into_response(), @@ -636,7 +662,7 @@ async fn pending_notices( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .pending_notices(profile_id, query.limit.unwrap_or(20)) @@ -666,7 +692,7 @@ async fn record_notice_receipt( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let notice_id = match NoticeId::parse(raw) { Ok(notice_id) => notice_id, @@ -674,7 +700,7 @@ async fn record_notice_receipt( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .record_notice_receipt_with_request_id(request_id, notice_id, body.profile_id, body.state) @@ -694,7 +720,7 @@ async fn runtime_metrics_snapshot( } let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client.runtime_metrics_snapshot().await { Ok(snapshot) => Json(snapshot).into_response(), @@ -740,11 +766,11 @@ async fn migration_response( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .migration_plan_with_request_id( @@ -782,11 +808,11 @@ async fn repair_runtime( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .repair_audit_with_request_id(request_id, body.apply) @@ -818,7 +844,7 @@ async fn upsert_view( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let soul = match SoulId::parse(raw) { Ok(soul) => soul, @@ -836,7 +862,7 @@ async fn upsert_view( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .upsert_view_intent_with_request_id( @@ -877,7 +903,7 @@ async fn update_view( } let request_id = match mutation_request_id(body.request_id) { Ok(request_id) => request_id, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; let view_id = match ViewIntentId::parse(raw) { Ok(view_id) => view_id, @@ -890,7 +916,7 @@ async fn update_view( }; let client = match runtime_client(&state) { Ok(client) => client, - Err(response) => return response, + Err(rejection) => return rejection.into_response(), }; match client .update_view_visibility_with_request_id( @@ -940,20 +966,33 @@ fn projection_event_is_current( || event.view_intent.visibility == ViewVisibilityIntent::Hidden } -fn mutation_request_id(raw: Option) -> Result { +#[derive(Debug)] +struct ApiRejection { + status: StatusCode, + message: String, +} + +impl IntoResponse for ApiRejection { + fn into_response(self) -> Response { + (self.status, Json(json!({"error": self.message}))).into_response() + } +} + +fn mutation_request_id(raw: Option) -> Result { + let rejection = |message: String| ApiRejection { + status: StatusCode::BAD_REQUEST, + message, + }; let Some(raw) = raw.filter(|value| !value.trim().is_empty()) else { - return Err(bad_request("requestId is required")); + return Err(rejection("requestId is required".into())); }; - RequestId::parse(raw).map_err(|error| bad_request(error.to_string())) + RequestId::parse(raw).map_err(|error| rejection(error.to_string())) } -fn runtime_client(state: &ManagedRuntimeApiState) -> Result { - state.client.clone().ok_or_else(|| { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "Managed runtime unavailable"})), - ) - .into_response() +fn runtime_client(state: &ManagedRuntimeApiState) -> Result { + state.client.clone().ok_or_else(|| ApiRejection { + status: StatusCode::SERVICE_UNAVAILABLE, + message: "Managed runtime unavailable".into(), }) } @@ -1038,6 +1077,10 @@ fn atomic_write_json(path: &Path, document: &ProjectionDocument) -> Result<(), S Ok(()) } +#[cfg(test)] +#[path = "managed_runtime_api_stop_tests.rs"] +mod stop_contract_tests; + #[cfg(test)] mod tests { use super::*; @@ -1143,6 +1186,7 @@ mod tests { terminal_create_request_id: Some("create-test".into()), terminal_resume_session_id: Some("ses_test".into()), fresh_agent_session_id: None, + fresh_agent_create_request_id: None, fresh_agent_session_type: None, fresh_agent_runtime_variant: None, project_key: Some("workspace".into()), diff --git a/crates/freshell-server/src/managed_runtime_api_stop_tests.rs b/crates/freshell-server/src/managed_runtime_api_stop_tests.rs new file mode 100644 index 000000000..39008cb55 --- /dev/null +++ b/crates/freshell-server/src/managed_runtime_api_stop_tests.rs @@ -0,0 +1,882 @@ +use super::*; +use async_trait::async_trait; +use axum::{ + body::{to_bytes, Body}, + http::Request, +}; +use freshell_runtime_protocol::*; +use freshell_supervisor::{ + admission::AdmissionPolicy, + backend::{BackendCreated, BackendError, BackendInspection, CreateRuntimeSpec, RuntimeBackend}, + loss_report::{LossDecisionInput, LostDecision}, + registry::{BackendCreatedRecord, LaunchPreparation, OwnedRuntimeHandle, Registry}, + service::{serve_control, Supervisor, SupervisorConfig}, +}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tower::ServiceExt; + +/// No processes or containers are created. The real supervisor owns the stop +/// decision and registry writes; only the external runtime backend is replaced. +#[derive(Default)] +struct StopBackend { + uncertain: AtomicBool, + stopped: Mutex>, + history_home: Option, +} + +#[async_trait] +impl RuntimeBackend for StopBackend { + async fn read_native_history( + &self, + handle: &OwnedRuntimeHandle, + provider: &str, + native_id: &str, + _: &std::path::Path, + ) -> Result { + assert_eq!(handle.fresh_agent().unwrap().provider.as_str(), provider); + freshell_freshagent::native_history::read( + provider, + self.history_home.as_deref().expect("owned native fixture"), + native_id, + ) + .map_err(BackendError::Unavailable) + } + async fn create_stopped(&self, _: &CreateRuntimeSpec) -> Result { + unreachable!() + } + async fn start_host(&self, _: &OwnedRuntimeHandle) -> Result<(), BackendError> { + unreachable!() + } + async fn inspect(&self, _: &OwnedRuntimeHandle) -> Result { + unreachable!() + } + async fn request_stop(&self, handle: &OwnedRuntimeHandle, _: u64) -> Result<(), BackendError> { + self.stopped + .lock() + .unwrap() + .push((handle.soul_id().clone(), handle.incarnation_id().clone())); + if self.uncertain.load(Ordering::SeqCst) { + Err(BackendError::DockerHttp { + status: 503, + body: "cleanup unconfirmed".into(), + }) + } else { + Ok(()) + } + } + async fn force_stop(&self, _: &OwnedRuntimeHandle) -> Result<(), BackendError> { + unreachable!() + } + async fn verify_empty(&self, _: &OwnedRuntimeHandle) -> Result { + Ok(true) + } + async fn list_known( + &self, + _: &[OwnedRuntimeHandle], + ) -> Result, BackendError> { + unreachable!() + } + async fn read_limits(&self, _: &OwnedRuntimeHandle) -> Result { + unreachable!() + } + async fn enable_long_lived(&self, _: &OwnedRuntimeHandle) -> Result<(), BackendError> { + unreachable!() + } +} + +fn limits() -> RuntimeLimits { + RuntimeLimits { + cpu_milli: 500, + memory_bytes: 64 * 1024 * 1024, + swap_bytes: 0, + pids_max: 32, + } +} + +async fn fixture_soul(root: &std::path::Path, certify_loss: bool) -> (Registry, RuntimeView) { + fixture_fresh_soul( + root, + certify_loss, + "opencode", + "freshopencode", + "retained-thread", + ) + .await +} + +async fn fixture_fresh_soul( + root: &std::path::Path, + certify_loss: bool, + provider: &str, + session_type: &str, + native_id: &str, +) -> (Registry, RuntimeView) { + let registry = Registry::open(root, None).unwrap(); + let soul_id = SoulId::new(); + let fresh_agent: FreshAgentLaunchSpec = serde_json::from_value(json!({ + "sessionId": "fresh-retained-thread", "provider": provider, "sessionType": session_type, + "runtimeVariant": provider, "providerStoreId": "store-test", "cwd": "/workspace", + "workspacePath": "/workspace", "runAsUid": 1000, "runAsGid": 1000, + "nativeSessionId": native_id + })) + .unwrap(); + let prepared = registry + .prepare_launch(LaunchPreparation { + soul_id: soul_id.clone(), + provider: provider.into(), + provider_store_id: "store-test".into(), + native_session_id: Some(native_id.into()), + creation_seed_ref: "seed-test".into(), + request_id: RequestId::new(), + payload_digest: "payload-test".into(), + requested_limits: limits(), + profile: RuntimeProfile::Custom, + project_key: "workspace".into(), + fixture: None, + terminal: None, + fresh_agent: Some(fresh_agent), + view_intent: None, + admission: AdmissionPolicy::default(), + }) + .await + .unwrap(); + registry + .commit_created( + prepared.incarnation_id.clone(), + BackendCreatedRecord { + daemon_id: DockerDaemonId::new(), + container_id: "a".repeat(64), + image_ref: format!("sha256:{}", "b".repeat(64)), + runtime_dir: root.join("runtime"), + host_binary_path: root.join("host"), + immutable_config_digest: format!("sha256:{}", "c".repeat(64)), + }, + ) + .await + .unwrap(); + registry + .commit_execution_grant(prepared.incarnation_id.clone(), HostBootId::new(), limits()) + .await + .unwrap(); + registry + .mark_running(prepared.incarnation_id) + .await + .unwrap(); + if !certify_loss { + let view = registry.inventory().await.unwrap().pop().unwrap(); + assert_eq!(view.desired_state, DesiredState::Running); + return (registry, view); + } + let context = registry.recovery_context(soul_id.clone()).await.unwrap(); + let paths = [ + ( + RecoveryPath::Reattach, + EvidenceStoreState::NotApplicable, + "runtime_absent", + ), + ( + RecoveryPath::NativeResume, + EvidenceStoreState::Missing, + "provider_store_missing", + ), + ( + RecoveryPath::CheckpointRestore, + EvidenceStoreState::Missing, + "checkpoint_missing", + ), + ( + RecoveryPath::PristineSeed, + EvidenceStoreState::NotApplicable, + "input_was_dispatched", + ), + ] + .into_iter() + .map(|(path, store_state, reason)| RecoveryPathEvidence { + path, + store_state, + verdict: RecoveryEvidenceVerdict::DefinitiveNegative, + reason_code: reason.into(), + evidence_refs: vec![format!("fixture://{reason}")], + }) + .collect(); + let decision = LostDecision::try_new(LossDecisionInput { + installation_id: registry.installation_id(), + context: &context, + cleanup_handle: &context.prior_handle, + path_evidence: paths, + builds: LossBuildEvidence { + web_commit: "d".repeat(40), + supervisor_commit: "d".repeat(40), + host_image_digest: format!("sha256:{}", "b".repeat(64)), + provider_version: "fixture".into(), + protocol_version: CONTROL_PROTOCOL_VERSION, + registry_schema_version: freshell_supervisor::registry::SCHEMA_VERSION, + }, + timeline: vec![IncidentTimelineEvent { + seq: 1, + at: "2026-10-03T00:00:00.000Z".into(), + event: "loss.checked".into(), + evidence_ref: Some("fixture://all-paths-absent".into()), + exit_code: None, + oom_killed: None, + }], + analysis: IncidentAnalysis { + observed_cause: "all_paths_absent".into(), + missing_invariant: "durable_provider_state".into(), + hypotheses: vec!["state_deleted".into()], + preventive_action: "retain_state".into(), + regression_case: "stop_lost_soul".into(), + }, + created_at: None, + }) + .unwrap(); + let loss = registry.prepare_loss(decision).await.unwrap(); + registry + .finalize_loss_cleanup( + loss.certificate.incident_id, + StopOutcome::TerminationUnconfirmed, + LossCleanupReport { + owned_handle_ref: loss.certificate.cleanup_target.owned_handle_ref, + ownership_verified: true, + graceful_attempt: "failed".into(), + forced_attempt: "not_attempted".into(), + verified_empty: false, + verified_at: None, + foreign_objects_touched: 0, + }, + ) + .await + .unwrap(); + let view = registry.inventory().await.unwrap().pop().unwrap(); + assert_eq!(view.desired_state, DesiredState::Stopped); + assert_eq!(view.recovery_state, RecoveryState::Lost); + assert_eq!(view.cleanup_state, CleanupState::TerminationUnconfirmed); + (registry, view) +} + +async fn web_router(socket: &std::path::Path, root: &std::path::Path) -> Router { + let (tx, _) = tokio::sync::broadcast::channel(16); + router( + ManagedRuntimeApiState::new( + Arc::new("web-token".into()), + Some(RuntimeClient::new(socket, "test-control-secret")), + Some(root.join("projections.json")), + Arc::new(PaneLedger::new(Some(root.join("ledger")))), + Arc::new(tx), + ) + .await + .unwrap(), + ) +} + +async fn stop( + router: &Router, + view: &RuntimeView, + revision: u64, +) -> (StatusCode, serde_json::Value) { + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/runtime/souls/{}/stop", view.soul_id)) + .header("x-auth-token", "web-token") + .header("content-type", "application/json") + .body(Body::from( + json!({ "requestId": RequestId::new(), "expectedIntentRevision": revision }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&body).unwrap()) +} + +async fn start_control( + root: &std::path::Path, + registry: Registry, + backend: Arc, +) -> (PathBuf, tokio::task::JoinHandle<()>) { + let socket = root.join("control.sock"); + let supervisor = Supervisor::new( + registry.clone(), + backend.clone(), + SupervisorConfig { + runtime_root: root.into(), + control_socket_path: socket.clone(), + host_binary_path: std::env::current_exe().unwrap(), + image_ref: format!("sha256:{}", "b".repeat(64)), + test_run_id: "stop-lost-fixture".into(), + control_secret: "test-control-secret".into(), + lifecycle_log: root.join("lifecycle.jsonl"), + admission: AdmissionPolicy::default(), + }, + ) + .unwrap(); + let socket_for_server = socket.clone(); + let control = + tokio::spawn(async move { serve_control(supervisor, &socket_for_server).await.unwrap() }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !socket.exists() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + (socket, control) +} + +async fn set_view_visibility( + router: &Router, + view: &ViewIntent, + visibility: ViewVisibilityIntent, +) -> (StatusCode, serde_json::Value) { + let response = router + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(format!("/api/runtime/views/{}", view.view_id)) + .header("x-auth-token", "web-token") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "requestId": RequestId::new(), "visibility": visibility, + "expectedRevision": view.revision, + "expectedSoulIntentRevision": view.soul_intent_revision, + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +async fn automatic_view(registry: &Registry, soul: &RuntimeView) -> ViewIntent { + registry + .upsert_view_intent(UpsertViewIntentRequest { + soul_id: soul.soul_id.clone(), + view_id: None, + intent: ViewIntentRequest::default(), + expected_revision: None, + expected_soul_intent_revision: soul.intent_revision, + expected_control_epoch: None, + }) + .await + .unwrap() +} + +#[tokio::test] +async fn stopped_hidden_view_satisfies_stale_detach_without_mutating_new_authority() { + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_soul(temp.path(), false).await; + let frozen = automatic_view(®istry, &before).await; + let backend = Arc::new(StopBackend::default()); + let (socket, control) = start_control(temp.path(), registry.clone(), backend).await; + let web = web_router(&socket, temp.path()).await; + let (status, stopped) = stop(&web, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(stopped["outcome"], "verified_empty"); + let hidden = registry.inventory_snapshot().await.unwrap().view_intents[0].clone(); + assert_eq!(hidden.visibility, ViewVisibilityIntent::Hidden); + assert!(hidden.revision > frozen.revision); + assert!(hidden.soul_intent_revision > frozen.soul_intent_revision); + + // Ordinary pane close and tab Shift-close still carry the pre-kill + // projection until the inventory broadcast reaches that browser. + for _ in 0..2 { + let (status, body) = + set_view_visibility(&web, &frozen, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(serde_json::from_value::(body).unwrap(), hidden); + assert_eq!( + registry.inventory_snapshot().await.unwrap().view_intents[0], + hidden + ); + } + let mut future = hidden.clone(); + future.revision += 1; + let (status, _) = set_view_visibility(&web, &future, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::CONFLICT); + future.revision = hidden.revision; + future.soul_intent_revision += 1; + let (status, _) = set_view_visibility(&web, &future, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::CONFLICT); + // A stale visible write still cannot reopen a stopped view. + let (status, _) = set_view_visibility(&web, &frozen, ViewVisibilityIntent::Visible).await; + assert_eq!(status, StatusCode::CONFLICT); + // A newer explicit visible view is authority even while stopped. + let (status, _) = set_view_visibility(&web, &hidden, ViewVisibilityIntent::Visible).await; + assert_eq!(status, StatusCode::OK); + let visible = registry.inventory_snapshot().await.unwrap().view_intents[0].clone(); + let (status, _) = set_view_visibility(&web, &frozen, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + registry.inventory_snapshot().await.unwrap().view_intents[0], + visible + ); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn stale_detach_cannot_close_running_or_cleanup_unconfirmed_views() { + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_soul(temp.path(), false).await; + let frozen = automatic_view(®istry, &before).await; + let backend = Arc::new(StopBackend::default()); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + let web = web_router(&socket, temp.path()).await; + let (status, _) = set_view_visibility(&web, &frozen, ViewVisibilityIntent::Visible).await; + assert_eq!(status, StatusCode::OK); + let newer = registry.inventory_snapshot().await.unwrap().view_intents[0].clone(); + let (status, _) = set_view_visibility(&web, &frozen, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + registry.inventory_snapshot().await.unwrap().view_intents[0], + newer + ); + backend.uncertain.store(true, Ordering::SeqCst); + let (status, stopped) = stop(&web, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(stopped["outcome"], "termination_unconfirmed"); + let hidden = registry.inventory_snapshot().await.unwrap().view_intents[0].clone(); + assert_eq!(hidden.visibility, ViewVisibilityIntent::Hidden); + let (status, _) = set_view_visibility(&web, &frozen, ViewVisibilityIntent::Detached).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + registry.inventory_snapshot().await.unwrap().view_intents[0], + hidden + ); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn restored_web_stops_persisted_lost_soul_only_after_verified_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_soul(temp.path(), true).await; + let backend = Arc::new(StopBackend::default()); + backend.uncertain.store(true, Ordering::SeqCst); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + + // Rebuild web state from persisted inventory, with no session alias cache. + drop(web_router(&socket, temp.path()).await); + let restored = web_router(&socket, temp.path()).await; + let inventory_response = restored + .clone() + .oneshot( + Request::builder() + .uri("/api/runtime/souls") + .header("x-auth-token", "web-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(inventory_response.status(), StatusCode::OK); + let inventory: serde_json::Value = serde_json::from_slice( + &to_bytes(inventory_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + inventory["souls"][0]["freshAgentSessionId"], + "fresh-retained-thread" + ); + let (status, uncertain) = stop(&restored, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(uncertain["outcome"], "termination_unconfirmed"); + assert_eq!(uncertain["soul"]["soulId"], before.soul_id.as_str()); + assert_eq!(uncertain["soul"]["nativeSessionId"], "retained-thread"); + assert_eq!( + uncertain["soul"]["freshAgentSessionId"], + "fresh-retained-thread" + ); + assert_eq!(uncertain["soul"]["recoveryState"], "lost"); + assert_eq!(uncertain["soul"]["cleanupState"], "termination_unconfirmed"); + assert_eq!( + registry + .loss_certificate(before.incident_id.clone().unwrap()) + .await + .unwrap() + .soul_id, + before.soul_id + ); + + let (status, _) = stop(&restored, &before, before.intent_revision - 1).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(backend.stopped.lock().unwrap().len(), 1); + + backend.uncertain.store(false, Ordering::SeqCst); + let (status, verified) = stop(&restored, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(verified["outcome"], "verified_empty"); + assert_eq!(verified["soul"]["cleanupState"], "verified_empty"); + assert_eq!(verified["soul"]["recoveryState"], "lost"); + assert_eq!(verified["soul"]["nativeSessionId"], "retained-thread"); + assert_eq!(verified["soul"]["intentRevision"], before.intent_revision); + assert_eq!( + *backend.stopped.lock().unwrap(), + vec![(before.soul_id.clone(), before.incarnation_id.clone()); 2] + ); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn running_soul_uncertain_stop_returns_the_revision_for_immediate_retry() { + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_soul(temp.path(), false).await; + let backend = Arc::new(StopBackend::default()); + backend.uncertain.store(true, Ordering::SeqCst); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + let router = web_router(&socket, temp.path()).await; + + let (status, uncertain) = stop(&router, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(uncertain["outcome"], "termination_unconfirmed"); + assert_eq!(uncertain["soul"]["desiredState"], "stopped"); + let returned_revision = uncertain["soul"]["intentRevision"].as_u64().unwrap(); + assert_eq!(returned_revision, before.intent_revision + 1); + assert_eq!(uncertain["soul"]["soulId"], before.soul_id.as_str()); + assert_eq!(uncertain["soul"]["nativeSessionId"], "retained-thread"); + + let (status, _) = stop(&router, &before, before.intent_revision).await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(backend.stopped.lock().unwrap().len(), 1); + + backend.uncertain.store(false, Ordering::SeqCst); + let (status, verified) = stop(&router, &before, returned_revision).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(verified["outcome"], "verified_empty"); + assert_eq!(verified["soul"]["intentRevision"], returned_revision); + assert_eq!(verified["soul"]["nativeSessionId"], "retained-thread"); + assert_eq!( + *backend.stopped.lock().unwrap(), + vec![(before.soul_id, before.incarnation_id); 2] + ); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn restored_web_reads_exact_persisted_lost_native_history_without_starting_runtime() { + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_soul(temp.path(), true).await; + let home = temp.path().join("owned-provider-store"); + let directory = home.join(".local/share/opencode"); + std::fs::create_dir_all(&directory).unwrap(); + let connection = rusqlite::Connection::open(directory.join("opencode.db")).unwrap(); + connection.execute_batch("CREATE TABLE session (id TEXT PRIMARY KEY,title TEXT,time_updated INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY,session_id TEXT,message_id TEXT,time_created INTEGER,data TEXT); + INSERT INTO session VALUES ('retained-thread','Durable name',2); + INSERT INTO session VALUES ('foreign-thread','Foreign',2);").unwrap(); + let saved_text = "Actual saved managed answer\n".repeat(50_000); + assert!(saved_text.len() > freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES); + for (id, text) in [ + ("retained-thread", saved_text.as_str()), + ("foreign-thread", "Foreign history"), + ] { + connection + .execute( + "INSERT INTO message VALUES (?1,?2,1,?3)", + rusqlite::params![ + format!("{id}-message"), + id, + json!({"role":"assistant","time":{"created":1,"completed":2}}).to_string() + ], + ) + .unwrap(); + connection + .execute( + "INSERT INTO part VALUES (?1,?2,?3,1,?4)", + rusqlite::params![ + format!("{id}-part"), + id, + format!("{id}-message"), + json!({"type":"text","text":text}).to_string() + ], + ) + .unwrap(); + } + drop(connection); + let backend = Arc::new(StopBackend { + history_home: Some(home), + ..Default::default() + }); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + // Reconstruct the web API: no provider actor, running-soul lookup or alias cache. + drop(web_router(&socket, temp.path()).await); + let router = web_router(&socket, temp.path()).await; + let response = router + .oneshot( + Request::builder() + .uri(format!("/api/runtime/souls/{}/history", before.soul_id)) + .header("x-auth-token", "web-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); + assert_eq!(body["threadId"], "retained-thread"); + assert_eq!(body["turns"][0]["items"][0]["text"], saved_text); + assert_eq!(body["capabilities"]["send"], false); + assert_eq!(registry.inventory().await.unwrap().pop().unwrap(), before); + assert!(backend.stopped.lock().unwrap().is_empty()); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn restored_web_reads_large_retained_codex_history_without_source_or_registry_writes() { + use std::io::Write; + let temp = tempfile::tempdir().unwrap(); + let (registry, before) = fixture_fresh_soul( + temp.path(), + false, + "codex", + "freshcodex", + "large-native-thread", + ) + .await; + registry + .mark_recovery_blocked( + before.soul_id.clone(), + None, + RecoveryBlockReason::ProviderUnavailable, + vec!["fixture://provider-unavailable".into()], + ) + .await + .unwrap(); + let before = registry.inventory().await.unwrap().pop().unwrap(); + let home = temp.path().join("owned-provider-store"); + let directory = home.join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("rollout-large-native-thread.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + writeln!( + file, + "{}", + json!({"type":"session_meta","payload":{"id":"large-native-thread"}}) + ) + .unwrap(); + let answer = "Actual saved answer\n".repeat(8000); + for index in 0..140 { + for row in [ + json!({"type":"turn_context","payload":{"turn_id":format!("turn-{index}")}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated actual prompt"}}), + json!({"type":"event_msg","payload":{"type":"task_complete","last_agent_message":answer}}), + ] { + writeln!(file, "{row}").unwrap(); + } + } + let recent_parts = |kind: &str, tail: &str| { + let mut parts: Vec = (0..24).map(|index| json!({"type":kind,"text":format!("Earlier array part {index} {}", "saved ".repeat(32_000))})).collect(); + parts.push(json!({"type":kind,"text":tail})); + parts + }; + for row in [ + json!({"type":"turn_context","payload":{"turn_id":"latest-turn"}}), + json!({"type":"response_item","payload":{"type":"message","id":"latest-prompt","role":"user","content":[{"type":"input_text","text":"Latest actual saved prompt"}]}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"latest-tool","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"latest-tool","output":recent_parts("input_text", "/actual-workspace")}}), + json!({"type":"response_item","payload":{"type":"message","id":"latest-answer","role":"assistant","content":recent_parts("output_text", "Latest actual saved answer FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:body")}}), + ] { + writeln!(file, "{row}").unwrap(); + } + drop(file); + let source_before = std::fs::read(&path).unwrap(); + assert!(source_before.len() as u64 > freshell_freshagent::native_history::MAX_HISTORY_BYTES); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + let backend = Arc::new(StopBackend { + history_home: Some(home), + ..Default::default() + }); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + let router = web_router(&socket, temp.path()).await; + let response = router + .oneshot( + Request::builder() + .uri(format!("/api/runtime/souls/{}/history", before.soul_id)) + .header("x-auth-token", "web-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!((bytes.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["threadId"], "large-native-thread"); + assert_eq!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); + assert_eq!(body["capabilities"]["send"], false); + let text = body["turns"].to_string(); + assert!(text.contains("Latest actual saved prompt")); + assert!(text.contains("Latest actual saved answer FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:body")); + assert!(text.contains("latest-tool")); + assert!(text.contains("/actual-workspace")); + assert_eq!(std::fs::read(&path).unwrap(), source_before); + assert_eq!( + std::fs::metadata(&path).unwrap().modified().unwrap(), + modified + ); + assert_eq!(registry.inventory().await.unwrap().pop().unwrap(), before); + assert!(backend.stopped.lock().unwrap().is_empty()); + control.abort(); + let _ = control.await; +} + +#[tokio::test] +async fn unavailable_runtime_and_invalid_mutation_keep_their_http_error_contracts() { + let root = tempfile::tempdir().unwrap(); + let (tx, _) = tokio::sync::broadcast::channel(16); + let router = router( + ManagedRuntimeApiState::new( + Arc::new("web-token".into()), + None, + None, + Arc::new(PaneLedger::new(Some(root.path().join("ledger")))), + Arc::new(tx), + ) + .await + .unwrap(), + ); + for (method, uri, body, status, error) in [ + ( + "GET", + "/api/runtime/souls/retained-soul/history", + None, + StatusCode::SERVICE_UNAVAILABLE, + "Managed runtime unavailable", + ), + ( + "POST", + "/api/runtime/souls/retained-soul/stop", + Some(json!({"expectedIntentRevision":1})), + StatusCode::BAD_REQUEST, + "requestId is required", + ), + ( + "POST", + "/api/runtime/souls/retained-soul/stop", + Some(json!({"requestId":" ","expectedIntentRevision":1})), + StatusCode::BAD_REQUEST, + "requestId is required", + ), + ( + "POST", + "/api/runtime/souls/retained-soul/stop", + Some(json!({"requestId":"valid-request","expectedIntentRevision":1})), + StatusCode::SERVICE_UNAVAILABLE, + "Managed runtime unavailable", + ), + ] { + let response = router + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("x-auth-token", "web-token") + .header("content-type", "application/json") + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), status); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(body, json!({"error":error})); + } +} + +#[tokio::test] +async fn restored_api_reads_managed_claude_and_kilroy_native_history_without_an_actor() { + for (provider, session_type) in [("claude", "freshclaude"), ("kilroy", "kilroy")] { + let temp = tempfile::tempdir().unwrap(); + let id = "44444444-4444-4444-8444-444444444444"; + let (registry, before) = + fixture_fresh_soul(temp.path(), false, provider, session_type, id).await; + registry + .mark_recovery_blocked( + before.soul_id.clone(), + None, + RecoveryBlockReason::StoreUnreadable, + vec!["fixture native store temporarily unavailable".into()], + ) + .await + .unwrap(); + let handle = registry.begin_stop(before.soul_id.clone()).await.unwrap(); + registry + .mark_stop_outcome(handle.incarnation_id().clone(), StopOutcome::VerifiedEmpty) + .await + .unwrap(); + let before = registry.inventory().await.unwrap().pop().unwrap(); + assert_eq!(before.desired_state, DesiredState::Stopped); + let home = temp.path().join("owned-provider-store"); + let directory = home.join(".claude/projects/-workspace"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join(format!("{id}.jsonl")), + include_str!("../../../test/fixtures/managed-native-history/claude.jsonl"), + ) + .unwrap(); + let backend = Arc::new(StopBackend { + history_home: Some(home), + ..Default::default() + }); + let (socket, control) = start_control(temp.path(), registry.clone(), backend.clone()).await; + drop(web_router(&socket, temp.path()).await); + let response = web_router(&socket, temp.path()) + .await + .oneshot( + Request::builder() + .uri(format!("/api/runtime/souls/{}/history", before.soul_id)) + .header("x-auth-token", "web-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(body["threadId"], id); + assert_eq!(body["sessionType"], session_type); + assert_eq!(body["provider"], "claude"); + assert!(body["turns"] + .to_string() + .contains("Saved native Claude answer")); + assert!(body["turns"].to_string().contains("toolu_native")); + assert_eq!(body["capabilities"]["send"], false); + assert_eq!(registry.inventory().await.unwrap().pop().unwrap(), before); + assert!(backend.stopped.lock().unwrap().is_empty()); + control.abort(); + let _ = control.await; + } +} diff --git a/crates/freshell-server/src/net_bind.rs b/crates/freshell-server/src/net_bind.rs index 7b39723ab..3a5b6f32b 100644 --- a/crates/freshell-server/src/net_bind.rs +++ b/crates/freshell-server/src/net_bind.rs @@ -238,9 +238,11 @@ mod tests { use super::*; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - fn free_port() -> u16 { - let l = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); - l.local_addr().unwrap().port() + // Keep this listener until the controller's initial bind succeeds, then + // drop it before sending traffic so only the controller accepts requests. + fn reusable_loopback_listener() -> StdTcpListener { + bind_reusable(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), true) + .expect("own an OS-assigned reusable listener") } #[test] @@ -255,18 +257,17 @@ mod tests { #[test] fn two_reuseport_binds_on_same_addr_both_succeed() { - let port = free_port(); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); - let a = bind_reusable(addr, true).expect("first reuseport bind"); + let a = reusable_loopback_listener(); + let addr = a.local_addr().expect("first listener address"); let b = bind_reusable(addr, true).expect("second reuseport bind must also succeed"); drop((a, b)); } #[test] fn foreign_squatter_blocks_our_bind() { - let port = free_port(); - let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port); - let squatter = std::net::TcpListener::bind(addr).expect("squatter binds"); + let squatter = + std::net::TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)).expect("squatter binds"); + let addr = squatter.local_addr().expect("squatter address"); let result = bind_reusable(addr, true); assert!( result.is_err(), @@ -278,13 +279,23 @@ mod tests { #[tokio::test] async fn serve_on_proves_bind_before_swapping_and_serves_traffic() { use axum::{routing::get, Router}; - let port = free_port(); + let port_owner = reusable_loopback_listener(); + let port = port_owner + .local_addr() + .expect("initial listener address") + .port(); let app = Router::new().route("/ping", get(|| async { "pong" })); let ctl = RebindController::new(port, true); ctl.set_app(app); + let competing_bind = StdTcpListener::bind((Ipv4Addr::LOCALHOST, port)); + assert!( + matches!(&competing_bind, Err(err) if err.kind() == std::io::ErrorKind::AddrInUse), + "the fixture must own its chosen port until the controller starts serving" + ); ctl.serve_on(IpAddr::V4(Ipv4Addr::LOCALHOST)) .await .expect("initial serve"); + drop(port_owner); let body = reqwest::get(format!("http://127.0.0.1:{port}/ping")) .await .unwrap() @@ -311,13 +322,18 @@ mod tests { // reports/V1.md): with notify_waiters and no barrier, 42-99/100 of // these iterations fail. Do NOT weaken this test. use axum::{routing::get, Router}; - let port = free_port(); + let port_owner = reusable_loopback_listener(); + let port = port_owner + .local_addr() + .expect("initial listener address") + .port(); let app = Router::new().route("/ping", get(|| async { "pong" })); let ctl = RebindController::new(port, true); ctl.set_app(app); let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST); let wildcard = IpAddr::V4(Ipv4Addr::UNSPECIFIED); ctl.serve_on(localhost).await.expect("initial serve"); + drop(port_owner); for i in 0..100 { let target = if i % 2 == 0 { wildcard } else { localhost }; ctl.serve_on(target).await.expect("swap"); @@ -344,13 +360,18 @@ mod tests { use std::io::Write; use tokio::io::AsyncReadExt; - let port = free_port(); + let port_owner = reusable_loopback_listener(); + let port = port_owner + .local_addr() + .expect("initial listener address") + .port(); let app = Router::new().route("/ping", get(|| async { "pong" })); let ctl = RebindController::new(port, true); ctl.set_app(app); ctl.serve_on(IpAddr::V4(Ipv4Addr::LOCALHOST)) .await .expect("initial serve"); + drop(port_owner); // A current-thread runtime has not polled the accept loop yet. The // handshake and request reach the old socket before its shutdown. @@ -394,7 +415,11 @@ mod tests { use tokio::sync::{mpsc, watch}; use tokio::time::timeout; - let port = free_port(); + let port_owner = reusable_loopback_listener(); + let port = port_owner + .local_addr() + .expect("initial listener address") + .port(); let (arrived_tx, mut arrived_rx) = mpsc::unbounded_channel::<()>(); let (release_tx, release_rx) = watch::channel(false); let slow = { @@ -421,6 +446,7 @@ mod tests { ctl.set_app(app); let localhost = IpAddr::V4(Ipv4Addr::LOCALHOST); ctl.serve_on(localhost).await.expect("initial serve"); + drop(port_owner); // Start a request that will still be in flight when we swap. Only the // OLD listener exists at this point, so it owns the connection. diff --git a/crates/freshell-session-host/Cargo.toml b/crates/freshell-session-host/Cargo.toml index 471fc638d..d4c01b550 100644 --- a/crates/freshell-session-host/Cargo.toml +++ b/crates/freshell-session-host/Cargo.toml @@ -29,3 +29,6 @@ async-trait = "0.1" uuid = { version = "1", features = ["v4"] } url = "2" tempfile = "3" + +[dev-dependencies] +rusqlite = { version = "0.31", features = ["bundled"] } diff --git a/crates/freshell-session-host/src/main.rs b/crates/freshell-session-host/src/main.rs index 473f34339..9b75c258d 100644 --- a/crates/freshell-session-host/src/main.rs +++ b/crates/freshell-session-host/src/main.rs @@ -116,6 +116,15 @@ async fn run() -> Result<(), String> { match args.get(1).map(String::as_str) { Some("serve") => serve(&args[2..]).await, Some("worker") => worker(&args[2..]).await, + Some("native-history-only") => { + let snapshot = freshell_freshagent::native_history::read( + &required_arg(&args[2..], "--provider")?, + Path::new(&required_arg(&args[2..], "--provider-home")?), + &required_arg(&args[2..], "--session-id")?, + )?; + println!("{snapshot}"); + Ok(()) + } Some("fixture-child") => fixture_child(&args[2..]).await, #[cfg(feature = "fresh-agent-fixtures")] Some("fresh-agent-fixture-worker") => providers::run_fresh_agent_fixture_worker(&args[2..]).await, @@ -206,9 +215,18 @@ async fn handle_connection(mut stream: UnixStream, state: Arc) -> Res serde_json::json!({"errorCode":error.code,"message":error.message}), ); } - write_frame(&mut stream, &HostReply { request_id, result }) - .await - .map_err(|e| e.to_string()) + let limit = if matches!(result, Ok(HostResult::FreshAgentSnapshot(_))) { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &HostReply { request_id, result }, + limit, + ) + .await + .map_err(|e| e.to_string()) } async fn dispatch( @@ -575,6 +593,18 @@ async fn dispatch( .map_err(map_actor_error)?, )) } + HostCommand::FreshAgentReadSnapshot { incarnation_id } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentSnapshot( + actor.snapshot().await.map_err(map_actor_error)?, + )) + } HostCommand::FreshAgentResolve { incarnation_id, decision_id, @@ -2856,6 +2886,13 @@ mod tests { }) } + async fn snapshot(&self) -> Result { + Ok( + serde_json::json!({"threadId":"fixture-native-thread","provider":"claude", + "sessionType":"freshclaude","status":"idle","turns":[{"turnId":"retained-rpc-turn","text":"x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}), + ) + } + async fn stop(self: Arc) -> Result<(), String> { self.stops.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -3015,6 +3052,34 @@ mod tests { .await .unwrap(); assert!(matches!(rollback, HostResult::FreshAgentCommand { .. })); + let before_snapshot = transport.dispatches.load(Ordering::SeqCst); + let (mut client_stream, server_stream) = UnixStream::pair().unwrap(); + let serving = tokio::spawn(handle_connection(server_stream, state.clone())); + write_frame( + &mut client_stream, + &authenticated_host_envelope( + &state, + HostCommand::FreshAgentReadSnapshot { + incarnation_id: state.incarnation_id.clone(), + }, + ), + ) + .await + .unwrap(); + let snapshot: HostReply = freshell_runtime_protocol::read_frame_with_limit( + &mut client_stream, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + serving.await.unwrap().unwrap(); + assert!( + matches!(snapshot.result.unwrap(), HostResult::FreshAgentSnapshot(value) + if value["threadId"] == "fixture-native-thread" && value["turns"][0]["turnId"] == "retained-rpc-turn" + && value["turns"][0]["text"].as_str().unwrap().len() == 2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES) + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), before_snapshot); + assert_eq!(transport.stops.load(Ordering::SeqCst), 0); let capture = dispatch( authenticated_host_envelope( &state, diff --git a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs index 68cb66031..5ae495246 100644 --- a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs @@ -283,6 +283,42 @@ impl FreshAgentTransport for DeterministicFreshAgentTransport { Ok(()) } + async fn snapshot(&self) -> Result { + let state = self.state.lock().await.clone(); + // Fixture transcript setup lives in the same owned native store that + // the history route reads. Current fixture state supplies live gates. + let mut snapshot = read_provider_snapshot( + &self.state_dir, + self.provider.as_str(), + &state.native_session_id, + self.run_as_uid, + self.run_as_gid, + ) + .await?; + let provider = if self.provider == FreshProvider::Kilroy { + "claude" + } else { + self.provider.as_str() + }; + snapshot["extensions"][provider] + .as_object_mut() + .ok_or("fixture provider metadata unavailable")? + .remove("nativeHistoryAvailable"); + snapshot["extensions"][provider]["ownerKind"] = json!("fresh-agent"); + snapshot["extensions"][provider]["statusFromLiveState"] = json!(true); + snapshot["status"] = json!(if state.pending_decision_id.is_some() { + "permission" + } else if state.dispatch_count > state.completion_count { + "running" + } else { + "idle" + }); + snapshot["capabilities"]["send"] = json!(state.pending_decision_id.is_none()); + snapshot["capabilities"]["interrupt"] = + json!(state.dispatch_count > state.completion_count); + Ok(snapshot) + } + async fn is_live(&self) -> bool { let mut slot = self.child.lock().await; match slot.as_mut() { @@ -445,6 +481,38 @@ async fn read_provider_state( .map_err(|error| format!("decode provider-state worker reply: {error}")) } +#[cfg(test)] +async fn read_provider_snapshot( + state_dir: &Path, + provider: &str, + native_id: &str, + _: u32, + _: u32, +) -> Result { + freshell_freshagent::native_history::read( + provider, + state_dir + .parent() + .ok_or("fixture provider home unavailable")?, + native_id, + ) +} + +#[cfg(not(test))] +async fn read_provider_snapshot( + state_dir: &Path, + provider: &str, + native_id: &str, + run_as_uid: u32, + run_as_gid: u32, +) -> Result { + let input = serde_json::to_vec(&json!({"provider":provider,"nativeId":native_id})) + .map_err(|error| error.to_string())?; + let output = + provider_state_worker(state_dir, run_as_uid, run_as_gid, "snapshot", Some(&input)).await?; + serde_json::from_slice(&output).map_err(|error| format!("decode provider snapshot: {error}")) +} + #[cfg(not(test))] async fn write_provider_state( state_dir: &Path, @@ -532,7 +600,23 @@ pub(crate) fn run_state_worker(args: &[String]) -> Result<(), String> { .map_err(|error| format!("decode provider-state write: {error}"))?; write_state(state_dir, &state) } - _ => Err("fixture state worker requires read or write".into()), + [operation] if operation == "snapshot" => { + let input: Value = + serde_json::from_reader(std::io::stdin()).map_err(|error| error.to_string())?; + let snapshot = freshell_freshagent::native_history::read( + input["provider"] + .as_str() + .ok_or("fixture snapshot provider missing")?, + state_dir + .parent() + .ok_or("fixture provider home unavailable")?, + input["nativeId"] + .as_str() + .ok_or("fixture snapshot identity missing")?, + )?; + serde_json::to_writer(std::io::stdout(), &snapshot).map_err(|error| error.to_string()) + } + _ => Err("fixture state worker requires read, write or snapshot".into()), } } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index b6d61eccf..30be1f888 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -887,6 +887,53 @@ impl FreshAgentTransport for HostedTransport { }) } + async fn snapshot(&self) -> Result { + #[cfg(test)] + if let Some(delegate) = self.test_delegate.as_ref() { + return delegate.snapshot().await; + } + let mut snapshot = self.snapshot_value().await?; + // The native getter's vacant fallback means its captured HTTP read + // failed; tracking the session does not make that empty result live. + if self.provider == FreshProvider::Opencode + && snapshot["extensions"]["opencode"]["ownerKind"] == "vacant" + { + return Err("opencode snapshot unavailable".into()); + } + let provider = if self.provider == FreshProvider::Kilroy { + "claude" + } else { + self.provider.as_str() + }; + snapshot["extensions"][provider]["statusFromLiveState"] = serde_json::json!(true); + Ok(snapshot) + } + + async fn registered_snapshot_identity(&self) -> Option { + if self.provider != FreshProvider::Opencode + || self.native_rx.lock().await.borrow().is_some() + || self + .profile + .lock() + .unwrap() + .as_ref()? + .native_session_id + .is_some() + { + return None; + } + let session_id = self.session_id.lock().await.clone()?; + let ProviderState::Opencode { runtime, .. } = &self.state else { + return None; + }; + if !runtime.has_live_session(&session_id).await + || self.native_rx.lock().await.borrow().is_some() + { + return None; + } + Some(session_id) + } + async fn is_live(&self) -> bool { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -1261,6 +1308,390 @@ fn parse_send_outcome(value: &Value) -> Option<(String, bool)> { mod tests { use super::*; + static OPENCODE_SNAPSHOT_ENV_LOCK: Mutex<()> = Mutex::const_new(()); + + struct SnapshotProviderEnv(Vec<(&'static str, Option)>); + impl Drop for SnapshotProviderEnv { + fn drop(&mut self) { + for (key, value) in &self.0 { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + + #[tokio::test] + async fn actual_opencode_first_send_materializes_once_and_reuses_owned_native_http_session() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/e2e-browser/fixtures/fake-opencode.cjs") + .canonicalize() + .unwrap(); + let audit = dir.path().join("native-audit.jsonl"); + let keys = [ + "OPENCODE_CMD", + "HOME", + "XDG_DATA_HOME", + "FAKE_OPENCODE_AUDIT_LOG", + ]; + let _env = SnapshotProviderEnv( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var("OPENCODE_CMD", fixture); + std::env::set_var("HOME", dir.path()); + std::env::set_var("XDG_DATA_HOME", dir.path().join("data")); + std::env::set_var("FAKE_OPENCODE_AUDIT_LOG", &audit); + let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; + let profile = FreshAgentProfile { + provider: FreshProvider::Opencode, + runtime_variant: "freshopencode".into(), + cwd: dir.path().to_string_lossy().into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "owned-store".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }; + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport.clone()) + .await + .unwrap(); + let registered = transport.session_id.lock().await.clone().unwrap(); + let pre = actor.snapshot().await; + let spawned_before_send = audit.exists(); + let first = actor + .dispatch( + RequestId::parse("first-owned-send").unwrap(), + "First owned prompt".into(), + None, + ) + .await; + let identity = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Some(native) = actor.profile().await.native_session_id { + break native; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + let live = actor.snapshot().await; + let second = actor + .dispatch( + RequestId::parse("second-owned-send").unwrap(), + "Second owned prompt".into(), + None, + ) + .await; + let observed_prompts = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let count = std::fs::read_to_string(&audit) + .unwrap_or_default() + .lines() + .filter(|line| { + serde_json::from_str::(line) + .is_ok_and(|row| row["event"] == "prompt_async") + }) + .count(); + if count == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + let final_native = actor.profile().await.native_session_id; + transport.stop().await.unwrap(); + let rows: Vec = std::fs::read_to_string(&audit) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!( + !spawned_before_send, + "snapshot must not spawn the native daemon" + ); + assert_eq!(pre.unwrap()["threadId"], registered); + first.unwrap(); + second.unwrap(); + observed_prompts.unwrap(); + let native = identity.unwrap(); + assert_eq!(live.unwrap()["threadId"], native); + assert_eq!(final_native.as_deref(), Some(native.as_str())); + assert_eq!( + rows.iter() + .filter(|row| row["event"] == "session_created") + .count(), + 1 + ); + assert_eq!( + rows.iter() + .filter(|row| row["event"] == "prompt_async" && row["sessionId"] == native) + .count(), + 2 + ); + } + + #[tokio::test] + async fn actual_opencode_snapshot_read_failure_preserves_owned_native_identity() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/e2e-browser/fixtures/fake-opencode.cjs") + .canonicalize() + .unwrap(); + let audit = dir.path().join("native-audit.jsonl"); + let marker = dir.path().join("snapshot-read-failure"); + let keys = [ + "OPENCODE_CMD", + "HOME", + "XDG_DATA_HOME", + "OPENCODE_DB", + "FAKE_OPENCODE_AUDIT_LOG", + "FAKE_OPENCODE_SNAPSHOT_READ_FAILURE_MARKER", + "FAKE_OPENCODE_SELF_EXIT_MARKER", + "FAKE_OPENCODE_HANG_SESSION_CREATE", + "FAKE_OPENCODE_PROMPT_ERROR", + "FAKE_OPENCODE_TOOL_ERROR", + "FAKE_OPENCODE_TUI_PARITY", + "FAKE_OPENCODE_BUSY_AT_LAUNCH", + "FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE", + "FAKE_OPENCODE_PROJECT_CWD", + ]; + let _env = SnapshotProviderEnv( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + for key in keys { + std::env::remove_var(key); + } + std::env::set_var("OPENCODE_CMD", fixture); + std::env::set_var("HOME", dir.path()); + std::env::set_var("XDG_DATA_HOME", dir.path().join(".local/share")); + std::env::set_var("FAKE_OPENCODE_AUDIT_LOG", &audit); + std::env::set_var("FAKE_OPENCODE_SNAPSHOT_READ_FAILURE_MARKER", &marker); + std::env::set_var("FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE", "1"); + std::env::set_var("FAKE_OPENCODE_PROJECT_CWD", dir.path()); + let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; + let read_audit = || -> Result, String> { + std::fs::read_to_string(&audit) + .map_err(|error| error.to_string())? + .lines() + .map(serde_json::from_str) + .collect::>() + .map_err(|error| error.to_string()) + }; + // Capture every outcome, then join the owned fixture before asserting, + // including the product RED and setup-error paths. + let observed: Result = async { + let profile = FreshAgentProfile { + provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), + cwd: dir.path().to_string_lossy().into(), model: None, effort: None, + permission_mode: None, sandbox: None, provider_store_id: "owned-store".into(), + native_session_id: None, plugins: None, model_selection: None, session_ref: None, + provider_launch_context: None, provider_secret_references: vec![], + }; + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport.clone()) + .await.map_err(|error| error.to_string())?; + let registered = transport.session_id.lock().await.clone().ok_or("missing registration")?; + let pre = actor.snapshot().await.map_err(|error| error.to_string())?; + let spawned_before_send = audit.exists(); + actor.dispatch(RequestId::parse("read-failure-owned-send").unwrap(), + "Owned conversation before unavailable read".into(), None) + .await.map_err(|error| error.to_string())?; + let live = tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Ok(snapshot) = actor.snapshot().await { + if snapshot["turns"].as_array().is_some_and(|turns| !turns.is_empty()) + && serde_json::to_string(&snapshot).unwrap().contains("Fake OpenCode response:") { + break snapshot; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }).await.map_err(|_| "native prompt did not complete".to_string())?; + let native = actor.profile().await.native_session_id.ok_or("missing native identity")?; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if actor.read_events(0, 256).await.events.iter().any(|entry| + matches!(&entry.event, AgentEvent::Provider { payload } + if payload["sessionId"] == native && payload["event"]["type"] == "freshAgent.turn.complete")) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.map_err(|_| "actor did not journal native turn completion".to_string())?; + let journal_path = dir.path().join("actor/fresh-agent-state.json"); + let journal_before = std::fs::read(&journal_path).map_err(|error| error.to_string())?; + let profile_before = actor.profile().await; + let db_path = dir.path().join(".local/share/opencode/opencode.db"); + let source_before = std::fs::read(&db_path).map_err(|error| error.to_string())?; + let ProviderState::Opencode { owner, runtime } = &transport.state else { unreachable!() }; + let launched = read_audit()?; + let empty_native = launched.iter().find(|row| row["event"] == "launch") + .and_then(|row| row["rootSessionId"].as_str()).ok_or("missing seeded native empty session")?; + let healthy_empty = owner.get_opencode_snapshot(empty_native, Some(&profile_before.cwd)) + .await.map_err(|error| error.to_string())?; + let history_before = freshell_freshagent::native_history::read("opencode", dir.path(), &native)?; + std::fs::write(&marker, b"fail native snapshot reads").map_err(|error| error.to_string())?; + let fallback = owner.get_opencode_snapshot(&native, Some(&profile_before.cwd)) + .await.map_err(|error| error.to_string())?; + let tracked = runtime.has_live_session(&native).await; + let unavailable = actor.snapshot().await; + let failures = read_audit()?; + std::fs::remove_file(&marker).map_err(|error| error.to_string())?; + let recovered = actor.snapshot().await.map_err(|error| error.to_string())?; + let history_after = freshell_freshagent::native_history::read("opencode", dir.path(), &native)?; + let journal_unchanged = std::fs::read(&journal_path).map_err(|error| error.to_string())? == journal_before; + let source_unchanged = std::fs::read(&db_path).map_err(|error| error.to_string())? == source_before; + let profile_unchanged = actor.profile().await == profile_before; + let rows = read_audit()?; + Ok(json!({ "pre":pre, "registered":registered, "spawnedBeforeSend":spawned_before_send, + "native":native, "live":live, "healthyEmpty":healthy_empty, "fallback":fallback, "tracked":tracked, + "unavailable":unavailable.is_err(), "unavailableResult":format!("{unavailable:?}"), + "recovered":recovered, "historyBefore":history_before, "historyAfter":history_after, + "journalUnchanged":journal_unchanged, "sourceUnchanged":source_unchanged, + "profileUnchanged":profile_unchanged, "rows":rows, "failureRows":failures })) + }.await; + // Also clear a marker left by any preparation failure before teardown. + let _ = std::fs::remove_file(&marker); + let stopped = transport.stop().await; + stopped.expect("join exact owned OpenCode fixture shutdown"); + let observed = + observed.expect("establish actual native HTTP read failure and healthy recovery"); + assert_eq!(observed["pre"]["threadId"], observed["registered"]); + assert_eq!(observed["pre"]["capabilities"]["send"], true); + assert_eq!(observed["spawnedBeforeSend"], false); + assert_eq!(observed["live"]["threadId"], observed["native"]); + assert!(observed["healthyEmpty"]["turns"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!(observed["healthyEmpty"]["capabilities"]["send"], true); + assert!(observed["healthyEmpty"]["extensions"]["opencode"]["ownerKind"].is_null()); + assert_eq!(observed["tracked"], true); + assert_eq!(observed["fallback"]["threadId"], observed["native"]); + assert_eq!( + observed["fallback"]["extensions"]["opencode"]["ownerKind"], + "vacant" + ); + assert!(observed["fallback"]["turns"].as_array().unwrap().is_empty()); + assert!(observed["fallback"]["extensions"]["opencode"]["nativeHistoryAvailable"].is_null()); + let rows = observed["rows"].as_array().unwrap(); + let serving = rows + .iter() + .find(|row| row["event"] == "listen") + .expect("native server listening"); + let failed: Vec<_> = observed["failureRows"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["event"] == "snapshot_read_failed") + .collect(); + assert!( + failed.len() >= 2, + "getter and actor must both execute the failed native HTTP read" + ); + for row in failed { + assert_eq!(row["sessionId"], observed["native"]); + assert_eq!(row["routeDirectory"], dir.path().to_string_lossy().as_ref()); + assert_eq!(row["pid"], serving["pid"]); + } + assert_eq!( + rows.iter().filter(|row| row["event"] == "listen").count(), + 1 + ); + assert_eq!( + rows.iter() + .filter(|row| row["event"] == "session_created") + .count(), + 1 + ); + assert_eq!( + rows.iter() + .filter(|row| row["event"] == "prompt_async") + .count(), + 1 + ); + assert!(!rows + .iter() + .any(|row| row["event"] == "self_exit" || row["event"] == "shutdown")); + assert_eq!(observed["recovered"]["threadId"], observed["native"]); + assert_eq!( + observed["recovered"]["extensions"]["opencode"]["statusFromLiveState"], + true + ); + assert_eq!(observed["historyBefore"], observed["historyAfter"]); + assert_eq!(observed["historyAfter"]["threadId"], observed["native"]); + assert_eq!(observed["historyAfter"]["capabilities"]["send"], false); + assert_eq!( + observed["historyAfter"]["extensions"]["opencode"]["nativeHistoryAvailable"], + true + ); + assert_eq!(observed["journalUnchanged"], true); + assert_eq!(observed["sourceUnchanged"], true); + assert_eq!(observed["profileUnchanged"], true); + assert_eq!( + observed["unavailable"], true, + "vacant native fallback must not certify live truth: {}", + observed["unavailableResult"] + ); + } + + #[tokio::test] + async fn actual_opencode_zero_turn_snapshot_keeps_registered_identity_without_materializing() { + let dir = tempfile::tempdir().unwrap(); + let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; + let profile = FreshAgentProfile { + provider: FreshProvider::Opencode, + runtime_variant: "freshopencode".into(), + cwd: dir.path().to_string_lossy().into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "owned-store".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }; + let actor = FreshAgentHostActor::open(dir.path(), profile, transport.clone()) + .await + .unwrap(); + let registered = transport.session_id.lock().await.clone().unwrap(); + let before = std::fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let result = actor.snapshot().await; + let after = std::fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let native = actor.profile().await.native_session_id; + transport.stop().await.unwrap(); + let snapshot = result.expect("registered zero-turn OpenCode is live snapshot truth"); + assert_eq!(snapshot["threadId"], registered); + assert_eq!(snapshot["sessionType"], "freshopencode"); + assert_eq!(snapshot["provider"], "opencode"); + assert_eq!(snapshot["capabilities"]["send"], true); + assert!(snapshot["turns"].as_array().unwrap().is_empty()); + assert_eq!( + snapshot["extensions"]["opencode"]["statusFromLiveState"], + true + ); + assert!(native.is_none()); + assert_eq!(after, before); + } + #[derive(Debug, Clone, PartialEq, Eq)] struct TransportObservation { route: String, diff --git a/crates/freshell-session-host/tests/native_history.rs b/crates/freshell-session-host/tests/native_history.rs new file mode 100644 index 000000000..3128d41d8 --- /dev/null +++ b/crates/freshell-session-host/tests/native_history.rs @@ -0,0 +1,2024 @@ +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::{path::Path, process::Command}; + +fn history(home: &Path, provider: &str, session: &str) -> std::process::Output { + use std::os::unix::fs::PermissionsExt; + let probe = home.join("provider-start-probe"); + let counter = home.join("provider-started"); + std::fs::write( + &probe, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", counter.display()), + ) + .unwrap(); + std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o755)).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_freshell-session-host")) + .args([ + "native-history-only", + "--provider", + provider, + "--session-id", + session, + "--provider-home", + ]) + .arg(home) + .env("CLAUDE_CMD", &probe) + .env("CODEX_CMD", &probe) + .env("OPENCODE_CMD", &probe) + .output() + .unwrap(); + assert!(!counter.exists(), "history read attempted a provider start"); + output +} + +fn rollout(home: &Path, id: &str, text: &str) { + let root = home.join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&root).unwrap(); + let rows = [ + json!({"type":"session_meta","payload":{"id":id,"cwd":"/workspace","history_mode":"paginated"}}), + json!({"type":"turn_context","payload":{"turn_id":"native-turn","model":"saved-model"}}), + json!({"type":"response_item","payload":{"type":"message","id":"native-user","role":"user","content":[{"type":"input_text","text":"Saved user prompt"}]}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Saved user prompt"}}), + json!({"type":"response_item","payload":{"type":"message","id":"native-assistant","role":"assistant","content":[{"type":"output_text","text":text}]}}), + ]; + std::fs::write( + root.join(format!("rollout-2026-10-03-{id}.jsonl")), + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); +} + +fn write_codex_rows(home: &Path, id: &str, rows: &[Value]) { + let root = home.join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join(format!("rollout-2026-10-03-{id}.jsonl")), + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); +} + +fn codex_response_message(role: &str, index: usize, text: &str) -> Value { + json!({"type":"response_item","payload":{"type":"message","role":role,"id":format!("{role}-{index}"), + "content":[{"type":if role == "user" { "input_text" } else { "output_text" },"text":text}]}}) +} + +#[test] +fn history_binary_reads_exact_saved_codex_rollout_without_a_runtime() { + let home = tempfile::tempdir().unwrap(); + rollout(home.path(), "selected-thread", "Saved Codex answer"); + rollout( + home.path(), + "other-thread", + "Other conversation must not appear", + ); + let result = history(home.path(), "codex", "selected-thread"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["threadId"], "selected-thread"); + assert_eq!(body["turns"].as_array().unwrap().len(), 2); + assert_eq!(body["turns"][0]["items"][0]["text"], "Saved user prompt"); + assert_eq!(body["turns"][1]["items"][0]["text"], "Saved Codex answer"); + assert_eq!(body["capabilities"]["send"], false); + assert!(!home.path().join("fresh-agent-state.json").exists()); + assert!(!history(home.path(), "codex", "missing-thread") + .status + .success()); +} + +#[test] +fn history_binary_preserves_normal_text_that_quotes_the_retention_marker() { + let home = tempfile::tempdir().unwrap(); + let text = "Source code defines OMITTED_BODY as FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:; this is saved text."; + rollout(home.path(), "quoted-marker", text); + let result = history(home.path(), "codex", "quoted-marker"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["turns"][1]["items"][0]["text"], text); + assert_ne!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); +} + +#[test] +fn history_binary_preserves_full_literal_retention_markers() { + for provider in ["claude", "kilroy", "codex", "opencode"] { + let home = tempfile::tempdir().unwrap(); + let literal = format!("Saved FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:body; collection FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:collection; digest FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:{} remains literal", "a".repeat(64)); + if provider == "codex" { + write_codex_rows( + home.path(), + "literal-markers", + &[ + json!({"type":"session_meta","payload":{"id":"literal-markers"}}), + codex_response_message("user", 0, &literal), + json!({"type":"event_msg","payload":{"type":"user_message","message":literal}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"literal-tool","name":"echo","arguments":json!({"saved":literal}).to_string()}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"literal-tool","output":[{"type":"input_text","text":literal}]}}), + codex_response_message("assistant", 0, &literal), + ], + ); + } else if provider == "opencode" { + let directory = home.path().join(".local/share/opencode"); + std::fs::create_dir_all(&directory).unwrap(); + let db = Connection::open(directory.join("opencode.db")).unwrap(); + db.execute_batch("CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, time_updated INTEGER); CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, time_created INTEGER, data TEXT); INSERT INTO session VALUES ('literal-markers','Saved',1);").unwrap(); + for (index, role) in ["user", "assistant"].iter().enumerate() { + let id = format!("literal-{role}"); + db.execute( + "INSERT INTO message VALUES (?1,'literal-markers',?2,?3)", + rusqlite::params![id, index, json!({"role":role}).to_string()], + ) + .unwrap(); + db.execute( + "INSERT INTO part VALUES (?1,'literal-markers',?2,0,?3)", + rusqlite::params![ + format!("{id}-text"), + id, + json!({"type":"text","text":literal}).to_string() + ], + ) + .unwrap(); + } + db.execute("INSERT INTO part VALUES ('literal-tool','literal-markers','literal-assistant',1,?1)", [json!({"type":"tool","callID":"literal-call","tool":"echo","state":{"status":"completed","input":{"saved":literal},"output":literal}}).to_string()]).unwrap(); + } else { + let dir = home.path().join(".claude/projects/saved"); + std::fs::create_dir_all(&dir).unwrap(); + let rows = [ + json!({"type":"user","uuid":"literal-user","message":{"content":[{"type":"text","text":literal}]}}), + json!({"type":"assistant","uuid":"literal-answer","message":{"content":[{"type":"text","text":literal},{"type":"tool_use","id":"literal-tool","name":"echo","input":{"saved":literal}}]}}), + json!({"type":"user","uuid":"literal-result","message":{"content":[{"type":"tool_result","tool_use_id":"literal-tool","content":literal}]}}), + ]; + std::fs::write( + dir.join("literal-markers.jsonl"), + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + } + let result = history(home.path(), provider, "literal-markers"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items: Vec<_> = body["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert_eq!( + items + .iter() + .filter(|item| item["kind"] == "text" && item["text"] == literal) + .count(), + 2, + "{provider}: user/answer literals changed" + ); + if provider == "claude" || provider == "kilroy" { + assert!(items + .iter() + .any(|item| item["kind"] == "tool_use" && item["input"]["saved"] == literal)); + assert!(items + .iter() + .any(|item| item["kind"] == "tool_result" && item["content"] == literal)); + } else { + let tool = items + .iter() + .find(|item| item["id"] == "literal-tool") + .unwrap(); + let arguments = if provider == "codex" { + serde_json::from_str::(tool["arguments"].as_str().unwrap()).unwrap() + } else { + tool["arguments"].clone() + }; + assert_eq!(arguments["saved"], literal); + assert_eq!( + if provider == "codex" { + &tool["contentItems"][0]["text"] + } else { + &tool["contentItems"][0] + }, + &literal + ); + } + let wire_provider = if provider == "kilroy" { + "claude" + } else { + provider + }; + assert_ne!( + body["extensions"][wire_provider]["nativeHistoryRetention"]["partial"], + true + ); + } +} + +#[test] +fn history_binary_retains_late_claude_array_blocks_and_original_indices() { + let home = tempfile::tempdir().unwrap(); + let dir = home.path().join(".claude/projects/saved"); + std::fs::create_dir_all(&dir).unwrap(); + let mut blocks: Vec = (0..32) + .map( + |i| json!({"type":"text","text":format!("Earlier block {i} {}", "é🚀".repeat(35_000))}), + ) + .collect(); + blocks.extend([ + json!({"type":"tool_use","id":"late-tool-a","name":"echo","input":{"value":"late input"}}), + json!({"type":"tool_use","id":"late-tool-b","name":"pwd","input":{}}), + json!({"type":"text","text":"Final saved array answer"}), + ]); + let row = json!({"type":"assistant","uuid":"array-answer","message":{"content":blocks}}); + std::fs::write(dir.join("array-claude.jsonl"), row.to_string()).unwrap(); + let result = history(home.path(), "claude", "array-claude"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items = body["turns"][0]["items"].as_array().unwrap(); + assert!( + items.last().unwrap()["text"] == "Final saved array answer", + "latest saved answer is missing" + ); + assert_eq!(items.last().unwrap()["id"], "array-answer-i34"); + for (index, tool) in [(32, "late-tool-a"), (33, "late-tool-b")] { + assert!(items.iter().any( + |item| item["id"] == format!("array-answer-i{index}") && item["toolUseId"] == tool + )); + } + assert_eq!( + body["extensions"]["claude"]["nativeHistoryRetention"]["partial"], + true + ); + assert!((result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES); +} + +#[test] +fn history_binary_compacts_one_large_structured_claude_block_before_array_eviction() { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".claude/projects/saved"); + std::fs::create_dir_all(&directory).unwrap(); + let input: serde_json::Map = (0..16_000) + .map(|index| { + ( + format!("field-{index:05}"), + json!("saved value ".repeat(110)), + ) + }) + .collect(); + let row = json!({"type":"assistant","uuid":"structured-answer","message":{"content":[ + {"type":"tool_use","id":"structured-tool","name":"Write","input":input}, + {"type":"tool_use","id":"recent-tool","name":"pwd","input":{}}, + {"type":"text","text":"Latest answer after structured input"} + ]}}); + let path = directory.join("structured-claude.jsonl"); + let source = row.to_string(); + assert!(source.len() as u64 > freshell_freshagent::native_history::MAX_HISTORY_BYTES); + std::fs::write(&path, &source).unwrap(); + let result = history(home.path(), "claude", "structured-claude"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items = body["turns"][0]["items"].as_array().unwrap(); + for (index, tool) in [(0, "structured-tool"), (1, "recent-tool")] { + assert!( + items + .iter() + .any(|item| item["id"] == format!("structured-answer-i{index}") + && item["toolUseId"] == tool), + "selected native call {tool} was displaced by its body" + ); + } + assert_eq!( + items.last().unwrap()["text"], + "Latest answer after structured input" + ); + assert_eq!(items.last().unwrap()["id"], "structured-answer-i2"); + assert_eq!( + body["extensions"]["claude"]["nativeHistoryRetention"]["partial"], + true + ); + assert!( + (result.stdout.len() as u64) + < freshell_freshagent::native_history::MAX_HISTORY_BYTES / 4 + 4096 + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), source); +} + +#[test] +fn history_binary_retains_late_codex_array_prompt_answer_and_tool_outputs() { + let home = tempfile::tempdir().unwrap(); + let parts = |kind: &str, tail: &str| { + let mut parts: Vec = (0..32).map(|i| json!({"type":kind,"text":format!("Earlier part {i} {}", "é🚀".repeat(35_000))})).collect(); + parts.push(json!({"type":kind,"text":tail})); + parts + }; + let rows = vec![ + json!({"type":"session_meta","payload":{"id":"array-codex"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"array-task"}}), + json!({"type":"response_item","payload":{"type":"message","id":"array-user","role":"user","content":parts("input_text","Final saved array prompt")}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"array-tool-a","name":"echo","arguments":"{}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"array-tool-a","output":parts("input_text","Final saved array output")}}), + json!({"type":"response_item","payload":{"type":"custom_tool_call","call_id":"array-tool-b","name":"pwd","input":"pwd"}}), + json!({"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"array-tool-b","output":"/saved/workspace"}}), + json!({"type":"response_item","payload":{"type":"message","id":"array-assistant","role":"assistant","content":parts("output_text","Final saved array answer")}}), + ]; + write_codex_rows(home.path(), "array-codex", &rows); + let result = history(home.path(), "codex", "array-codex"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + let items: Vec<_> = turns + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert!(items.iter().any( + |item| item["id"] == "array-user:part:32" && item["text"] == "Final saved array prompt" + )); + assert!(items.iter().any(|item| item["id"] == "array-assistant" + && item["text"] + .as_str() + .is_some_and(|text| text.ends_with("Final saved array answer")))); + assert!(body["turns"] + .to_string() + .contains("Final saved array output")); + for tool in ["array-tool-a", "array-tool-b"] { + assert!(items.iter().any(|item| item["id"] == tool)); + } + assert_eq!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); + assert!((result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES); +} + +#[test] +fn history_binary_deduplicates_array_mirrors_across_repeated_native_tasks() { + use std::io::Write; + for response_first in [false, true] { + let home = tempfile::tempdir().unwrap(); + let parts = |kind: &str, last: &str| { + let mut parts: Vec = (0..24) + .map(|i| json!({"type":kind,"text":format!("Part {i} {}", "é🚀".repeat(30_000))})) + .collect(); + parts.push(json!({"type":kind,"text":last})); + parts + }; + let user = parts("input_text", "Repeated latest array prompt"); + let assistant = parts("output_text", "Repeated latest array answer"); + let joined = |parts: &[Value]| { + parts + .iter() + .map(|part| part["text"].as_str().unwrap()) + .collect::>() + .join("\n") + }; + let mut rows = vec![json!({"type":"session_meta","payload":{"id":"array-mirrors"}})]; + for index in 0..2 { + rows.push(json!({"type":"event_msg","payload":{"type":"task_started","turn_id":format!("array-task-{index}")}})); + for (role, content, event_type) in [ + ("user", &user, "user_message"), + ("assistant", &assistant, "agent_message"), + ] { + let response = json!({"type":"response_item","payload":{"type":"message","id":format!("array-{role}-{index}"),"role":role,"content":content}}); + let event = json!({"type":"event_msg","payload":{"type":event_type,"message":joined(content)}}); + if response_first { + rows.extend([response, event]); + } else { + rows.extend([event, response]); + } + } + rows.push(json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":format!("array-task-{index}"),"last_agent_message":joined(&assistant)}})); + } + let directory = home.path().join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("rollout-array-mirrors.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + for row in &rows { + let text = if row["type"] == "response_item" { + row.to_string() + .replace('é', "\\u00e9") + .replace('🚀', "\\ud83d\\ude80") + } else { + row.to_string() + }; + writeln!(file, "{text}").unwrap(); + } + drop(file); + let source = std::fs::read(&path).unwrap(); + let result = history(home.path(), "codex", "array-mirrors"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 4, "response first {response_first}"); + for index in 0..2 { + let user = &turns[index * 2]; + let assistant = &turns[index * 2 + 1]; + assert_eq!(user["turnId"], format!("array-task-{index}:row-0")); + assert_eq!(assistant["turnId"], format!("array-task-{index}:row-1")); + assert_eq!( + user["items"].as_array().unwrap().last().unwrap()["id"], + format!("array-user-{index}:part:24") + ); + assert_eq!( + user["items"].as_array().unwrap().last().unwrap()["text"], + "Repeated latest array prompt" + ); + assert_eq!(assistant["items"].as_array().unwrap().len(), 1); + assert_eq!( + assistant["items"][0]["id"], + format!("array-assistant-{index}") + ); + assert!(assistant["items"][0]["text"] + .as_str() + .unwrap() + .ends_with("Repeated latest array answer")); + } + assert_eq!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); + assert!( + (result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES + ); + assert_eq!(std::fs::read(&path).unwrap(), source); + } +} + +#[test] +fn history_binary_matches_empty_and_nontext_parts_to_event_copies() { + let home = tempfile::tempdir().unwrap(); + write_codex_rows( + home.path(), + "mixed-parts", + &[ + json!({"type":"session_meta","payload":{"id":"mixed-parts"}}), + json!({"type":"turn_context","payload":{"turn_id":"mixed-task"}}), + json!({"type":"response_item","payload":{"type":"message","id":"mixed-user","role":"user","content":[{"type":"input_text","text":""},{"type":"input_image","image_url":"saved://image"},{"type":"input_text","text":null},{"type":"input_text","text":"Saved mixed prompt"}]}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"\nSaved mixed prompt"}}), + json!({"type":"response_item","payload":{"type":"message","id":"mixed-assistant","role":"assistant","content":[{"type":"output_text","text":null},{"type":"output_text","text":""},{"type":"output_text","text":"Saved mixed answer"}]}}), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"\nSaved mixed answer"}}), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"mixed-task","last_agent_message":"\nSaved mixed answer"}}), + ], + ); + let result = history(home.path(), "codex", "mixed-parts"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0]["items"].as_array().unwrap().len(), 4); + assert_eq!(turns[0]["items"][3]["id"], "mixed-user:part:3"); + assert_eq!(turns[0]["items"][3]["text"], "Saved mixed prompt"); + assert_eq!(turns[1]["items"].as_array().unwrap().len(), 1); + assert_eq!(turns[1]["items"][0]["id"], "mixed-assistant"); + assert_eq!(turns[1]["items"][0]["text"], "\nSaved mixed answer"); +} + +#[test] +fn history_binary_restores_a_message_mirror_after_large_patch_retention() { + for response_first in [false, true] { + let home = tempfile::tempdir().unwrap(); + let answer = format!( + "{}Saved answer after a large patch", + "Earlier saved answer ".repeat(15_000) + ); + let response = codex_response_message("assistant", 0, &answer); + let event = json!({"type":"event_msg","payload":{"type":"agent_message","message":answer}}); + // PatchApplyEnd uses this map shape in the existing legacy-event regression. + let changes: serde_json::Map = (0..1280).map(|index| (format!("/workspace/file-{index:04}.rs"), json!({"type":"update","unified_diff":"+saved change\n".repeat(300),"move_path":null}))).collect(); + let rows = vec![ + json!({"type":"session_meta","payload":{"id":"retained-mirrors"}}), + json!({"type":"turn_context","payload":{"turn_id":"patch-task"}}), + codex_response_message("user", 0, "Saved patch request"), + if response_first { + response.clone() + } else { + event.clone() + }, + json!({"type":"event_msg","payload":{"type":"patch_apply_end","call_id":"large-patch","success":true,"status":"completed","stdout":"Saved patch","stderr":"","changes":changes}}), + if response_first { event } else { response }, + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"patch-task","last_agent_message":answer}}), + ]; + write_codex_rows(home.path(), "retained-mirrors", &rows); + let result = history(home.path(), "codex", "retained-mirrors"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + let answers: Vec<_> = turns + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .filter(|item| item["id"] == "assistant-0") + .collect(); + assert_eq!(answers.len(), 1, "later canonical answer was lost after earlier copy eviction; response first {response_first}"); + assert!(answers[0]["text"] + .as_str() + .unwrap() + .ends_with("Saved answer after a large patch")); + assert_eq!(turns.last().unwrap()["turnId"], "patch-task:row-3"); + assert_eq!(body["threadId"], "retained-mirrors"); + assert_eq!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); + assert!( + (result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES + ); + } +} + +#[test] +fn history_binary_reads_supported_codex_task_event_transcript() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("rollout-2026-10-03-session-activity.jsonl"), + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl"), + ) + .unwrap(); + let result = history(home.path(), "codex", "session-activity"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["threadId"], "session-activity"); + assert_eq!(body["turns"].as_array().unwrap().len(), 2); + assert_eq!(body["turns"][0]["role"], "user"); + assert_eq!(body["turns"][0]["items"][0]["text"], "Sanitized prompt"); + assert_eq!(body["turns"][1]["role"], "assistant"); + assert_eq!(body["turns"][1]["items"][0]["text"], "Sanitized completion"); + assert_eq!(body["capabilities"]["send"], false); +} + +#[test] +fn history_binary_reads_interrupted_codex_agent_message_events() { + let home = tempfile::tempdir().unwrap(); + let mut rows = vec![json!({"type":"session_meta","payload":{"id":"interrupted-events"}})]; + for turn in 0..2 { + rows.extend([ + json!({"type":"turn_context","payload":{"turn_id":format!("interrupted-turn-{turn}")}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated interrupted prompt"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":format!("interrupted-turn-{turn}")}}), + // AgentMessageEvent has a message and optional phase; it need not have a turn id. + json!({"type":"event_msg","payload":{"type":"agent_message","message":"Saved answer before interruption","phase":"commentary"}}), + json!({"type":"event_msg","payload":{"type":"turn_aborted","turn_id":format!("interrupted-turn-{turn}"),"reason":"interrupted"}}), + ]); + } + write_codex_rows(home.path(), "interrupted-events", &rows); + let result = history(home.path(), "codex", "interrupted-events"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 4); + for (index, turn) in turns.iter().enumerate() { + assert_eq!( + turn["role"], + if index % 2 == 0 { "user" } else { "assistant" } + ); + assert_eq!(turn["items"].as_array().unwrap().len(), 1); + assert_eq!( + turn["items"][0]["text"], + if index % 2 == 0 { + "Repeated interrupted prompt" + } else { + "Saved answer before interruption" + } + ); + } + assert_eq!(body["capabilities"]["send"], false); +} + +#[test] +fn history_binary_deduplicates_codex_response_agent_and_completion_mirrors() { + for order in [ + &[0, 1, 2][..], + &[0, 2, 1][..], + &[1, 0, 2][..], + &[1, 2, 0][..], + &[2, 0, 1][..], + &[2, 1, 0][..], + &[1, 2][..], + &[2, 1][..], + ] { + let home = tempfile::tempdir().unwrap(); + let mut rows = + vec![json!({"type":"session_meta","payload":{"id":"three-message-formats"}})]; + for turn in 0..2 { + rows.extend([ + json!({"type":"turn_context","payload":{"turn_id":format!("triple-turn-{turn}")}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}), + ]); + let messages = [ + json!({"type":"response_item","payload":{"type":"message","role":"assistant","id":format!("assistant-{turn}"), + "content":[{"type":"output_text","text":"Repeated saved answer"}]}}), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"Repeated saved answer","phase":"final"}}), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":format!("triple-turn-{turn}"),"last_agent_message":"Repeated saved answer"}}), + ]; + for &index in order { + rows.push(messages[index].clone()); + } + } + write_codex_rows(home.path(), "three-message-formats", &rows); + let result = history(home.path(), "codex", "three-message-formats"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 4, "order {order:?}"); + for turn in 0..2 { + let assistant = &turns[turn * 2 + 1]; + assert_eq!(assistant["role"], "assistant"); + assert_eq!( + assistant["items"].as_array().unwrap().len(), + 1, + "order {order:?}" + ); + assert_eq!(assistant["items"][0]["text"], "Repeated saved answer"); + if order.contains(&0) { + assert_eq!(assistant["items"][0]["id"], format!("assistant-{turn}")); + } + } + } +} + +#[test] +fn history_binary_associates_codex_message_mirrors_across_turn_context() { + for modern_first in [false, true] { + for context_first in [false, true] { + for context_has_id in [true, false] { + for (start_position, start_has_id) in [ + ("none", false), + ("before", false), + ("before", true), + ("after", false), + ("after", true), + ] { + for order in [ + &[0, 1, 2][..], + &[0, 2, 1][..], + &[1, 0, 2][..], + &[1, 2, 0][..], + &[2, 0, 1][..], + &[2, 1, 0][..], + &[1, 2][..], + &[2, 1][..], + ] { + let home = tempfile::tempdir().unwrap(); + let mut rows = vec![ + json!({"type":"session_meta","payload":{"id":"context-mirrors","history_mode":"legacy"}}), + ]; + for index in 0..2 { + let id = format!("saved-turn-{index}"); + let mut context = + json!({"type":"turn_context","payload":{"model":"saved-model"}}); + if context_has_id { + context["payload"]["turn_id"] = json!(id); + } + let mut started = + json!({"type":"event_msg","payload":{"type":"task_started"}}); + if start_has_id { + started["payload"]["turn_id"] = json!(id); + } + let legacy = json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}); + let response = + codex_response_message("user", index, "Repeated saved prompt"); + if context_first { + rows.push(context.clone()); + } + if start_position == "before" { + rows.push(started.clone()); + } + rows.push(if modern_first { + response.clone() + } else { + legacy.clone() + }); + if start_position == "after" { + rows.push(started); + } + if !context_first { + rows.push(context); + } + rows.push(if modern_first { legacy } else { response }); + let mut completed = json!({"type":"event_msg","payload":{"type":"task_complete","last_agent_message":"Repeated saved answer"}}); + if context_has_id { + completed["payload"]["turn_id"] = json!(id); + } + let messages = [ + codex_response_message("assistant", index, "Repeated saved answer"), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"Repeated saved answer","phase":"final"}}), + completed, + ]; + for &source in order { + rows.push(messages[source].clone()); + } + } + write_codex_rows(home.path(), "context-mirrors", &rows); + let result = history(home.path(), "codex", "context-mirrors"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + let case = format!("modern_first={modern_first}, context_first={context_first}, context_has_id={context_has_id}, start={start_position}/{start_has_id}, sources={order:?}"); + assert_eq!(turns.len(), 4, "{case}: {body}"); + assert_ne!( + turns[0]["turnId"], turns[2]["turnId"], + "distinct completed turns: {case}" + ); + for (index, turn) in turns.iter().enumerate() { + assert_eq!( + turn["role"], + if index % 2 == 0 { "user" } else { "assistant" }, + "{case}" + ); + assert_eq!(turn["items"].as_array().unwrap().len(), 1, "{case}"); + assert_eq!( + turn["items"][0]["text"], + if index % 2 == 0 { + "Repeated saved prompt" + } else { + "Repeated saved answer" + }, + "{case}" + ); + if index % 2 == 0 || order.contains(&0) { + assert_eq!( + turn["items"][0]["id"], + if index % 2 == 0 { + format!("user-{}:part:0", index / 2) + } else { + format!("assistant-{}", index / 2) + }, + "{case}" + ); + } + let saved_id = + if context_has_id || (start_position != "none" && start_has_id) { + format!("saved-turn-{}", index / 2) + } else { + format!("native-history-{}", index / 2) + }; + assert_eq!( + turn["turnId"] + .as_str() + .unwrap() + .split(":row-") + .next() + .unwrap(), + saved_id, + "{case}" + ); + } + assert_eq!(body["capabilities"]["send"], false); + } + } + } + } + } +} + +#[test] +fn history_binary_associates_resumed_input_after_an_abrupt_codex_turn() { + let fixture: Vec = + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let task_started = |id: Option<&str>| { + let mut row = fixture[2].clone(); + let payload = row["payload"].as_object_mut().unwrap(); + payload.remove("turn_id"); + if let Some(id) = id { + payload.insert("turn_id".into(), json!(id)); + } + row + }; + for progress in ["assistant", "tool", "both", "input_only"] { + for prior_has_id in [true, false] { + for same_prompt in [false, true] { + for modern_first in [false, true] { + for context_first in [false, true] { + for start_has_id in [true, false] { + for context_has_id in [true, false] { + let home = tempfile::tempdir().unwrap(); + // Keep the supported fixture's user-before-task-start order, + // then simulate an exit before any completion or abort record. + let mut rows = fixture[..3].to_vec(); + rows[2] = task_started(prior_has_id.then_some("turn-1")); + rows.extend([ + if prior_has_id { + json!({"type":"turn_context","payload":{"turn_id":"turn-1"}}) + } else { + json!({"type":"turn_context","payload":{"model":"saved-model"}}) + }, + codex_response_message("user", 0, "Sanitized prompt"), + ]); + if progress == "tool" || progress == "both" { + rows.push(json!({"type":"response_item","payload":{"type":"custom_tool_call", + "call_id":"interrupted-tool","name":"apply_patch","input":"Saved unfinished patch"}})); + } + if progress == "assistant" || progress == "both" { + rows.extend([ + codex_response_message("assistant", 0, "Saved partial answer"), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"Saved partial answer","phase":"commentary"}}), + ]); + } + let prompt = if same_prompt { + "Sanitized prompt" + } else { + "Resumed prompt" + }; + let mut user = fixture[1].clone(); + user["payload"]["message"] = json!(prompt); + let response = codex_response_message("user", 1, prompt); + rows.push(if modern_first { + response.clone() + } else { + user.clone() + }); + let started = task_started(start_has_id.then_some("turn-2")); + let context = if context_has_id { + json!({"type":"turn_context","payload":{"turn_id":"turn-2"}}) + } else { + json!({"type":"turn_context","payload":{"model":"saved-model"}}) + }; + rows.extend(if context_first { + [context, started] + } else { + [started, context] + }); + rows.push(if modern_first { user } else { response }); + rows.push(codex_response_message( + "assistant", + 1, + "Saved resumed answer", + )); + write_codex_rows(home.path(), "session-activity", &rows); + let result = history(home.path(), "codex", "session-activity"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + let case = format!("progress={progress}, prior_id={prior_has_id}, same_prompt={same_prompt}, modern_first={modern_first}, context_first={context_first}, start_id={start_has_id}, context_id={context_has_id}"); + let prior_id = if prior_has_id { + "turn-1" + } else { + "native-history-0" + }; + let mut expected = + vec![("user", prior_id, "user-0:part:0", "Sanitized prompt")]; + if progress == "tool" || progress == "both" { + expected.push(("tool", prior_id, "interrupted-tool", "")); + } + if progress == "assistant" || progress == "both" { + expected.push(( + "assistant", + prior_id, + "assistant-0", + "Saved partial answer", + )); + } + let resumed_id = if start_has_id || context_has_id { + "turn-2" + } else { + "native-history-1" + }; + expected.extend([ + ("user", resumed_id, "user-1:part:0", prompt), + ( + "assistant", + resumed_id, + "assistant-1", + "Saved resumed answer", + ), + ]); + assert_eq!(turns.len(), expected.len(), "{case}: {body}"); + for (turn, (role, id, item_id, text)) in turns.iter().zip(expected) + { + assert_eq!(turn["role"], role, "{case}"); + assert_eq!( + turn["items"].as_array().unwrap().len(), + 1, + "{case}" + ); + assert_eq!(turn["items"][0]["id"], item_id, "{case}"); + assert_eq!( + turn["turnId"] + .as_str() + .unwrap() + .split(":row-") + .next() + .unwrap(), + id, + "native task association: {case}: {body}" + ); + if role == "tool" { + assert_eq!(turn["items"][0]["status"], "running", "{case}"); + assert_eq!( + turn["items"][0]["arguments"], "Saved unfinished patch", + "{case}" + ); + } else { + assert_eq!(turn["items"][0]["text"], text, "{case}"); + } + } + assert_eq!(body["capabilities"]["send"], false); + } + } + } + } + } + } + } +} + +#[test] +fn history_binary_binds_legacy_resumed_prompt_after_an_unfinished_codex_task() { + let fixture: Vec = + include_str!("../../../test/fixtures/coding-cli/codex/task-events.sanitized.jsonl") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for same_prompt in [false, true] { + for prior_has_id in [false, true] { + for next_has_id in [false, true] { + let home = tempfile::tempdir().unwrap(); + let mut rows = fixture[..3].to_vec(); + if !prior_has_id { + rows[2]["payload"] + .as_object_mut() + .unwrap() + .remove("turn_id"); + } + rows.push(json!({"type":"event_msg","payload":{"type":"agent_message","message":"Unfinished legacy answer","phase":"commentary"}})); + let prompt = if same_prompt { + "Sanitized prompt" + } else { + "Resumed legacy prompt" + }; + let mut resumed = fixture[1].clone(); + resumed["payload"]["message"] = json!(prompt); + let mut started = fixture[2].clone(); + let mut completed = fixture[4].clone(); + for row in [&mut started, &mut completed] { + if next_has_id { + row["payload"]["turn_id"] = json!("turn-2"); + } else { + row["payload"].as_object_mut().unwrap().remove("turn_id"); + } + } + rows.extend([resumed, started, completed]); + write_codex_rows(home.path(), "session-activity", &rows); + let result = history(home.path(), "codex", "session-activity"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + let case = format!( + "same_prompt={same_prompt}, prior_id={prior_has_id}, next_id={next_has_id}" + ); + assert_eq!(turns.len(), 4, "{case}: {body}"); + let prior_id = if prior_has_id { + "turn-1" + } else { + "native-history-0" + }; + let next_id = if next_has_id { + "turn-2" + } else { + "native-history-1" + }; + for (turn, (role, id, item_id, text)) in turns.iter().zip([ + ("user", prior_id, "native-line-1:part:0", "Sanitized prompt"), + ( + "assistant", + prior_id, + "native-line-3", + "Unfinished legacy answer", + ), + ("user", next_id, "native-line-4:part:0", prompt), + ( + "assistant", + next_id, + "native-line-6", + "Sanitized completion", + ), + ]) { + assert_eq!(turn["role"], role, "{case}"); + assert_eq!( + turn["turnId"] + .as_str() + .unwrap() + .split(":row-") + .next() + .unwrap(), + id, + "{case}" + ); + assert_eq!(turn["items"].as_array().unwrap().len(), 1, "{case}"); + assert_eq!(turn["items"][0]["id"], item_id, "{case}"); + assert_eq!(turn["items"][0]["text"], text, "{case}"); + } + assert_eq!(body["capabilities"]["send"], false); + } + } + } +} + +#[test] +fn history_binary_preserves_codex_task_boundaries_without_context_or_completion_text() { + for end in ["task_complete", "turn_aborted", "next_task_started"] { + for has_id in [false, true] { + let home = tempfile::tempdir().unwrap(); + let mut rows = vec![ + json!({"type":"session_meta","payload":{"id":"task-boundaries","history_mode":"legacy"}}), + ]; + for index in 0..2 { + let id = format!("task-{index}"); + let mut started = json!({"type":"event_msg","payload":{"type":"task_started"}}); + if has_id { + started["payload"]["turn_id"] = json!(id); + } + rows.extend([ + started, + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}), + codex_response_message("user", index, "Repeated saved prompt"), + codex_response_message("assistant", index, "Repeated saved answer"), + json!({"type":"event_msg","payload":{"type":"agent_message","message":"Repeated saved answer"}}), + ]); + if end != "next_task_started" { + let mut completed = json!({"type":"event_msg","payload":{"type":end,"last_agent_message":null}}); + if has_id { + completed["payload"]["turn_id"] = json!(id); + } + rows.push(completed); + } + } + write_codex_rows(home.path(), "task-boundaries", &rows); + let result = history(home.path(), "codex", "task-boundaries"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 4, "end={end}, id={has_id}: {body}"); + assert_ne!( + turns[0]["turnId"], turns[2]["turnId"], + "distinct tasks: end={end}, id={has_id}" + ); + for (index, turn) in turns.iter().enumerate() { + assert_eq!(turn["items"].as_array().unwrap().len(), 1); + assert_eq!( + turn["items"][0]["id"], + if index % 2 == 0 { + format!("user-{}:part:0", index / 2) + } else { + format!("assistant-{}", index / 2) + } + ); + let saved_id = if has_id { + format!("task-{}", index / 2) + } else { + format!("native-history-{}", index / 2) + }; + assert_eq!( + turn["turnId"] + .as_str() + .unwrap() + .split(":row-") + .next() + .unwrap(), + saved_id, + "end={end}, id={has_id}" + ); + } + } + } +} + +#[test] +fn history_binary_keeps_delayed_codex_messages_and_tools_in_their_named_turn() { + for next_has_id in [false, true] { + for completion_has_text in [false, true] { + let home = tempfile::tempdir().unwrap(); + let mut next = json!({"type":"event_msg","payload":{"type":"task_started"}}); + if next_has_id { + next["payload"]["turn_id"] = json!("named-1"); + } + let rows = [ + json!({"type":"session_meta","payload":{"id":"delayed-turn","history_mode":"legacy"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"named-0"}}), + json!({"type":"turn_context","payload":{"turn_id":"named-0"}}), + codex_response_message("user", 0, "Repeated saved prompt"), + json!({"type":"response_item","payload":{"type":"custom_tool_call","call_id":"custom-0","name":"apply_patch","input":"saved patch"}}), + codex_response_message("assistant", 0, "Repeated saved answer"), + next, + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"named-0", + "last_agent_message": if completion_has_text { json!("Repeated saved answer") } else { Value::Null }}}), + json!({"type":"event_msg","payload":{"type":"item_completed","turn_id":"named-0", + "item":{"type":"DynamicToolCall","id":"custom-0","tool":"apply_patch","arguments":"saved patch","status":"completed", + "content_items":[{"type":"inputText","text":"Delayed saved tool result"}],"success":true}}}), + json!({"type":"turn_context","payload":{"turn_id":"named-1"}}), + codex_response_message("user", 1, "Repeated saved prompt"), + codex_response_message("assistant", 1, "Repeated saved answer"), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"named-1","last_agent_message":"Repeated saved answer"}}), + ]; + write_codex_rows(home.path(), "delayed-turn", &rows); + let result = history(home.path(), "codex", "delayed-turn"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!( + turns.len(), + 5, + "next_id={next_has_id}, completion_text={completion_has_text}: {body}" + ); + for (turn, (role, source)) in turns.iter().zip([ + ("user", 0), + ("tool", 0), + ("assistant", 0), + ("user", 1), + ("assistant", 1), + ]) { + assert_eq!(turn["role"], role); + let items = turn["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!( + items[0]["id"], + if role == "tool" { + "custom-0".to_owned() + } else if role == "user" { + format!("user-{source}:part:0") + } else { + format!("assistant-{source}") + } + ); + assert_eq!( + turn["turnId"] + .as_str() + .unwrap() + .split(":row-") + .next() + .unwrap(), + format!("named-{source}") + ); + } + let tool = turns[1]["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["kind"] == "dynamic_tool") + .unwrap(); + assert_eq!(tool["id"], "custom-0"); + assert_eq!(tool["status"], "completed"); + assert_eq!(tool["contentItems"][0]["text"], "Delayed saved tool result"); + } + } +} + +#[test] +fn history_binary_deduplicates_codex_message_mirrors_in_either_record_order() { + for modern_first in [false, true] { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&root).unwrap(); + let mut rows = vec![json!({"type":"session_meta","payload":{"id":"mixed-messages"}})]; + for turn in 0..4 { + rows.push( + json!({"type":"turn_context","payload":{"turn_id":format!("mixed-turn-{turn}")}}), + ); + for (role, event_type, text_key, text) in [ + ("user", "user_message", "message", "Repeated saved prompt"), + ( + "assistant", + "task_complete", + "last_agent_message", + "Repeated saved answer", + ), + ] { + let modern = json!({"type":"response_item","payload":{"type":"message","role":role, + "id":format!("{role}-{turn}"),"content":[{"type":if role == "user" {"input_text"} else {"output_text"},"text":text}]}}); + let legacy = json!({"type":"event_msg","payload":{"type":event_type, + "turn_id":format!("mixed-turn-{turn}"),text_key:text}}); + if turn == 2 { + rows.push(legacy); + } else if turn == 3 { + rows.push(modern); + } else if modern_first { + rows.extend([modern, legacy]); + } else { + rows.extend([legacy, modern]); + } + } + } + std::fs::write( + root.join("rollout-2026-10-03-mixed-messages.jsonl"), + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + let result = history(home.path(), "codex", "mixed-messages"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!(turns.len(), 8); + for (index, turn) in turns.iter().enumerate() { + assert_eq!( + turn["role"], + if index % 2 == 0 { "user" } else { "assistant" } + ); + assert_eq!(turn["items"].as_array().unwrap().len(), 1); + assert_eq!( + turn["items"][0]["text"], + if index % 2 == 0 { + "Repeated saved prompt" + } else { + "Repeated saved answer" + } + ); + } + } +} + +#[test] +fn history_binary_reads_large_sources_with_small_retained_conversations() { + use std::io::Write; + for provider in ["codex", "claude"] { + let home = tempfile::tempdir().unwrap(); + let path = if provider == "codex" { + rollout(home.path(), "large-thread", "Early saved answer"); + home.path() + .join(".codex/sessions/2026/10/03/rollout-2026-10-03-large-thread.jsonl") + } else { + let directory = home.path().join(".claude/projects/workspace"); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("large-thread.jsonl"); + std::fs::write(&path, format!("{}\n", json!({"type":"assistant","uuid":"early-answer","message":{"content":"Early saved answer"}}))).unwrap(); + path + }; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!(file).unwrap(); + let progress = + json!({"type":"progress","data":"ignored progress".repeat(2048)}).to_string(); + for _ in 0..600 { + writeln!(file, "{progress}").unwrap(); + } + if provider == "codex" { + writeln!(file,"{}",json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"native-turn","last_agent_message":"Early saved answer"}})).unwrap(); + for row in [ + json!({"type":"turn_context","payload":{"turn_id":"latest-turn"}}), + codex_response_message("user", 2, "Latest saved prompt"), + codex_response_message("assistant", 2, "Latest saved answer"), + ] { + writeln!(file, "{row}").unwrap(); + } + } else { + writeln!(file,"{}",json!({"type":"user","uuid":"latest-prompt","message":{"content":"Latest saved prompt"}})).unwrap(); + writeln!(file,"{}",json!({"type":"assistant","uuid":"latest-answer","message":{"content":"Latest saved answer"}})).unwrap(); + } + drop(file); + let original = std::fs::read(&path).unwrap(); + assert!(original.len() as u64 > freshell_freshagent::native_history::MAX_HISTORY_BYTES); + let result = history(home.path(), provider, "large-thread"); + assert!( + result.status.success(), + "{provider}: {}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let text = body["turns"].to_string(); + assert!(text.contains("Early saved answer")); + assert!(text.contains("Latest saved prompt")); + assert!(text.contains("Latest saved answer")); + assert_ne!( + body["extensions"][provider]["nativeHistoryRetention"]["partial"], + true + ); + assert_eq!(body["capabilities"]["send"], false); + assert_eq!(std::fs::read(&path).unwrap(), original); + } +} + +#[test] +fn history_binary_bounds_large_codex_display_without_losing_recent_turns_or_tools() { + let home = tempfile::tempdir().unwrap(); + let mut rows = vec![json!({"type":"session_meta","payload":{"id":"large-display"}})]; + let answer = "Large saved answer \n".repeat(8000); + for index in 0..160 { + rows.extend([json!({"type":"event_msg","payload":{"type":"task_started","turn_id":format!("turn-{index}")}}), + codex_response_message("user", index, "Repeated saved prompt"), + codex_response_message("assistant", index, &answer), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":format!("turn-{index}"),"last_agent_message":answer}})]); + } + rows.extend([json!({"type":"turn_context","payload":{"turn_id":"latest-tool-turn"}}), + codex_response_message("user", 160, "Latest saved prompt"), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"latest-tool","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"latest-tool","output":"/workspace"}}), + codex_response_message("assistant", 160, "Latest saved answer")]); + rows.push(json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-0","last_agent_message":"Older delayed answer"}})); + write_codex_rows(home.path(), "large-display", &rows); + let result = history(home.path(), "codex", "large-display"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!((result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let retention = &body["extensions"]["codex"]["nativeHistoryRetention"]; + assert_eq!(retention["partial"], true); + assert!(retention["omittedNativeTurns"].as_u64().unwrap() > 0); + let turns = body["turns"].as_array().unwrap(); + assert!(turns.len() > 4); + assert!(turns + .to_vec() + .iter() + .any(|turn| turn["items"][0]["id"] == "latest-tool" + && turn["items"][0]["kind"] == "dynamic_tool")); + assert!(body["turns"].to_string().contains("/workspace")); + assert_eq!( + turns.last().unwrap()["items"][0]["text"], + "Latest saved answer" + ); + assert!(turns + .iter() + .any(|turn| turn["items"][0]["id"] == "user-159:part:0")); + assert!(turns + .iter() + .any(|turn| turn["items"][0]["id"] == "assistant-159")); +} + +#[test] +fn history_binary_preserves_oversized_codex_task_mirrors_and_subsequent_identical_input() { + let home = tempfile::tempdir().unwrap(); + let huge = "Repeated saved answer é 🚀\n".repeat(650_000); + let rows = vec![ + json!({"type":"session_meta","payload":{"id":"oversized-task"}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"huge-turn"}}), + codex_response_message("user", 0, "Repeated saved prompt"), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"huge-tool","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"huge-tool","output":huge}}), + json!({"type":"event_msg","payload":{"type":"agent_message","message":huge}}), + codex_response_message("assistant", 0, &huge), + json!({"type":"event_msg","payload":{"type":"task_complete","turn_id":"huge-turn","last_agent_message":huge}}), + json!({"type":"event_msg","payload":{"type":"user_message","message":"Repeated saved prompt"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"next-turn"}}), + codex_response_message("user", 1, "Repeated saved prompt"), + codex_response_message("assistant", 1, "Latest saved answer"), + ]; + let directory = home.path().join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&directory).unwrap(); + let mut file = std::fs::File::create(directory.join("rollout-oversized-task.jsonl")).unwrap(); + use std::io::Write; + for row in &rows { + let text = if row["type"] == "response_item" && row["payload"]["role"] == "assistant" { + row.to_string() + .replace('é', "\\u00e9") + .replace('🚀', "\\ud83d\\ude80") + } else { + row.to_string() + }; + writeln!(file, "{text}").unwrap(); + } + drop(file); + let result = history(home.path(), "codex", "oversized-task"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert_eq!( + turns.iter().filter(|turn| turn["role"] == "user").count(), + 2 + ); + assert_eq!( + turns + .iter() + .filter(|turn| turn["items"][0]["id"] == "assistant-0") + .count(), + 1 + ); + assert_eq!( + turns + .iter() + .filter(|turn| turn["role"] == "assistant") + .count(), + 2 + ); + assert!(body["turns"].to_string().contains("huge-tool")); + assert_eq!( + turns.last().unwrap()["items"][0]["text"], + "Latest saved answer" + ); + assert_eq!( + body["extensions"]["codex"]["nativeHistoryRetention"]["partial"], + true + ); + assert!( + body["extensions"]["codex"]["nativeHistoryRetention"]["omittedBodies"] + .as_u64() + .unwrap() + > 0 + ); + assert!((result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES); +} + +#[test] +fn history_binary_retains_large_claude_and_kilroy_tools_with_original_ordinals() { + use std::io::Write; + for provider in ["claude", "kilroy"] { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".claude/projects/workspace"); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("large-claude.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + for _ in 0..100 { + writeln!( + file, + "{}", + json!({"type":"assistant","message":{"content":"Saved answer ".repeat(16_000)}}) + ) + .unwrap(); + } + for row in [ + json!({"type":"user","uuid":"latest-prompt","message":{"content":"Latest saved prompt"}}), + json!({"type":"assistant","uuid":"latest-invocation","message":{"content":[{"type":"tool_use","id":"huge-tool","name":"Read","input":{"file_path":"/workspace/saved.txt"}}]}}), + json!({"type":"user","uuid":"latest-result","message":{"content":[{"type":"tool_result","tool_use_id":"huge-tool","content":"Saved output ".repeat(1_500_000)}]}}), + json!({"type":"assistant","uuid":"latest-answer","message":{"content":"Latest saved answer"}}), + ] { + writeln!(file, "{row}").unwrap(); + } + drop(file); + let before = std::fs::read(&path).unwrap(); + let modified = std::fs::metadata(&path).unwrap().modified().unwrap(); + let result = history(home.path(), provider, "large-claude"); + assert!( + result.status.success(), + "{provider}: {}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let turns = body["turns"].as_array().unwrap(); + assert!(turns[0]["ordinal"].as_u64().unwrap() > 0); + assert_eq!( + turns[0]["id"], + format!("large-claude:{}", turns[0]["ordinal"]) + ); + let items: Vec<_> = turns + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert!(items + .iter() + .any(|item| item["kind"] == "tool_use" && item["toolUseId"] == "huge-tool")); + assert!(items.iter().any(|item| item["kind"] == "tool_result" + && item["toolUseId"] == "huge-tool" + && item["content"].as_str().is_some_and(|text| text + .starts_with("[Content omitted from retained history]") + && text.ends_with("Saved output ")))); + assert_eq!(body["latestTurnId"], "latest-answer"); + assert_eq!(turns.last().unwrap()["ordinal"], 103); + assert_eq!( + body["extensions"]["claude"]["nativeHistoryRetention"]["partial"], + true + ); + assert_eq!(body["capabilities"]["send"], false); + assert!( + (result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!( + std::fs::metadata(&path).unwrap().modified().unwrap(), + modified + ); + } +} + +#[test] +fn history_binary_bounds_large_opencode_active_and_reverted_history_including_wal() { + for (journal, keep_open) in [("DELETE", false), ("WAL", false), ("WAL", true)] { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".local/share/opencode"); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("opencode.db"); + let db = Connection::open(&path).unwrap(); + db.pragma_update(None, "journal_mode", journal).unwrap(); + db.execute_batch("CREATE TABLE session (id TEXT PRIMARY KEY,title TEXT,time_updated INTEGER,revert TEXT); + CREATE TABLE message (id TEXT PRIMARY KEY,session_id TEXT,time_created INTEGER,data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY,session_id TEXT,message_id TEXT,time_created INTEGER,data TEXT); + INSERT INTO session VALUES ('large-opencode','Saved title',2,'{\"messageID\":\"message-100\"}'); + INSERT INTO session VALUES ('foreign','Foreign',2,NULL);").unwrap(); + for ordinal in 0..200 { + let id = format!("message-{ordinal}"); + db.execute( + "INSERT INTO message VALUES (?1,'large-opencode',?2,?3)", + rusqlite::params![ + id, + ordinal, + json!({"role":if ordinal % 2 == 0 {"user"} else {"assistant"}}).to_string() + ], + ) + .unwrap(); + db.execute( + "INSERT INTO part VALUES (?1,'large-opencode',?2,0,?3)", + rusqlite::params![ + format!("part-{ordinal}"), + id, + json!({"type":"text","text":"Saved conversation ".repeat(10_000)}).to_string() + ], + ) + .unwrap(); + } + db.execute("INSERT INTO part VALUES ('huge-tool-part','large-opencode','message-199',1,?1)", [json!({"type":"tool","callID":"huge-call","tool":"bash","state":{"status":"completed","input":{"command":"pwd"},"output":"Saved output ".repeat(1_500_000)}}).to_string()]).unwrap(); + db.execute( + "INSERT INTO message VALUES ('foreign-message','foreign',0,'{\"role\":\"assistant\"}')", + [], + ) + .unwrap(); + db.execute("INSERT INTO part VALUES ('foreign-part','foreign','foreign-message',0,'{\"type\":\"text\",\"text\":\"Foreign history\"}')", []).unwrap(); + let writer = if keep_open { + Some(db) + } else { + drop(db); + None + }; + let before = std::fs::read(&path).unwrap(); + let wal_before = keep_open.then(|| std::fs::read(path.with_extension("db-wal")).unwrap()); + let result = history(home.path(), "opencode", "large-opencode"); + assert!( + result.status.success(), + "{journal} open {keep_open}: {}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let active = body["turns"].as_array().unwrap(); + let reverted = body["rolledBackTurns"].as_array().unwrap(); + assert_eq!(body["latestTurnId"], "message-99"); + assert_eq!(active.last().unwrap()["ordinal"], 99); + assert!(active[0]["ordinal"].as_u64().unwrap() > 0); + assert!( + reverted[0]["ordinal"].as_u64().unwrap() > 100, + "eviction must not forget the revert pointer" + ); + assert!(reverted + .iter() + .all(|turn| turn["rolledBack"] == true && turn["restorable"] == false)); + let tool_items = reverted.last().unwrap()["items"].as_array().unwrap(); + let tool = tool_items + .iter() + .find(|item| item["id"] == "huge-tool-part") + .unwrap(); + assert_eq!(tool["kind"], "dynamic_tool"); + assert_eq!(tool["tool"], "bash"); + assert_eq!(tool["status"], "completed"); + assert_eq!(tool["arguments"]["command"], "pwd"); + let output = tool["contentItems"][0].as_str().unwrap(); + assert!(output.starts_with("[Content omitted from retained history]")); + assert!(output.ends_with("Saved output ")); + assert_eq!(tool["success"], true); + assert!(!body.to_string().contains("Foreign history")); + assert_eq!( + body["extensions"]["opencode"]["nativeHistoryRetention"]["partial"], + true + ); + assert_eq!(body["capabilities"]["send"], false); + assert!( + (result.stdout.len() as u64) < freshell_freshagent::native_history::MAX_HISTORY_BYTES + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(path.with_extension("db-wal").exists(), keep_open); + assert_eq!(path.with_extension("db-shm").exists(), keep_open); + if let Some(wal) = wal_before { + assert_eq!(std::fs::read(path.with_extension("db-wal")).unwrap(), wal); + } + drop(writer); + } +} + +#[test] +fn history_binary_keeps_codex_tool_output_with_its_invocation() { + let home = tempfile::tempdir().unwrap(); + rollout(home.path(), "tool-thread", "Saved answer"); + let path = home + .path() + .join(".codex/sessions/2026/10/03/rollout-2026-10-03-tool-thread.jsonl"); + use std::io::Write; + let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + writeln!(file,"\n{}",json!({"type":"response_item","payload":{"type":"function_call","call_id":"tool-1","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"}})).unwrap(); + writeln!(file,"{}",json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"tool-1","output":"/workspace"}})).unwrap(); + let result = history(home.path(), "codex", "tool-thread"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items: Vec<_> = body["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + let tool = items + .iter() + .find(|item| item["kind"] == "dynamic_tool") + .unwrap(); + assert_eq!(tool["tool"], "exec_command"); + assert_eq!(tool["contentItems"][0]["text"], "/workspace"); + assert!(items + .iter() + .all(|item| item["kind"] != "text" || item["text"] != "/workspace")); +} + +#[test] +fn history_binary_reads_exact_saved_opencode_rows_without_a_daemon() { + for (has_revert, journal_mode, keep_open) in [ + (false, "DELETE", false), + (false, "WAL", false), + (false, "WAL", true), + (true, "DELETE", false), + (true, "WAL", false), + (true, "WAL", true), + ] { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".local/share/opencode"); + std::fs::create_dir_all(&directory).unwrap(); + let db = Connection::open(directory.join("opencode.db")).unwrap(); + db.pragma_update(None, "journal_mode", journal_mode) + .unwrap(); + db.execute_batch("CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT, directory TEXT, time_created INTEGER, time_updated INTEGER); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT); + CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, time_created INTEGER, data TEXT);").unwrap(); + if has_revert { + db.execute_batch("ALTER TABLE session ADD COLUMN revert TEXT") + .unwrap(); + } + for (id, text) in [ + ("ses_selected", "Saved OpenCode answer"), + ("ses_other", "Other conversation must not appear"), + ] { + db.execute( + "INSERT INTO session (id,title,directory,time_created,time_updated) VALUES (?1,'Saved name','/workspace',1,2)", + [id], + ) + .unwrap(); + for (message, role, text) in [ + (format!("{id}-user"), "user", "Saved user prompt"), + (format!("{id}-assistant"), "assistant", text), + ] { + db.execute( + "INSERT INTO message VALUES (?1,?2,?3,?4)", + rusqlite::params![ + message, + id, + if role == "user" { 1 } else { 2 }, + json!({"role":role,"time":{"created":1,"completed":2}}).to_string() + ], + ) + .unwrap(); + db.execute( + "INSERT INTO part VALUES (?1,?2,?3,1,?4)", + rusqlite::params![ + format!("{message}-text"), + id, + message, + json!({"type":"text","text":text}).to_string() + ], + ) + .unwrap(); + } + } + let writer = if keep_open { + Some(db) + } else { + drop(db); + None + }; + let before = std::fs::read(directory.join("opencode.db")).unwrap(); + if journal_mode == "WAL" { + assert_eq!(&before[18..20], &[2, 2]); + } + assert_eq!(directory.join("opencode.db-wal").exists(), keep_open); + assert_eq!(directory.join("opencode.db-shm").exists(), keep_open); + let wal_before = + keep_open.then(|| std::fs::read(directory.join("opencode.db-wal")).unwrap()); + let result = history(home.path(), "opencode", "ses_selected"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["threadId"], "ses_selected"); + assert_eq!(body["turns"].as_array().unwrap().len(), 2); + assert_eq!(body["turns"][0]["items"][0]["text"], "Saved user prompt"); + assert_eq!( + body["turns"][1]["items"][0]["text"], + "Saved OpenCode answer" + ); + assert_eq!(body["capabilities"]["send"], false); + assert_eq!( + std::fs::read(directory.join("opencode.db")).unwrap(), + before + ); + assert_eq!(directory.join("opencode.db-wal").exists(), keep_open); + assert_eq!(directory.join("opencode.db-shm").exists(), keep_open); + if let Some(wal_before) = wal_before { + assert_eq!( + std::fs::read(directory.join("opencode.db-wal")).unwrap(), + wal_before + ); + } + assert!(!history(home.path(), "opencode", "missing-session") + .status + .success()); + drop(writer); + if has_revert { + let db = Connection::open(directory.join("opencode.db")).unwrap(); + db.execute( + "UPDATE session SET revert = ?1 WHERE id = 'ses_selected'", + [json!({"messageID":"ses_selected-assistant"}).to_string()], + ) + .unwrap(); + drop(db); + let before = std::fs::read(directory.join("opencode.db")).unwrap(); + let result = history(home.path(), "opencode", "ses_selected"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["turns"].as_array().unwrap().len(), 1); + assert_eq!(body["turns"][0]["items"][0]["text"], "Saved user prompt"); + let tail = body["rolledBackTurns"].as_array().unwrap(); + assert_eq!(tail.len(), 1); + assert_eq!(tail[0]["items"][0]["text"], "Saved OpenCode answer"); + assert_eq!(tail[0]["rolledBack"], true); + assert_eq!(tail[0]["restorable"], false); + assert_eq!( + std::fs::read(directory.join("opencode.db")).unwrap(), + before + ); + assert!(!directory.join("opencode.db-wal").exists()); + assert!(!directory.join("opencode.db-shm").exists()); + } + } +} + +#[test] +fn history_binary_reads_managed_claude_transcript_and_tools_from_exact_provider_home() { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".claude/projects/-workspace"); + std::fs::create_dir_all(&directory).unwrap(); + let id = "44444444-4444-4444-8444-444444444444"; + let native = include_str!("../../../test/fixtures/managed-native-history/claude.jsonl"); + std::fs::write(directory.join(format!("{id}.jsonl")), native).unwrap(); + std::fs::write( + directory.join("foreign.jsonl"), + native.replace("Saved native Claude answer", "Foreign history"), + ) + .unwrap(); + for provider in ["claude", "kilroy"] { + let result = history(home.path(), provider, id); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(body["threadId"], id); + assert_eq!(body["provider"], "claude"); + let captured: Value = serde_json::from_str(include_str!( + "../../../test/fixtures/managed-native-history/claude.json" + )) + .unwrap(); + assert_eq!(body["turns"], captured["turns"]); + assert!(result + .stdout + .windows(b"Saved native Claude answer".len()) + .any(|part| part == b"Saved native Claude answer")); + assert!(body["turns"].to_string().contains("toolu_native")); + assert!(body["turns"].to_string().contains("/workspace")); + assert!(!body.to_string().contains("Foreign history")); + assert_eq!(body["capabilities"]["send"], false); + assert_eq!( + std::fs::read_to_string(directory.join(format!("{id}.jsonl"))).unwrap(), + native + ); + } + assert!(!history(home.path(), "claude", "missing").status.success()); +} + +#[test] +fn history_binary_preserves_custom_tools_and_persisted_completed_actions() { + let home = tempfile::tempdir().unwrap(); + let directory = home.path().join(".codex/sessions/2026/10/03"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("rollout-rich-tools.jsonl"), + include_str!("../../../test/fixtures/managed-native-history/codex-tools.jsonl"), + ) + .unwrap(); + let result = history(home.path(), "codex", "rich-tools"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let captured: Value = serde_json::from_str(include_str!( + "../../../test/fixtures/managed-native-history/codex-tools.json" + )) + .unwrap(); + assert_eq!(body["turns"], captured["turns"]); + let items: Vec<_> = body["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + let custom: Vec<_> = items + .iter() + .filter(|item| item["kind"] == "dynamic_tool" && item["tool"] == "apply_patch") + .collect(); + assert_eq!( + custom.len(), + 1, + "call and completed event must not duplicate" + ); + assert_eq!(custom[0]["status"], "completed"); + assert_eq!(custom[0]["contentItems"][0]["text"], "Patch saved"); + assert!(items.iter().any(|item| item["kind"] == "command" + && item["output"] == "/workspace" + && item["exitCode"] == 0)); + assert!(items + .iter() + .any(|item| item["kind"] == "web_search" && item["query"] == "SQLite WAL")); + assert!(items + .iter() + .any(|item| item["kind"] == "image_generation" && item["result"] == "saved-image")); + assert!(items.iter().any(|item| item["kind"] == "mcp_tool" + && item["result"]["content"][0]["text"] == "MCP saved result")); +} + +#[test] +fn history_binary_preserves_legacy_persisted_tool_events() { + let home = tempfile::tempdir().unwrap(); + rollout(home.path(), "legacy-tools", "Saved answer"); + let path = home + .path() + .join(".codex/sessions/2026/10/03/rollout-2026-10-03-legacy-tools.jsonl"); + use std::io::Write; + let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + for payload in [ + json!({"type":"patch_apply_end","call_id":"patch-1","success":true,"status":"completed","stdout":"Saved patch","stderr":"","changes":{"/workspace/saved.rs":{"type":"update","unified_diff":"+saved change","move_path":null}}}), + json!({"type":"mcp_tool_call_end","call_id":"mcp-legacy","invocation":{"server":"fixture","tool":"lookup","arguments":{"saved":true}},"result":{"Ok":{"content":[{"type":"text","text":"Legacy MCP result"}]}}}), + json!({"type":"web_search_end","call_id":"search-legacy","query":"saved search","action":{"type":"search","query":"saved search"}}), + ] { + writeln!(file, "\n{}", json!({"type":"event_msg","payload":payload})).unwrap(); + } + let result = history(home.path(), "codex", "legacy-tools"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items: Vec<_> = body["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert!(items + .iter() + .any(|item| item["kind"] == "file_change" + && item["changes"][0]["path"] == "/workspace/saved.rs")); + assert!(items.iter().any(|item| item["kind"] == "mcp_tool" + && item["result"]["content"][0]["text"] == "Legacy MCP result")); + assert!(items + .iter() + .any(|item| item["kind"] == "web_search" && item["query"] == "saved search")); +} + +#[test] +fn history_binary_keeps_native_shell_and_tool_search_outputs() { + let home = tempfile::tempdir().unwrap(); + rollout(home.path(), "other-tools", "Saved answer"); + let path = home + .path() + .join(".codex/sessions/2026/10/03/rollout-2026-10-03-other-tools.jsonl"); + use std::io::Write; + let mut file = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + for payload in [ + json!({"type":"local_shell_call","call_id":"shell-1","status":"completed","action":{"type":"exec","command":["pwd"],"working_directory":"/workspace"}}), + json!({"type":"function_call_output","call_id":"shell-1","output":"Saved shell output"}), + json!({"type":"tool_search_call","call_id":"search-tools","execution":"client","arguments":{"query":"saved"}}), + json!({"type":"tool_search_output","call_id":"search-tools","status":"completed","execution":"client","tools":[{"name":"native-search-tool"}]}), + ] { + writeln!( + file, + "\n{}", + json!({"type":"response_item","payload":payload}) + ) + .unwrap(); + } + let result = history(home.path(), "codex", "other-tools"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let body: Value = serde_json::from_slice(&result.stdout).unwrap(); + let items: Vec<_> = body["turns"] + .as_array() + .unwrap() + .iter() + .flat_map(|turn| turn["items"].as_array().unwrap()) + .collect(); + assert!(items.iter().any(|item| item["kind"] == "command" + && item["command"] == "pwd" + && item["output"] == "Saved shell output")); + assert!(items.iter().any(|item| item["kind"] == "dynamic_tool" + && item["tool"] == "tool_search" + && item["contentItems"][0]["name"] == "native-search-tool")); +} diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 1b4fa5c31..b80b3d937 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -1,4 +1,5 @@ pub mod docker; +mod native_history; use crate::registry::OwnedRuntimeHandle; use async_trait::async_trait; @@ -116,6 +117,17 @@ pub struct BackendInspection { #[async_trait] pub trait RuntimeBackend: Send + Sync { + async fn read_native_history( + &self, + _handle: &OwnedRuntimeHandle, + _provider: &str, + _native_id: &str, + _reader_binary: &Path, + ) -> Result { + Err(BackendError::Unavailable( + "native history reader unavailable".into(), + )) + } async fn create_stopped( &self, spec: &CreateRuntimeSpec, @@ -248,6 +260,16 @@ impl DockerEngineBackend { method: &str, path: &str, body: Option<&Value>, + ) -> Result { + self.request_bounded(method, path, body, u64::MAX).await + } + + async fn request_bounded( + &self, + method: &str, + path: &str, + body: Option<&Value>, + limit: u64, ) -> Result { let mut stream = UnixStream::connect(&self.socket_path) .await @@ -277,9 +299,15 @@ impl DockerEngineBackend { .map_err(|e| BackendError::Unavailable(e.to_string()))?; let mut raw = Vec::new(); stream + .take(limit.saturating_add(1)) .read_to_end(&mut raw) .await .map_err(|e| BackendError::Unavailable(e.to_string()))?; + if raw.len() as u64 > limit { + return Err(BackendError::Malformed( + "docker response exceeds history read limit".into(), + )); + } parse_http_response(&raw) } } @@ -470,6 +498,22 @@ fn runtime_host_environment( #[async_trait] impl RuntimeBackend for DockerEngineBackend { + async fn read_native_history( + &self, + handle: &OwnedRuntimeHandle, + provider: &str, + native_id: &str, + reader_binary: &Path, + ) -> Result { + self.read_history_helper( + handle, + provider, + native_id, + reader_binary, + std::time::Duration::from_secs(20), + ) + .await + } async fn create_stopped( &self, spec: &CreateRuntimeSpec, diff --git a/crates/freshell-supervisor/src/backend/native_history.rs b/crates/freshell-supervisor/src/backend/native_history.rs new file mode 100644 index 000000000..a3306f922 --- /dev/null +++ b/crates/freshell-supervisor/src/backend/native_history.rs @@ -0,0 +1,348 @@ +use super::*; +use std::time::Duration; + +// Keep the helper output bounded below the history-specific control reply budget. +const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024; + +impl DockerEngineBackend { + pub(super) async fn read_history_helper( + &self, + handle: &OwnedRuntimeHandle, + provider: &str, + native_id: &str, + reader_binary: &Path, + budget: Duration, + ) -> Result { + let name = format!("freshell-history-{}", uuid::Uuid::new_v4()); + let mut create_attempted = false; + let operation = async { + if self.daemon_id().await? != *handle.daemon_id() { + return Err(BackendError::OwnershipMismatch( + "native history daemon changed".into(), + )); + } + let volume = self + .request( + "GET", + &format!("{DOCKER_API}/volumes/{}", handle.provider_volume_name()), + None, + ) + .await?; + if volume.status != 200 { + return Err(volume.as_error()); + } + let volume: Value = serde_json::from_slice(&volume.body) + .map_err(|e| BackendError::Malformed(e.to_string()))?; + if volume["Name"].as_str() != Some(handle.provider_volume_name()) { + return Err(BackendError::OwnershipMismatch( + "native history volume changed".into(), + )); + } + let agent = handle.fresh_agent().ok_or_else(|| { + BackendError::InvalidConfig("native history requires a fresh agent".into()) + })?; + let binary = std::fs::canonicalize(reader_binary) + .map_err(|e| BackendError::Unavailable(e.to_string()))?; + let body = json!({ + "Image":handle.image_ref(), "User":format!("{}:{}",agent.run_as_uid,agent.run_as_gid), "Tty":true, + "Entrypoint":["/runtime/freshell-session-host"], + "Cmd":["native-history-only","--provider",provider,"--session-id",native_id,"--provider-home","/home/freshell/provider"], + "Env":["HOME=/home/freshell/provider"], + "HostConfig": {"NetworkMode":"none","ReadonlyRootfs":true,"CapDrop":["ALL"], + "SecurityOpt":["no-new-privileges"],"Memory":256*1024*1024,"MemorySwap":256*1024*1024, + "NanoCpus":500_000_000,"PidsLimit":32,"Tmpfs":{"/tmp":"rw,noexec,nosuid,nodev,size=16m"}, + "Mounts":[{"Type":"bind","Source":binary,"Target":"/runtime/freshell-session-host","ReadOnly":true}, + {"Type":"volume","Source":handle.provider_volume_name(),"Target":"/home/freshell/provider","ReadOnly":true}]} + }); + // The helper has no runtime labels, registry incarnation, execution grant or control mount. + create_attempted = true; + let created = self + .request_bounded( + "POST", + &format!("{DOCKER_API}/containers/create?name={name}"), + Some(&body), + 64 * 1024, + ) + .await?; + if created.status != 201 { + return Err(created.as_error()); + } + let created: Value = serde_json::from_slice(&created.body) + .map_err(|e| BackendError::Malformed(e.to_string()))?; + let id = created["Id"] + .as_str() + .ok_or_else(|| BackendError::Malformed("history helper has no id".into()))?; + let started = self + .request("POST", &format!("{DOCKER_API}/containers/{id}/start"), None) + .await?; + if started.status != 204 { + return Err(started.as_error()); + } + let waited = self + .request_bounded( + "POST", + &format!("{DOCKER_API}/containers/{id}/wait?condition=not-running"), + None, + 64 * 1024, + ) + .await?; + if waited.status != 200 { + return Err(waited.as_error()); + } + let waited: Value = serde_json::from_slice(&waited.body) + .map_err(|e| BackendError::Malformed(e.to_string()))?; + if waited["StatusCode"].as_i64() != Some(0) { + let logs = self + .request_bounded( + "GET", + &format!("{DOCKER_API}/containers/{id}/logs?stdout=1&stderr=1"), + None, + 16 * 1024, + ) + .await?; + let cause = std::str::from_utf8(&logs.body) + .ok() + .into_iter() + .flat_map(str::lines) + .filter_map(|line| serde_json::from_str::(line).ok()) + .find_map(|record| { + record + .get("error") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or_else(|| "saved native history could not be read".into()); + return Err(BackendError::Unavailable(cause)); + } + let logs = self + .request_bounded( + "GET", + &format!("{DOCKER_API}/containers/{id}/logs?stdout=1&stderr=0"), + None, + (MAX_SNAPSHOT_BYTES + 64 * 1024) as u64, + ) + .await?; + if logs.status != 200 { + return Err(logs.as_error()); + } + if logs.body.len() > MAX_SNAPSHOT_BYTES { + return Err(BackendError::Unavailable( + "saved history exceeds snapshot read limit".into(), + )); + } + serde_json::from_slice::(&logs.body) + .map_err(|e| BackendError::Malformed(e.to_string())) + }; + let result = tokio::time::timeout(budget, operation) + .await + .unwrap_or_else(|_| { + Err(BackendError::Unavailable( + "native history read timed out".into(), + )) + }); + // Unique owned name also covers a lost Docker create acknowledgment. Never touch the provider container. + if !create_attempted { + return result; + } + let cleanup = tokio::time::timeout( + Duration::from_secs(5), + self.request( + "DELETE", + &format!("{DOCKER_API}/containers/{name}?force=1"), + None, + ), + ) + .await; + match cleanup { + Ok(Ok(response)) if response.status == 204 || response.status == 404 => {} + _ => { + tracing::warn!(soul_id=%handle.soul_id(), helper=%name, "runtime.native_history_cleanup_failed"); + return Err(BackendError::Unavailable( + "native history helper cleanup failed".into(), + )); + } + } + if let Err(error) = &result { + tracing::warn!(soul_id=%handle.soul_id(), helper=%name, error=%error, "runtime.native_history_read_failed"); + } else { + tracing::debug!(soul_id=%handle.soul_id(), helper=%name, "runtime.native_history_read"); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use freshell_runtime_protocol::LaunchNonce; + use std::sync::{Arc, Mutex}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixListener, + }; + + #[tokio::test] + async fn history_helper_cleans_only_its_owned_container_on_success_error_and_timeout() { + for mode in [ + "success", + "failed_exit", + "bad_output", + "timeout", + "failed_cleanup", + ] { + let temp = tempfile::tempdir().unwrap(); + let socket = temp.path().join("docker.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let daemon = DockerDaemonId::new(); + let expected_daemon = daemon.clone(); + let binary = temp.path().join("reader"); + std::fs::write(&binary, "fixture binary").unwrap(); + let agent: FreshAgentLaunchSpec = serde_json::from_value(json!({ + "sessionId":"presentation-alias","provider":"opencode","sessionType":"freshopencode", + "runtimeVariant":"opencode","providerStoreId":"store","cwd":"/workspace","workspacePath":"/workspace", + "runAsUid":65534,"runAsGid":0,"nativeSessionId":"ses_saved" + })).unwrap(); + let handle = OwnedRuntimeHandle::from_registry( + InstallationId::new(), + SoulId::new(), + IncarnationId::new(), + LaunchNonce::new(), + daemon, + "provider-container-must-not-touch".into(), + "sha256:fixture".into(), + temp.path().into(), + binary.clone(), + "config".into(), + RuntimeLimits { + cpu_milli: 500, + memory_bytes: 64 * 1024 * 1024, + swap_bytes: 0, + pids_max: 32, + }, + None, + None, + Some(agent), + "owned-native-volume".into(), + ); + let seen = Arc::new(Mutex::new(Vec::new())); + let captured = seen.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let seen = captured.clone(); + let daemon = expected_daemon.clone(); + tokio::spawn(async move { + let mut raw = Vec::new(); + let mut byte = [0]; + while !raw.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + raw.push(byte[0]); + } + let head = String::from_utf8(raw).unwrap(); + let length: usize = head + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .unwrap() + .trim() + .parse() + .unwrap(); + let mut body = vec![0; length]; + stream.read_exact(&mut body).await.unwrap(); + let request = head.lines().next().unwrap().to_string(); + seen.lock().unwrap().push((request.clone(), body)); + let (status, body) = if request.contains("/info ") { + (200, json!({"ID":daemon}).to_string()) + } else if request.contains("/volumes/") { + (200, json!({"Name":"owned-native-volume"}).to_string()) + } else if request.contains("/create?") { + (201, json!({"Id":"owned-history-helper"}).to_string()) + } else if request.contains("/start ") { + (204, String::new()) + } else if request.contains("/wait?") { + if mode == "timeout" { + tokio::time::sleep(Duration::from_secs(1)).await; + } + ( + 200, + json!({"StatusCode": if mode == "failed_exit" {1} else {0}}) + .to_string(), + ) + } else if request.contains("/logs?") { + ( + 200, + if mode == "bad_output" { + "invalid".into() + } else if mode == "failed_exit" { + json!({"event":"session_host.fatal","error":"saved native session not found"}).to_string() + } else { + json!({"threadId":"ses_saved","provider":"opencode","turns":[]}) + .to_string() + }, + ) + } else if request.starts_with("DELETE ") { + ( + if mode == "failed_cleanup" { 500 } else { 204 }, + String::new(), + ) + } else { + panic!("unexpected request {request}") + }; + let response = format!("HTTP/1.1 {status} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + let result = DockerEngineBackend::new(&socket) + .read_history_helper( + &handle, + "opencode", + "ses_saved", + &binary, + if mode == "timeout" { + Duration::from_millis(250) + } else { + Duration::from_secs(5) + }, + ) + .await; + assert_eq!(result.is_ok(), mode == "success", "{mode}: {result:?}"); + if mode == "failed_exit" { + assert!(result + .unwrap_err() + .to_string() + .contains("saved native session not found")); + } + { + let seen = seen.lock().unwrap(); + assert!(seen + .iter() + .all(|(request, _)| !request.contains(handle.container_id()))); + let create: Value = serde_json::from_slice( + &seen + .iter() + .find(|(request, _)| request.contains("/create?")) + .unwrap() + .1, + ) + .unwrap(); + assert_eq!(create["User"], "65534:0"); + assert_eq!(create["HostConfig"]["NetworkMode"], "none"); + assert_eq!(create["HostConfig"]["Mounts"].as_array().unwrap().len(), 2); + assert!(create["HostConfig"]["Mounts"] + .as_array() + .unwrap() + .iter() + .all(|mount| mount["ReadOnly"] == true)); + assert!(create.get("Labels").is_none()); + let deletes: Vec<_> = seen + .iter() + .filter(|(request, _)| request.starts_with("DELETE ")) + .collect(); + assert_eq!(deletes.len(), 1); + assert!(deletes[0].0.contains("/containers/freshell-history-")); + } + server.abort(); + let _ = server.await; + } + } +} diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index d9cb6b021..33dfb52d3 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -2594,7 +2594,7 @@ fn load_recovery_context( pub(crate) fn load_inventory(conn: &Connection) -> Result, RegistryError> { let mut stmt = conn.prepare( - "SELECT i.soul_id,i.incarnation_id,i.launch_state,i.cleanup_state,s.intent_revision,i.container_id,i.host_boot_id,i.execution_generation,i.effective_limits,i.terminal_id,s.project_key,s.resource_profile,s.desired_state,s.recovery_state,s.durability_state,s.allocation_state,s.provider,s.native_session_id,s.recovery_reason,i.prior_incarnation_id,s.recovery_attempt_id,s.evidence_revision,s.successful_recoveries_in_window,i.terminal_spec,s.configured_limits,(SELECT MAX(v.revision) FROM view_intents v WHERE v.soul_id=i.soul_id),s.loss_incident_id,i.fresh_agent_spec FROM incarnations i JOIN souls s ON s.soul_id=i.soul_id ORDER BY i.created_at,i.incarnation_id", + "SELECT i.soul_id,i.incarnation_id,i.launch_state,i.cleanup_state,s.intent_revision,i.container_id,i.host_boot_id,i.execution_generation,i.effective_limits,i.terminal_id,s.project_key,s.resource_profile,s.desired_state,s.recovery_state,s.durability_state,s.allocation_state,s.provider,s.native_session_id,s.recovery_reason,i.prior_incarnation_id,s.recovery_attempt_id,s.evidence_revision,s.successful_recoveries_in_window,i.terminal_spec,s.configured_limits,(SELECT MAX(v.revision) FROM view_intents v WHERE v.soul_id=i.soul_id),s.loss_incident_id,i.fresh_agent_spec,s.creation_seed_ref FROM incarnations i JOIN souls s ON s.soul_id=i.soul_id ORDER BY i.created_at,i.incarnation_id", )?; let mut rows = stmt.query([])?; let mut out = Vec::new(); @@ -2643,6 +2643,11 @@ pub(crate) fn load_inventory(conn: &Connection) -> Result, Regi fresh_agent_session_id: fresh_agent_spec .as_ref() .map(|spec| spec.session_id.clone()), + fresh_agent_create_request_id: if fresh_agent_spec.is_some() { + Some(row.get(28)?) + } else { + None + }, fresh_agent_session_type: fresh_agent_spec .as_ref() .map(|spec| spec.session_type.clone()), @@ -3339,6 +3344,54 @@ mod tests { )); } + #[tokio::test] + async fn fresh_create_inventory_correlates_launch_without_mislabeling_non_fresh_seeds() { + let dir = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let registry = Registry::open(dir.path(), None).unwrap(); + let fresh_soul = SoulId::new(); + let prepared = + materialize_fresh_runtime(®istry, fresh_soul.clone(), workspace.path()).await; + let non_fresh_soul = SoulId::new(); + materialize_test_runtime(®istry, non_fresh_soul.clone()).await; + let inventory = registry.inventory().await.unwrap(); + let fresh = inventory + .iter() + .find(|view| view.soul_id == fresh_soul) + .unwrap(); + assert_eq!(fresh.native_session_id, None); + assert_eq!( + serde_json::to_value(fresh).unwrap()["freshAgentCreateRequestId"], + "seed" + ); + let non_fresh = inventory + .iter() + .find(|view| view.soul_id == non_fresh_soul) + .unwrap(); + assert!(serde_json::to_value(non_fresh) + .unwrap() + .get("freshAgentCreateRequestId") + .is_none()); + registry + .record_native_session( + fresh_soul.clone(), + prepared.incarnation_id, + "native-observed".into(), + ) + .await + .unwrap(); + let inventory = registry.inventory().await.unwrap(); + let fresh = inventory + .iter() + .find(|view| view.soul_id == fresh_soul) + .unwrap(); + assert_eq!(fresh.native_session_id.as_deref(), Some("native-observed")); + assert_eq!( + serde_json::to_value(fresh).unwrap()["freshAgentCreateRequestId"], + "seed" + ); + } + #[tokio::test] async fn native_fork_transitions_identity_in_place_and_replaces_the_writer_claim() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 13433035c..94fd06d46 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -413,6 +413,14 @@ impl Supervisor { .await?, )) } + AdminCommand::FreshAgentReadSnapshot(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentSnapshot( + self.fresh_agent_snapshot(request.soul_id).await?, + )) + } AdminCommand::FreshAgentResolve(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -429,6 +437,55 @@ impl Supervisor { self.fresh_agent_interrupt(request.soul_id).await?; Ok(AdminResult::FreshAgentInterrupted) } + AdminCommand::FreshAgentReadHistory(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + let context = self + .registry + .recovery_context(request.soul_id) + .await + .map_err(map_registry)?; + let handle = &context.prior_handle; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::InvalidRequest, + "soul is not a fresh agent", + ) + })?; + let native_id = context.native_session_id.as_deref().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::InvalidRequest, + "saved native session identity unavailable", + ) + })?; + let snapshot = self + .backend + .read_native_history( + handle, + agent.provider.as_str(), + native_id, + &self.config.host_binary_path, + ) + .await + .map_err(map_backend)?; + // Kilroy uses the Claude transcript contract, with its own session type. + let wire_provider = + if agent.provider == freshell_runtime_protocol::FreshProvider::Kilroy { + "claude" + } else { + agent.provider.as_str() + }; + if snapshot["threadId"].as_str() != Some(native_id) + || snapshot["provider"].as_str() != Some(wire_provider) + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "native history identity mismatch", + )); + } + Ok(AdminResult::FreshAgentHistory(snapshot)) + } AdminCommand::FreshAgentReadEvents(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -1311,6 +1368,67 @@ impl Supervisor { } } + async fn fresh_agent_snapshot( + &self, + soul_id: SoulId, + ) -> Result { + let lifecycle_lock = self.lifecycle_lock(&soul_id).await; + let _guard = lifecycle_lock.lock().await; + let handle = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::UnsupportedWorkload, + "soul is not a hosted fresh-agent", + ) + })?; + let provider = if agent.provider == freshell_runtime_protocol::FreshProvider::Kilroy { + "claude" + } else { + agent.provider.as_str() + }; + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + let result = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentReadSnapshot { + incarnation_id: handle.incarnation_id().clone(), + }, + ) + .await?; + let HostResult::FreshAgentSnapshot(snapshot) = result else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected fresh-agent snapshot reply", + )); + }; + let current = self + .registry + .active_handle_for_soul(soul_id) + .await + .map_err(map_registry)?; + if current.incarnation_id() != handle.incarnation_id() { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent snapshot owner changed", + )); + } + if snapshot["provider"].as_str() != Some(provider) { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent snapshot provider mismatch", + )); + } + Ok(snapshot) + } + async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -2186,7 +2304,16 @@ pub async fn serve_control(supervisor: Supervisor, socket_path: &Path) -> Result )), }, }; - let _ = write_frame(&mut stream, &reply).await; + let limit = if matches!( + reply.result, + Ok(AdminResult::FreshAgentHistory(_) | AdminResult::FreshAgentSnapshot(_)) + ) { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; + let _ = + freshell_runtime_protocol::write_frame_with_limit(&mut stream, &reply, limit).await; }); } } @@ -2249,9 +2376,15 @@ pub(crate) async fn request_host_reply( write_frame(&mut stream, envelope) .await .map_err(|error| unreachable(error.to_string()))?; - let reply: HostReply = read_frame(&mut stream) - .await - .map_err(|error| unreachable(error.to_string()))?; + let reply_limit = if matches!(envelope.body, HostCommand::FreshAgentReadSnapshot { .. }) { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; + let reply: HostReply = + freshell_runtime_protocol::read_frame_with_limit(&mut stream, reply_limit) + .await + .map_err(|error| unreachable(error.to_string()))?; Ok(reply) }) .await @@ -2427,6 +2560,55 @@ mod host_ipc_timeout_tests { use std::time::{Duration, Instant}; use tokio::net::UnixListener; + #[tokio::test] + async fn snapshot_host_reply_preserves_large_history_and_controls_stay_bounded() { + for snapshot_read in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("host.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let expected = serde_json::json!({"threadId":"native-large", "turns":[{"text":"x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}); + let sent = expected.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = + freshell_runtime_protocol::read_frame(&mut stream) + .await + .unwrap(); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &freshell_runtime_protocol::HostReply { + request_id: envelope.request_id, + result: Ok(freshell_runtime_protocol::HostResult::FreshAgentSnapshot( + sent, + )), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + }); + let id = IncarnationId::parse("large-incarnation").unwrap(); + let command = if snapshot_read { + HostCommand::FreshAgentReadSnapshot { incarnation_id: id } + } else { + HostCommand::Status { incarnation_id: id } + }; + let reply = request_host_reply( + &socket, + &Envelope::new(RequestId::new(), ControlRole::Supervisor, command), + Duration::from_secs(10), + ) + .await; + let _ = server.await.unwrap(); + if snapshot_read { + assert!( + matches!(reply.unwrap().result.unwrap(), freshell_runtime_protocol::HostResult::FreshAgentSnapshot(value) if value == expected) + ); + } else { + assert_eq!(reply.unwrap_err().code, RuntimeErrorCode::HostUnreachable); + } + } + } + /// A session host that accepts the connection and then never answers must /// NOT be able to hold the supervisor's authoritative stop hostage. The /// supervisor owns the runtime; an unresponsive workload is a bounded, diff --git a/crates/freshell-supervisor/src/view_intents.rs b/crates/freshell-supervisor/src/view_intents.rs index bfd8be370..8c257358c 100644 --- a/crates/freshell-supervisor/src/view_intents.rs +++ b/crates/freshell-supervisor/src/view_intents.rs @@ -538,6 +538,27 @@ impl Registry { params![current.soul_id.as_str()], |row| Ok((row.get(0)?, row.get(1)?)), )?; + // A successful kill hides the automatic view and advances both + // fences before its inventory broadcast reaches the browser. + // An older detach can acknowledge that already-closed view; + // it must not mutate a newer visible view or trust stop intent + // before the owned runtime has been verified empty. + if visibility == ViewVisibilityIntent::Detached + && current.visibility == ViewVisibilityIntent::Hidden + && desired == "stopped" + && expected_revision <= current.revision + && expected_soul_intent_revision <= soul_revision + { + let verified_empty: bool = tx.query_row( + "SELECT EXISTS (SELECT 1 FROM incarnations WHERE soul_id=?1) AND NOT EXISTS (SELECT 1 FROM incarnations WHERE soul_id=?1 AND (launch_state<>'stopped' OR cleanup_state<>'verified_empty'))", + params![current.soul_id.as_str()], + |row| row.get(0), + )?; + if verified_empty { + tx.commit()?; + return Ok(current); + } + } if current.revision != expected_revision { return Err(RegistryError::StaleIntentRevision { expected: expected_revision, diff --git a/crates/freshell-ws/src/codex_reconcile.rs b/crates/freshell-ws/src/codex_reconcile.rs index 5f971cd31..e11477fb0 100644 --- a/crates/freshell-ws/src/codex_reconcile.rs +++ b/crates/freshell-ws/src/codex_reconcile.rs @@ -73,6 +73,10 @@ impl RolloutTailer { let Ok(len) = file.metadata().map(|m| m.len()) else { return Vec::new(); }; + self.read_file_extent(&mut file, len) + } + + fn read_file_extent(&mut self, file: &mut std::fs::File, len: u64) -> Vec { if len < self.offset { // Truncated/replaced file: restart from the top. self.offset = 0; @@ -89,7 +93,9 @@ impl RolloutTailer { if file.read_to_end(&mut buf).is_err() { return Vec::new(); } - self.offset = len; + // Appends can land after metadata(): commit the bytes read, not the + // earlier length, so a partial JSON record is never read twice. + self.offset += buf.len() as u64; self.partial.extend_from_slice(&buf); let mut lines = Vec::new(); @@ -322,6 +328,42 @@ mod tests { assert_eq!(tailer.read_new_lines(), vec!["partial4"]); } + #[test] + fn tailer_keeps_a_start_appended_after_the_length_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("growing.jsonl"); + let metadata = r#"{"type":"session_meta","payload":{"id":"selected-thread"}}"#; + std::fs::write(&path, format!("{metadata}\n")).unwrap(); + let mut tailer = RolloutTailer::new(&path); + tailer.attach().unwrap(); + let mut reader = std::fs::File::open(&path).unwrap(); + let captured_len = reader.metadata().unwrap().len(); + let start = event_line("task_started", "2026-07-25T08:00:00.000Z"); + let mut writer = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writer.write_all(start.as_bytes()).unwrap(); + assert_eq!( + tailer.read_file_extent(&mut reader, captured_len), + vec![metadata] + ); + writer.write_all(b"\n").unwrap(); + let lines = tailer.read_new_lines(); + assert_eq!(lines, vec![start]); + assert!(tailer.read_new_lines().is_empty()); + let events = fold_task_events(&lines); + assert_eq!(events.latest_task_started_at, Some(1_784_966_400_000)); + let mut tracker = freshell_activity::codex::CodexActivityTracker::new(); + tracker.track_terminal( + "selected-terminal", + Some("selected-thread"), + 1_784_966_400_000, + ); + tracker.reconcile_rollout("selected-terminal", &events, 1_784_966_400_001); + assert_eq!(tracker.list()[0].phase, freshell_protocol::CodexPhase::Busy); + } + #[test] fn tailer_initial_attach_is_bounded_and_drops_the_partial_first_line() { let dir = tempfile::tempdir().unwrap(); diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 5d7360639..f9026dd9d 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -17,21 +17,20 @@ RUN npm install --global pnpm@10.34.5 \ # Bundle Freshell-owned MCP code from this checkout, then export only its # lockfile-pinned runtime dependencies. The resulting entry has no worktree -# links and can run inside any managed soul. +# links and can run inside any managed soul. Keep build tools local to the +# package, and bundle into its published files before the production deploy. FROM node-source AS mcp-runtime WORKDIR /mcp-workspace COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY packages/freshell-mcp-runtime/package.json packages/freshell-mcp-runtime/ COPY tools/freshell-mcp/*.ts tools/freshell-mcp/ COPY tools/node-client-runtime/*.ts tools/node-client-runtime/ -# pnpm deploy only carries the package's generated/ allowlist, so copy this -# image build's root entry point into the deployed runtime explicitly. RUN npm install --global pnpm@10.34.5 \ && pnpm --filter freshell-mcp-runtime install --frozen-lockfile --ignore-scripts \ && pnpm --filter freshell-mcp-runtime exec esbuild ../../tools/freshell-mcp/server.ts \ - --bundle --platform=node --format=esm --packages=external --outfile=server.js \ + --bundle --platform=node --format=esm --packages=external --outfile=generated/server.js \ && pnpm --filter freshell-mcp-runtime --prod --frozen-lockfile --config.node-linker=hoisted deploy /opt/freshell-mcp \ - && cp packages/freshell-mcp-runtime/server.js /opt/freshell-mcp/server.js + && mv /opt/freshell-mcp/generated/server.js /opt/freshell-mcp/server.js FROM ubuntu@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG CLAUDE_CODE_VERSION=2.1.263 diff --git a/docker/sandbox/Dockerfile b/docker/sandbox/Dockerfile index a2d97a334..e36ae88c8 100644 --- a/docker/sandbox/Dockerfile +++ b/docker/sandbox/Dockerfile @@ -28,6 +28,9 @@ ENV DEBIAN_FRONTEND=noninteractive \ # plus git/curl for rustup and cargo's own network operations. gosu drops # root privileges cleanly (true exec, unlike su) once the entrypoint has # fixed up ownership of freshly-created named volumes (see entrypoint.sh). +# DBus supports desktop integration; freshell-tauri's Linux webview and +# default tray feature need GTK3, WebKit2GTK 4.1 (including libsoup3), and +# Ayatana AppIndicator headers/link libraries for full-workspace tests. RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ @@ -35,6 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libclang-dev \ pkg-config \ libssl-dev \ + libdbus-1-dev \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ perl \ git \ curl \ diff --git a/docs/development/test-sandbox.md b/docs/development/test-sandbox.md index 3bdd0e9bd..6e2170ece 100644 --- a/docs/development/test-sandbox.md +++ b/docs/development/test-sandbox.md @@ -121,6 +121,12 @@ Rebuild after any change to `docker/sandbox/Dockerfile` or `docker/sandbox/entry image is tagged `freshell-sandbox:latest` and Docker layer caching keeps rebuilds fast unless a step earlier in the Dockerfile changed. +The canonical image includes the native development libraries for the whole Rust workspace: +DBus and the Tauri desktop shell's GTK3, WebKit2GTK 4.1 (with libsoup3), and Ayatana +AppIndicator tray dependencies. Rebuild an existing image before running full-workspace tests +after these prerequisites change; an already-present image is not rebuilt automatically. +Tests use these libraries as the unprivileged sandbox user without installing packages at run time. + ## When you MUST use it vs may skip it **Must use the sandbox:** diff --git a/docs/index.html b/docs/index.html index f97d88aab..0df5db880 100644 --- a/docs/index.html +++ b/docs/index.html @@ -463,6 +463,8 @@ restart/start-fresh actions), mirroring the divergence card's structure */ .terminal-stuck-card { margin: 8px 12px 0; border: 1px solid hsl(38 92% 50% / .5); border-radius: 6px; background: hsl(38 92% 50% / .1); padding: 8px 12px; font-size: 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px; } .terminal-stuck-card-actions { display: flex; gap: 8px; flex-shrink: 0; } +.managed-runtime-recovery-card { margin: 8px 12px 0; border: 1px solid hsl(38 92% 50% / .5); border-radius: 6px; background: hsl(38 92% 50% / .1); padding: 8px 12px; font-size: 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.managed-runtime-recovery-card button { flex-shrink: 0; border: 1px solid hsl(var(--border)); border-radius: 4px; background: transparent; padding: 3px 8px; cursor: pointer; font: inherit; font-size: 12px; } .terminal-stuck-action { border: 1px solid hsl(var(--border) / .7); border-radius: 6px; background: transparent; color: hsl(var(--foreground)); font: inherit; font-size: 12px; padding: 4px 10px; white-space: nowrap; cursor: pointer; } .fresh-transcript { min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; padding: 14px 12px; } .fresh-transcript { position: relative; } @@ -735,14 +737,12 @@ Fresh Agent - +
- + - Recovered agents + Agents
@@ -754,16 +754,6 @@
Terminal — zsh~/code/freshell
Codex reconnecting... typed input is buffered until ready.
- -
Restarting agent — resuming the same OpenCode session (2 recovered).
- -
Agent ended without a running process. Cleanup verified. View incident · Dismiss
@@ -852,20 +842,17 @@ - +
-
OpenCode — recovered~/code/freshell
-
Ready — resumed the same session after a server restart.
+
OpenCode~/code/freshell
OpenCode — blocked~/code/other-project
-
Recovery blocked: provider credentials expired. Retry · Stop agent
+
diff --git a/docs/plans/2026-09-29-managed-recovery-contextual-ui.md b/docs/plans/2026-09-29-managed-recovery-contextual-ui.md new file mode 100644 index 000000000..7b6a5e8d4 --- /dev/null +++ b/docs/plans/2026-09-29-managed-recovery-contextual-ui.md @@ -0,0 +1,325 @@ +# Contextual Managed Recovery UI Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. + +## User Request + +### Requested result +Implement the revised durable-runtime UI so automatic recovery stays invisible; use existing yellow error popups only when a user decision or intervention is needed; keep System Status focused on system load; remove the persistent managed-runtime dashboard, routine notices, and per-agent resource controls; preserve existing agent panes and session history/actions. Make the canonical test sandbox include the system libraries needed by the full workspace so test runs do not rely on ad hoc package installation. + +### Explicit constraints +- Use the existing pane/agent error surfaces and yellow error popups for failures or decisions. +- Do not add a System Status error surface; System Status remains load/resource monitoring. +- Do not expose lifecycle/recovery details during normal operation. +- Do not silently create a replacement conversation; retain history and explicitly label any start-new action. +- Work in a dedicated worktree and complete the-usual workflow with tests and independent review. +- Rebase onto current main, resolve overlaps, fix checks to green, verify affected browser coverage, review the updated candidate, and land via PR. +- Continue whole-branch review beyond the-usual's five-round limit until it passes, then finish the authorized PR landing. +- Run independent checks and fixes in parallel; use separate worktrees when they would otherwise share mutable source or build artifacts. +- Fix missing test libraries in the canonical sandbox and verify the full workspace on a clean rebuilt image. + +### Accepted tradeoffs and residuals +- Runtime recovery internals and diagnostics may remain available to the implementation and existing panes; only unnecessary always-visible user-facing surfaces should be removed. +- Existing unrelated OpenCode baseline test failure is outside this UI change. + +**Goal:** Make managed-runtime recovery quiet during healthy operation and actionable only in the affected agent pane when the user must intervene. + +**Architecture:** Keep WebSocket negotiation, `managedRuntimeSlice`, supervisor inventory reconciliation, projection fields, session identity, and history behavior intact. Remove the fixed managed-agent dashboard and its resource editor. Add one presentational pane-local amber card driven by the projected `recoverySummary`; it offers same-soul retry for `blocked` and an explicitly labeled start-new action for certified `lost` state. Keep the existing notice mount only for cleanup failures that have no pane-local decision, while silently retiring successful cleanup and ordinary-ended notices. Repair the client merge so represented lost souls reach their existing panes without reconstructing or launching a replacement. + +**Tech Stack:** React 18, TypeScript, Redux Toolkit selectors, existing `FreshAgentApprovalBanner`/terminal amber-card patterns, Vitest/Testing Library, and the existing Playwright managed-runtime coverage. + +## Global Constraints + +- Work only in `/home/dan/code/freshell/.worktrees/managed-recovery-contextual-ui` on `the-usual/managed-recovery-contextual-ui`; never edit the primary checkout. +- The immutable base is `12e5e9f55fa049fd33b81f8f7ff64f451605b907`. +- Preserve the pre-existing `.tmp-native-smoke/` changes in the primary checkout. +- Use pnpm `10.34.5`, frozen installs, repository-owned test commands, and the configured Cloud Run backend for broad gates. +- Do not weaken, skip, or delete tests merely to obtain a green result. The baseline has one ledger-recorded unrelated failure in `test/unit/tooling/testing/opencode-native-history.test.ts`. +- Keep System Status (`HostStatsPane`) load-only. Do not route runtime recovery or incident data into it. +- Preserve session identity, history, existing pane actions, and the explicit kill-before-new-conversation semantics. Never auto-create a replacement conversation. +- Update `docs/index.html` for this significant user-facing UI change. + +--- + +### Task 1: Remove always-visible managed-runtime surfaces and retain only actionable error delivery + +**Files:** +- Modify: `src/App.tsx` to remove the managed dashboard import/mount while retaining the narrow cleanup-failure notice mount, managed-runtime readiness, and refresh wiring. +- Delete: `src/components/ManagedAgentRecoveryStatus.tsx`. +- Delete: `src/components/AgentResourceLimits.tsx`. +- Modify: `src/components/ManagedRuntimeNotices.tsx` to show only cleanup failures in the existing amber error-popup style, silently acknowledge routine success/ended notices, and never show a ready count, startup scan, IDs, or resource controls. +- Modify: `test/unit/client/components/ManagedRuntimeNotices.test.tsx` to protect actionable-error-only behavior. +- Delete: `test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx` after its dashboard/resource assertions are replaced by Task 2 card tests. +- Modify: `test/runtime/gates/phase-4.test.ts` so P4-G06 verifies the retained cleanup-failure notice and the new pane-local recovery card instead of reading the deleted dashboard/resource surface. + +**Interfaces:** +- Consumes: `getManagedRuntimeNotices`, `recordManagedRuntimeNoticeReceipt`, `ManagedRuntimeNotice`, and the existing `managedRuntime.available`/connection selectors. +- Produces: no new public API; App continues to expose only internal managed-runtime recovery state and pane projections. + +- [x] **Step 1: Write the failing behavioral test** + +Add tests to `ManagedRuntimeNotices.test.tsx` that render a `cleanup_succeeded` and an `ended_without_process` notice and assert no popup is rendered, while a `cleanup_failed` notice renders one amber `role="alert"` with its message and an explicit Dismiss action. Assert routine notices are acknowledged through the existing receipt API so they do not block later actionable notices. Assert the component never renders `Managed agent recovery`, `ready`, `Resource limits and usage`, or a runtime identifier. + +- [x] **Step 2: Run the test and verify the intended failure** + +Run: + +```bash +pnpm run test:vitest run test/unit/client/components/ManagedRuntimeNotices.test.tsx --config config/vitest/vitest.config.ts +``` + +Expected: FAIL because the current component renders successful and ended notices and uses the old generic popup behavior. + +- [x] **Step 3: Add the minimal production implementation** + +Remove only the dashboard import/mount from `App.tsx`; keep the notice mount. In `ManagedRuntimeNotices`, partition fetched notices by `kind === 'cleanup_failed'`; acknowledge non-actionable notices using the existing receipt endpoint, keep polling only while the managed capability and WebSocket are ready, and render the first actionable notice with the existing amber border/background classes, `role="alert"`, its user-facing message, Details when an incident exists, and Dismiss. Remove the 10-second auto-acknowledgement for the actionable error so the user can decide when to dismiss it. Delete the now-orphaned dashboard and resource-editor files. Update P4-G06 in `test/runtime/gates/phase-4.test.ts` to run the retained notice/card tests and assert only the actionable error semantics; do not preserve assertions for removed lifecycle labels, dashboard IDs, Close view, Stop agent, or resource controls. + +- [x] **Step 4: Run the focused test** + +Run the command from Step 2. + +Expected: PASS, including the routine-notice suppression and cleanup-failure popup behavior. + +- [x] **Step 5: Refactor while green** + +Keep notice filtering and acknowledgement in small named helpers, keep the existing API/receipt contracts unchanged, and remove dead imports/constants without changing managed-runtime refresh or host-stats code. + +- [x] **Step 6: Run impacted-test verification** + +Run: + +```bash +pnpm run test:vitest run \ + test/unit/client/components/ManagedRuntimeNotices.test.tsx \ + test/unit/client/components/App.machine-identity.test.tsx \ + test/unit/client/components/App.inventory-title-fold.test.tsx \ + test/unit/client/components/panes/HostStatsPane.test.tsx \ + test/unit/client/components/App.hoststats-ws.test.tsx \ + --config config/vitest/vitest.config.ts +``` + +Expected: PASS. The App tests must still cover managed-runtime bootstrap indirectly, and HostStats tests must remain unchanged and load-only. + +- [x] **Step 7: Commit the task** + +```bash +git add src/App.tsx src/components/ManagedRuntimeNotices.tsx test/unit/client/components/ManagedRuntimeNotices.test.tsx test/runtime/gates/phase-4.test.ts +git rm src/components/ManagedAgentRecoveryStatus.tsx src/components/AgentResourceLimits.tsx test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx +git commit -m "refactor(ui): remove managed runtime dashboard" +``` + +Do not modify `HostStatsPane` or the managed-runtime backend/API contracts in this task. + +### Task 2: Surface blocked and lost recovery inside the affected pane + +**Files:** +- Create: `src/components/ManagedRuntimeRecoveryCard.tsx`. +- Create: `test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx`. +- Modify: `src/lib/recovery/managed-runtime-recovery.ts` so an already represented `desiredState: 'stopped', recoveryState: 'lost'` soul updates its existing pane projection without creating a new tab or launching a replacement. +- Modify: `test/unit/lib/managed-runtime-recovery.test.ts` with terminal and Fresh Agent live-to-lost merge coverage, including the no-create rule for absent lost views. +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` recovery effects and deferred reconcile callbacks so managed `blocked`/`lost` projections cannot arm provider recovery or an identity-less create before the explicit user action. +- Modify: `src/components/TerminalView.tsx` to render the card for projected `blocked`/`lost` states before any generic terminal-exit presentation, retry the same soul with its revision fence, refresh inventory, and reuse an explicit start-new action for lost sessions. +- Modify: `src/components/fresh-agent/FreshAgentView.tsx` to render the same card, retry with the projected revision, refresh inventory, and reuse the existing kill-before-new-conversation action for lost sessions. +- Modify: `src/store/panesSlice.ts` and the explicit start-new handlers so a user-chosen new conversation mints a new `createRequestId` and clears every managed projection field; reconcile-driven same-conversation folds keep their existing create key. +- Modify: `test/unit/client/components/TerminalView.launchRetry.test.tsx` or the nearest existing TerminalView focused fixture to cover managed blocked/lost presentation and no automatic replacement. +- Modify: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx` with a focused managed blocked/lost case if its existing fixture can provide the projection without broad lifecycle setup. + +**Interfaces:** +- Consumes: `ManagedRuntimeRecoverySummary`, `TerminalPaneContent`/`FreshAgentPaneContent` projection fields, `retryManagedRuntimeSoul`, `queueManagedRuntimeRefresh`, and the existing parent callbacks `startFreshConversation` and `startNewConversation`. +- Produces: `ManagedRuntimeRecoveryCard({ recoverySummary, onRetry, onStartFresh })`, returning `null` for `live`, `recovering`, `stopped`, or missing summaries and rendering one amber `role="alert"` only for `blocked` or `lost`. + +- [x] **Step 1: Write the failing behavioral test** + +Create `ManagedRuntimeRecoveryCard.test.tsx` with a minimal provider-free render of the card. Cover: `live` and `recovering` render nothing; `blocked` renders one yellow alert and “Retry recovery”, calls the supplied async retry callback once, and reports a failed retry in the same card; `lost` renders a yellow alert explaining that the existing conversation could not be recovered and an explicitly labeled “Start new conversation” button that calls only after a user click. Extend `test/unit/lib/managed-runtime-recovery.test.ts` with real merge-plan cases proving a represented stopped/lost terminal and Fresh Agent receive the lost projection while an absent lost view produces no create. Add a FreshAgentView regression fixture proving managed blocked/lost state prevents both the normal `.lost` effect and a deferred fresh verdict from arming an identity-less create. Add a reducer/component regression that clicks start-new, mints a new `createRequestId`, clears every managed projection field, then applies an old inventory snapshot and proves the lost soul is not reattached. Assert that no test path creates a session during render. + +- [x] **Step 2: Run the test and verify the intended failure** + +Run: + +```bash +pnpm run test:vitest run test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx --config config/vitest/vitest.config.ts +``` + +Expected: FAIL because the component does not yet exist. + +- [x] **Step 3: Add the minimal production implementation** + +Implement the card with this decision table and prop shape. The retry handler must own its async state: catch a rejected retry, keep the alert mounted, and show a short retry-failed message; do not discard the promise from the button handler. + +```tsx +type Props = { + recoverySummary?: ManagedRuntimeRecoverySummary + onRetry: () => Promise + onStartFresh: () => void +} + +function ManagedRuntimeRecoveryCard({ recoverySummary, onRetry, onStartFresh }: Props) { + const [retryError, setRetryError] = useState() + const [retrying, setRetrying] = useState(false) + const handleRetry = async () => { + if (retrying) return + setRetrying(true) + setRetryError(undefined) + try { + await onRetry() + } catch (error) { + setRetryError(error instanceof Error ? error.message : 'Retry failed. Try again.') + } finally { + setRetrying(false) + } + } + const summary = recoverySummary + if (!summary || !['blocked', 'lost'].includes(summary.recoveryState)) return null + const blocked = summary.recoveryState === 'blocked' + return ( +
+ {blocked + ? 'This session needs attention before it can continue.' + : 'This session could not be recovered. Start a new conversation when you are ready.'} + {blocked ? + : } + {retryError ? {retryError} : null} +
+ ) +} +``` + +In `buildManagedRuntimeMergePlan`, match and update existing pane locations for `recoveryState === 'lost'` even when `desiredState === 'stopped'`, then keep the visible-only creation path gated to desired running souls; a lost soul absent from local layout must never create a new pane. In `TerminalView`, call `retryManagedRuntimeSoul(terminalContent.soulId, terminalContent.soulIntentRevision)` and then `queueManagedRuntimeRefresh(appStore, 'pane-recovery-retry')`; render the managed card before `TerminalExitBanner` whenever it owns a projected `lost` decision, so the user sees the explicitly labeled `Start new conversation` action. A managed `blocked`/`lost` card takes precedence over the generic exit/relaunch card for that pane; unrelated launch, owner-divergence, and handoff cards keep their existing precedence. Add a lifecycle guard at every TerminalView path that responds to an invalid terminal, reconnect, reconcile, or failed attach by minting a create key or sending an identity-less create: while the current managed projection is `blocked` or `lost`, those paths must stop after preserving the pane and wait for the card's Retry or Start new conversation action. Test the effect-driven rejected-terminal path, not only render-time absence of a create. For `lost`, use an explicit start-new transition that mints a new `createRequestId`, clears the old durable identity and all managed projection fields only after the user clicks, and is covered by a refresh-after-click test. In `FreshAgentView`, guard both the `.lost` recovery effect and any deferred/reconcile callback on the current managed summary, use the same fenced retry call and inventory refresh, suppress the duplicate generic ended-session card while the managed lost card is visible, and reuse `startNewConversation` for the explicit new-conversation click. A managed `lost` or `blocked` state must retain the old session reference until the user chooses a new conversation; the explicit new-conversation transition must clear `soulId`, `incarnationId`, `runtimeState`, `viewIntentId`, `viewIntentRevision`, `soulIntentRevision`, `incidentId`, `placementGroup`, `resourceSummary`, and `recoverySummary` and mint a new `createRequestId`, while reconcile-driven same-conversation folds preserve their create key. + +- [x] **Step 4: Run the focused tests** + +Run: + +```bash +pnpm run test:vitest run \ + test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx \ + test/unit/client/components/TerminalView.launchRetry.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/lib/managed-runtime-recovery.test.ts \ + --config config/vitest/vitest.config.ts +``` + +Expected: PASS. The managed card is silent during automatic recovery, exposes only the affected pane’s decision, preserves the existing session reference until an explicit new-conversation action, and keeps existing terminal/fresh-agent tests green. + +- [x] **Step 5: Refactor while green** + +Keep the card presentational and small, share its copy and amber classes across both parents, and keep retry ownership in each parent so each API call carries the current pane’s revision fence. Ensure repeated runtime snapshots clear the card automatically when recovery becomes `live`. + +- [x] **Step 6: Run impacted-test verification** + +Run: + +```bash +pnpm run test:vitest run \ + test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx \ + test/unit/client/components/TerminalView.launchRetry.test.tsx \ + test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \ + test/unit/lib/managed-runtime-recovery.test.ts \ + test/unit/client/components/panes/HostStatsPane.test.tsx \ + --config config/vitest/vitest.config.ts +``` + +Run the primary contextual browser coverage on the configured cloud backend: + +```bash +GCLOUD_ROBOT_REQUIRE=1 pnpm run test:e2e --project=chromium --workers=1 test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts +``` + +The separate live loss qualification is local-only and requires explicit opt-in plus configured provider credentials. Run it only in the owned disposable rig: + +```bash +FRESHELL_RUNTIME_PHASE5_LIVE=1 pnpm run test:e2e:local --project=chromium --workers=1 test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts +``` + +Live credential qualification remains deferred for this landing. A run without `FRESHELL_RUNTIME_PHASE5_LIVE=1` skips the loss test and provides no loss evidence. Record any concrete fixture failure in the external ledger; do not treat deterministic cloud coverage as live provider qualification. Preserve the existing unrelated baseline failure in the run ledger. + +- [x] **Step 7: Commit the task** + +```bash +git add src/components/ManagedRuntimeRecoveryCard.tsx src/components/TerminalView.tsx src/components/fresh-agent/FreshAgentView.tsx src/lib/recovery/managed-runtime-recovery.ts src/store/panesSlice.ts test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx test/unit/client/components/TerminalView.launchRetry.test.tsx test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/lib/managed-runtime-recovery.test.ts +git commit -m "feat(ui): show managed recovery in agent panes" +``` + +The task is complete only when existing agent panes, session history, explicit new-conversation semantics, and load-only System Status remain intact. + +### Task 3: Preserve managed view intent on ordinary close and align product examples + +**Files:** +- Modify: `src/store/tabsSlice.ts` as the shared close seam used by ordinary pane close and every direct `closeTab` caller (`TabBar`, `App`, UI commands, and context menus). Add a managed-view detach helper that uses the frozen pane projection fields and preserves close failure behavior when the server does not acknowledge the visibility change. +- Modify: `src/components/panes/PaneContainer.tsx` only if its close path needs to pass managed projection data into the shared thunk; do not add a second tab-close implementation there. +- Modify: `src/lib/api.ts` if needed to parse the visibility response as a `ManagedRuntimeViewIntent`, so a failed multi-view close can roll back already-detached views with their returned revision fences. +- Modify: `test/unit/client/store/paneCloseGate.test.ts` to cover managed detach-before-close and the refusal/error path through the shared close thunks; retain `test/unit/client/components/panes/PaneContainer.test.tsx` as component regression coverage. +- Modify: `test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts` to replace dashboard Close view interaction with ordinary pane close and assert the running soul remains detached after an inventory refresh; retain Stop agent coverage through the existing terminal shift-close path or rig action as appropriate. +- Modify: `test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts` to stop expecting a routine success notice and instead assert the actionable cleanup-failure or pane-local error path actually rendered; retain incident persistence, exact cleanup, identity, and receipt assertions consistent with what the UI displays. +- Modify: `docs/index.html` to remove the routine “Restarting agent”/cleanup-notice mock and show the contextual amber intervention card in the affected pane. + +**Interfaces:** +- Consumes: managed pane projection fields (`viewIntentId`, `viewIntentRevision`, `soulIntentRevision`), `updateManagedRuntimeViewVisibility`, existing pane close acknowledgements, and existing browser helpers. +- Produces: ordinary pane/tab close transactionally detaches managed views before layout removal, while unmanaged pane close behavior remains unchanged. + +- [x] **Step 1: Write the failing behavioral test** + +Add a focused close test with a managed terminal pane carrying a view ID and both revision values. Assert the close evidence is confirmed first, then the visibility PATCH is sent with `detached` and the current revisions, and only then does the close thunk remove the pane. Assert a visibility refusal leaves the pane visible, reasserts the pane-open evidence, and exposes its existing close error surface; if a multi-view tab close detached an earlier view before a later refusal, assert the helper rolls that view back to `visible` using the response revision before leaving the tab in place. Add a browser assertion that the affected managed view is not recreated after a later inventory refresh. + +- [x] **Step 2: Run the test and verify the intended failure** + +Run: + +```bash +pnpm run test:vitest run test/unit/client/store/paneCloseGate.test.ts --config config/vitest/vitest.config.ts +``` + +Expected: FAIL because ordinary close currently journals pane removal without updating the managed view intent’s visibility. + +- [x] **Step 3: Add the minimal production implementation** + +In the shared `tabsSlice` close flow, after the existing close evidence succeeds and before the reducer removes the frozen pane/tab, send `updateManagedRuntimeViewVisibility(viewIntentId, 'detached', viewIntentRevision, soulIntentRevision)` for every managed view in that frozen layout. Await every acknowledgement. If any detach refuses or times out, roll back each already-detached view to `visible` using the returned view revision and the unchanged soul revision, reassert the pane-open evidence, surface the existing close error on the kept pane(s), and return without removing layout state. Only after all managed detaches succeed may `closePaneWithCleanup` or `closeTab` commit the existing removal. Keep terminal detach and Fresh Agent kill/close sequencing intact, and skip the managed PATCH for panes without a view intent. Because `TabBar`, `App`, UI commands, and context menus already dispatch `closeTab`, the shared thunk covers ordinary tab close as well as the pane path. + +- [x] **Step 4: Run the focused test** + +Run the command from Step 2. + +Expected: PASS, including unchanged unmanaged close behavior. + +- [x] **Step 5: Refactor while green** + +Keep the managed-detach operation in one helper used by pane and tab close paths, preserve revision fencing, make its rollback explicit and testable, and avoid reintroducing a global stop/detach control surface. + +- [x] **Step 6: Run impacted-test verification** + +Run: + +```bash +pnpm run test:vitest run \ + test/unit/client/store/paneCloseGate.test.ts \ + test/unit/client/components/panes/PaneContainer.test.tsx \ + test/unit/client/components/panes/PaneContainer.createContent.test.tsx \ + test/unit/client/components/ManagedRuntimeNotices.test.tsx \ + test/unit/lib/managed-runtime-recovery.test.ts \ + --config config/vitest/vitest.config.ts +``` + +Run the owned real supervisor/Docker shell rehydration rig explicitly; this spec is excluded from cloud selection and needs no provider credentials: + +```bash +pnpm run test:e2e:local --project=chromium --workers=1 test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts +``` + +Run the configured cloud contextual spec from Task 2 for rendered pane decisions. The separate live loss command from Task 2 includes `FRESHELL_RUNTIME_PHASE5_LIVE=1` and remains deferred pending provider credential qualification. Do not weaken the local rig’s backend identity and cleanup assertions because the dashboard was removed. If the local fixture cannot run, record the concrete environment failure and leave that qualification unverified rather than treating a cloud run as equivalent coverage. + +- [x] **Step 7: Commit the task** + +```bash +git add src/store/tabsSlice.ts src/lib/api.ts src/components/panes/PaneContainer.tsx test/unit/client/store/paneCloseGate.test.ts test/unit/client/components/panes/PaneContainer.test.tsx test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts docs/index.html +git commit -m "fix(ui): preserve managed view intent on close" +``` + +## Landing continuation + +The September 29 implementation and independent delta review completed at `c2907a4e0`; the old broad gate stopped on the pre-existing OpenCode readiness test. On October 2, all 13 commits rebased cleanly onto `091b24bf0` with unchanged patches (`git range-diff`). The readiness regression now covers the configured GPT-5.6 Luna and previous Big Pickle banners, ANSI styling, and inactive/missing input mode instead of the obsolete free-model requirement. The updated focused suites passed 668 tests, and client typechecking passed. Full-suite and browser evidence will be recorded in the external run ledger before landing. diff --git a/scripts/testing/runtime-phase5-loss-evidence.ts b/scripts/testing/runtime-phase5-loss-evidence.ts index 6fc6eec2b..eb00f6d0a 100644 --- a/scripts/testing/runtime-phase5-loss-evidence.ts +++ b/scripts/testing/runtime-phase5-loss-evidence.ts @@ -41,7 +41,8 @@ export type Phase5LossSummary = { incarnationId: string incidentId: string exactCleanupVerified: true - displayedNoticeCount: 1 + routineNoticeCount: 0 + actionableRecoveryCardCount: 1 foreignObjectsTouched: 0 } @@ -81,7 +82,8 @@ export function buildPhase5LossReceipt(input: { path.join(input.evidenceDir, PHASE5_LOSS_ASSERTIONS_FILE), 'utf8', )) - const identity = assertions.identity + const identity = validateIdentity(assertions.identity) + const browserSummary = validateLossBrowser(assertions.browser, identity) const receipt = { schemaVersion: 2, kind: 'phase5_loss', @@ -96,15 +98,7 @@ export function buildPhase5LossReceipt(input: { failures: [], }, artifacts: refs, - summary: { - provider: identity.provider, - soulId: identity.soulId, - incarnationId: identity.incarnationId, - incidentId: identity.incidentId, - exactCleanupVerified: true, - displayedNoticeCount: 1, - foreignObjectsTouched: 0, - }, + summary: lossSummary(identity, browserSummary), } validatePhase5LossReceipt({ ...input, receipt }) return receipt @@ -131,7 +125,7 @@ export function validatePhase5LossReceipt(input: { 'schemaVersion', 'caseId', 'candidateSha', 'receiptRunId', 'test', 'identity', 'intent', 'providerState', 'browser', ], 'loss assertion artifact') - if (assertions.schemaVersion !== 1 || assertions.caseId !== 'P5-G02') { + if (assertions.schemaVersion !== 2 || assertions.caseId !== 'P5-G02') { throw new Error('loss assertion artifact has the wrong schema or case') } equalString(assertions.candidateSha, input.candidateSha, 'loss assertions candidate SHA') @@ -146,31 +140,36 @@ export function validatePhase5LossReceipt(input: { throw new Error('loss was not bound to the exact checked intent revision') } validateProviderState(assertions.providerState) - validateEndedPane(assertions.browser, identity) + const browserSummary = validateLossBrowser(assertions.browser, identity) const capability = capabilityFor(loaded.json.capabilityInventory, identity.provider) const incident = validateIncident(loaded.json.incident, identity, capability, checkedRevision, input) - const displayedNoticeIds = object(assertions.browser, 'loss browser evidence').displayedNoticeIds - if (displayedNoticeIds[0] !== loaded.json.incident.noticeId) { - throw new Error('displayed loss notice does not match the durable incident notice') - } validateLifecycle(loaded.jsonl.lifecycle, identity, incident.certificateSha256) validateBrokerDestructiveTargets(loaded.jsonl.broker, identity.containerId) validateCleanupContainsContainer(loaded.json.cleanup, identity.containerId) - const summary: Phase5LossSummary = { + const summary = lossSummary(identity, browserSummary) + if (stableJson(loaded.receipt.summary) !== stableJson(summary)) { + throw new Error('loss receipt summary differs from evidence-derived summary') + } + return { ...loaded, summary } +} + +type LossBrowserSummary = Pick + +function lossSummary( + identity: ReturnType, + browser: LossBrowserSummary, +): Phase5LossSummary { + return { provider: identity.provider, soulId: identity.soulId, incarnationId: identity.incarnationId, incidentId: identity.incidentId, exactCleanupVerified: true, - displayedNoticeCount: 1, + ...browser, foreignObjectsTouched: 0, } - if (stableJson(loaded.receipt.summary) !== stableJson(summary)) { - throw new Error('loss receipt summary differs from evidence-derived summary') - } - return { ...loaded, summary } } export function phase5LossRetainedBundle( @@ -215,6 +214,7 @@ function validateIdentity(value: unknown): { containerId: string nativeSessionIdHash: string incidentId: string + paneId: string } { const identity = object(value, 'loss identity') exactKeys(identity, [ @@ -271,17 +271,29 @@ function validateProviderState(value: unknown): void { } } -function validateEndedPane(browserValue: unknown, identity: ReturnType): void { +function validateLossBrowser( + browserValue: unknown, + identity: ReturnType, +): LossBrowserSummary { const browser = object(browserValue, 'loss browser evidence') - exactKeys(browser, ['displayedNoticeIds', 'endedPane'], 'loss browser evidence') - const notices = array(browser.displayedNoticeIds, 'displayed notice ids') - if (notices.length !== 1) throw new Error('loss browser evidence must display exactly one notice') + exactKeys(browser, ['routineNoticeCount', 'recoveryCards', 'endedPane'], 'loss browser evidence') + const routineNoticeCount = integer(browser.routineNoticeCount, 'routine notice count') + if (routineNoticeCount !== 0) throw new Error('loss browser evidence must suppress routine popups') + const cards = array(browser.recoveryCards, 'loss recovery cards') + const actionableRecoveryCardCount = cards.length + if (actionableRecoveryCardCount !== 1) throw new Error('loss browser evidence must display exactly one recovery card') + const card = object(cards[0], 'loss recovery card') + exactKeys(card, ['paneId', 'lossMessageVisible', 'startNewConversationEnabled'], 'loss recovery card') + if (card.paneId !== identity.paneId) throw new Error('loss recovery card must belong to the exact lost pane') + if (card.lossMessageVisible !== true) throw new Error('loss recovery card must display the loss message') + if (card.startNewConversationEnabled !== true) throw new Error('loss recovery card must be actionable') const pane = object(browser.endedPane, 'ended pane evidence') exactKeys(pane, ['soulId', 'incarnationId', 'incidentId', 'nativeSessionIdHash', 'recoveryState'], 'ended pane evidence') for (const key of ['soulId', 'incarnationId', 'incidentId', 'nativeSessionIdHash'] as const) { if (pane[key] !== identity[key]) throw new Error(`ended pane does not retain exact ${key}`) } if (pane.recoveryState !== 'lost') throw new Error('ended pane is not retained in lost state') + return { routineNoticeCount, actionableRecoveryCardCount } } function capabilityFor(inventory: Record, provider: string): Record { @@ -307,6 +319,7 @@ function validateIncident( 'cleanup', 'noticeId', 'updatedAt', ], 'loss incident artifact') equalString(value.incidentId, identity.incidentId, 'loss incident id') + nonEmptyString(value.noticeId, 'durable loss incident notice id') if (value.event !== 'soul.loss.finalized' || value.cleanupState !== 'closed') throw new Error('loss incident is not a closed final incident') const certificate = object(value.certificate, 'loss certificate') exactKeys(certificate, [ diff --git a/scripts/testing/runtime-test-broker.ts b/scripts/testing/runtime-test-broker.ts index 5cbe28f81..09cd50544 100644 --- a/scripts/testing/runtime-test-broker.ts +++ b/scripts/testing/runtime-test-broker.ts @@ -17,6 +17,7 @@ export type BrokerReceipt = { runtimeDir: string providerVolumeName?: string workspacePath?: string + hostBinaryPath?: string } export type BrokerEvent = { @@ -26,6 +27,10 @@ export type BrokerEvent = { decision: 'forward' | 'block' | 'inject_failure' reason?: string containerId?: string + ownerContainerId?: string + helperName?: string + requestDigest?: string + providerVolumeName?: string destructive: boolean unsafeAttempt: boolean } @@ -53,6 +58,7 @@ type DockerResponse = { export class RestrictedDockerBroker { private readonly knownContainerIds = new Set() private readonly receiptsById = new Map() + private readonly historyHelpers = new Map() private readonly events: BrokerEvent[] = [] private server?: http.Server private stopFailuresRemaining = 0 @@ -68,7 +74,7 @@ export class RestrictedDockerBroker { } receiptIds(): Set { - return new Set(this.knownContainerIds) + return new Set([...this.knownContainerIds, ...[...this.historyHelpers.values()].flatMap((helper) => helper.containerId ? [helper.containerId] : [])]) } eventsSnapshot(): BrokerEvent[] { @@ -132,7 +138,64 @@ export class RestrictedDockerBroker { return } + const volume = url.match(/^\/v1\.47\/volumes\/(freshell-provider-[a-f0-9]{24})$/)?.[1] + if (method === 'GET' && volume && this.receipts().some((receipt) => receipt.providerVolumeName === volume)) { + await this.forwardAndReply(request, response, body, { destructive: false }) + return + } + + const helperTarget = url.match(/^\/v1\.47\/containers\/([^/?]+)(?:\/(start|wait|logs))?(?:\?.*)?$/) + const helperEntry = helperTarget && [...this.historyHelpers].find(([name, helper]) => ( + helperTarget[1] === name || helperTarget[1] === helper.containerId + )) + if (helperEntry) { + const [name, helper] = helperEntry + const exactId = helperTarget![1] === helper.containerId + const allowed = (method === 'POST' && exactId && url === `${DOCKER_API_PREFIX}/containers/${helper.containerId}/start`) + || (method === 'POST' && exactId && url === `${DOCKER_API_PREFIX}/containers/${helper.containerId}/wait?condition=not-running`) + || (method === 'GET' && exactId && [0, 1].some((stderr) => url === `${DOCKER_API_PREFIX}/containers/${helper.containerId}/logs?stdout=1&stderr=${stderr}`)) + || (method === 'DELETE' && url === `${DOCKER_API_PREFIX}/containers/${helperTarget![1]}?force=1`) + if (allowed) { + const forwarded = await this.forward(request, body) + this.record({ method, url, decision: 'forward', destructive: method === 'DELETE' || method === 'POST', unsafeAttempt: false, + containerId: helper.containerId, helperName: name, ownerContainerId: helper.owner.containerId, providerVolumeName: helper.owner.providerVolumeName }) + replyDocker(response, forwarded) + return + } + } + if (method === 'POST' && url.startsWith(`${DOCKER_API_PREFIX}/containers/create?`)) { + const name = new URL(url, 'http://docker').searchParams.get('name') ?? '' + if (name.startsWith('freshell-history-')) { + const owner = this.validateHistoryHelper(name, body) + if (!owner || this.historyHelpers.has(name)) { + this.block(response, method, url, 403, 'history helper does not match an owned read-only source', false) + return + } + // Reserve the exact validated name before forwarding: Docker may create it but lose its acknowledgement. + const helper: { owner: BrokerReceipt; containerId?: string } = { owner } + this.historyHelpers.set(name, helper) + try { + const forwarded = await this.forward(request, body) + if (forwarded.statusCode === 201) { + const parsed = JSON.parse(forwarded.body.toString('utf8')) as { Id?: unknown } + if (typeof parsed.Id !== 'string' || !/^[0-9a-f]{64}$/.test(parsed.Id) || this.receiptIds().has(parsed.Id)) { + throw new Error('Docker returned an invalid or already owned history helper id') + } + helper.containerId = parsed.Id + } + this.record({ method, url, decision: 'forward', destructive: false, unsafeAttempt: false, + containerId: helper.containerId, helperName: name, requestDigest: sha256(body), ownerContainerId: owner.containerId, providerVolumeName: owner.providerVolumeName }) + replyDocker(response, forwarded) + } catch (error) { + this.record({ method, url, decision: 'forward', reason: `history helper create acknowledgement unavailable: ${String(error)}`, + destructive: false, unsafeAttempt: false, helperName: name, requestDigest: sha256(body), ownerContainerId: owner.containerId, providerVolumeName: owner.providerVolumeName }) + response.statusCode = 503 + response.end(JSON.stringify({ message: 'history helper create acknowledgement unavailable' })) + } + return + } + const validation = this.validateCreate(body) if (!validation.ok) { this.block(response, method, url, 403, validation.reason, true, undefined) @@ -155,6 +218,7 @@ export class RestrictedDockerBroker { soulId: validation.soulId, imageRef: validation.imageRef, runtimeDir: validation.runtimeDir, + hostBinaryPath: validation.hostBinaryPath, ...(validation.providerVolumeName ? { providerVolumeName: validation.providerVolumeName } : {}), ...(validation.workspacePath ? { workspacePath: validation.workspacePath } : {}), }) @@ -196,7 +260,7 @@ export class RestrictedDockerBroker { } private validateCreate(body: Buffer): - | { ok: true; incarnationId: string; soulId: string; imageRef: string; runtimeDir: string; providerVolumeName?: string; workspacePath?: string } + | { ok: true; incarnationId: string; soulId: string; imageRef: string; runtimeDir: string; hostBinaryPath: string; providerVolumeName?: string; workspacePath?: string } | { ok: false; reason: string } { let parsed: Record try { @@ -265,7 +329,7 @@ export class RestrictedDockerBroker { const binds = Array.isArray(host.Binds) ? host.Binds as string[] : [] if (!terminalWorkload && binds.length !== 3) return { ok: false, reason: `expected binary, runtime, and soul provider-volume fixture binds, found ${binds.length}` } - let binaryBind = false + let hostBinaryPath = '' let runtimeDir = '' let providerVolumeName = '' let workspacePath = '' @@ -276,7 +340,7 @@ export class RestrictedDockerBroker { const destination = parts.pop() ?? '' const source = parts.join(':') if (destination === '/runtime/freshell-session-host' && mode === 'ro' && this.policy.allowedHostBinaryPaths.has(source)) { - binaryBind = true + hostBinaryPath = source continue } if (destination === '/run/freshell' && mode === 'rw' && isStrictDescendant(source, this.policy.runtimeRootPrefix)) { @@ -314,7 +378,7 @@ export class RestrictedDockerBroker { } return { ok: false, reason: `unapproved bind ${bind}` } } - if (!binaryBind || !runtimeDir || !providerVolumeName) return { ok: false, reason: 'required binary/runtime/provider-volume bind topology missing' } + if (!hostBinaryPath || !runtimeDir || !providerVolumeName) return { ok: false, reason: 'required binary/runtime/provider-volume bind topology missing' } if (terminalWorkload && !workspacePath) return { ok: false, reason: 'terminal workload missing approved workspace bind' } const actorKey = createHash('sha256').update(`${installationId}\0${soulId}`).digest('hex') const expectedActorStateDir = path.join(path.dirname(runtimeDir), 'souls', actorKey, 'actor') @@ -327,7 +391,37 @@ export class RestrictedDockerBroker { if (binds.some((bind) => bind.includes('docker.sock') || bind.includes('/var/lib/freshell-supervisor') || bind.includes('/run/freshell-supervisor'))) { return { ok: false, reason: 'management-state mount is forbidden' } } - return { ok: true, incarnationId, soulId, imageRef, runtimeDir, ...(providerVolumeName ? { providerVolumeName } : {}), ...(workspacePath ? { workspacePath } : {}) } + return { ok: true, incarnationId, soulId, imageRef, runtimeDir, hostBinaryPath, ...(providerVolumeName ? { providerVolumeName } : {}), ...(workspacePath ? { workspacePath } : {}) } + } + + private validateHistoryHelper(name: string, body: Buffer): BrokerReceipt | undefined { + if (!/^freshell-history-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(name)) return + let parsed: Record + try { parsed = JSON.parse(body.toString('utf8')) } catch { return } + const host = parsed.HostConfig ?? {} + if (Object.keys(parsed.Labels ?? {}).length !== 0) return + const same = (actual: unknown, expected: unknown) => JSON.stringify(actual) === JSON.stringify(expected) + if (parsed.User !== '65534:0' || parsed.Tty !== true + || !same(parsed.Entrypoint, ['/runtime/freshell-session-host']) + || !same(parsed.Env, ['HOME=/home/freshell/provider'])) return + const cmd = parsed.Cmd + if (!Array.isArray(cmd) || cmd.length !== 7 || cmd[0] !== 'native-history-only' + || cmd[1] !== '--provider' || !['claude', 'kilroy', 'codex', 'opencode'].includes(cmd[2]) + || cmd[3] !== '--session-id' || typeof cmd[4] !== 'string' || !cmd[4] + || cmd[5] !== '--provider-home' || cmd[6] !== '/home/freshell/provider') return + if (host.NetworkMode !== 'none' || host.ReadonlyRootfs !== true || host.Privileged === true + || (host.PidMode ?? '') !== '' || (host.Binds?.length ?? 0) !== 0 || (host.CapAdd?.length ?? 0) !== 0 + || !same(host.CapDrop, ['ALL']) || !same(host.SecurityOpt, ['no-new-privileges']) + || host.Memory !== 256 * 1024 * 1024 || host.MemorySwap !== 256 * 1024 * 1024 + || host.NanoCpus !== 500_000_000 || host.PidsLimit !== 32 + || !same(host.Tmpfs, { '/tmp': 'rw,noexec,nosuid,nodev,size=16m' })) return + const mounts = host.Mounts + if (!Array.isArray(mounts) || mounts.length !== 2) return + const binary = mounts.find((mount) => mount.Type === 'bind' && mount.Target === '/runtime/freshell-session-host' && mount.ReadOnly === true) + const volume = mounts.find((mount) => mount.Type === 'volume' && mount.Target === '/home/freshell/provider' && mount.ReadOnly === true) + if (!binary || !volume || !this.policy.allowedHostBinaryPaths.has(binary.Source) || !this.policy.allowedImageRefs.has(parsed.Image)) return + return this.receipts().find((receipt) => receipt.providerVolumeName === volume.Source + && receipt.imageRef === parsed.Image && receipt.hostBinaryPath === binary.Source) } private isAllowedWorkspacePath(candidate: string): boolean { diff --git a/shared/managed-runtime.ts b/shared/managed-runtime.ts index 36d76610d..d542274c6 100644 --- a/shared/managed-runtime.ts +++ b/shared/managed-runtime.ts @@ -98,6 +98,10 @@ export const ManagedRuntimeSoulSchema = z.object({ terminalCwd: z.string().optional(), terminalCreateRequestId: z.string().optional(), terminalResumeSessionId: z.string().optional(), + freshAgentSessionId: z.string().optional(), + freshAgentCreateRequestId: z.string().optional(), + freshAgentSessionType: z.string().optional(), + freshAgentRuntimeVariant: z.string().optional(), projectKey: z.string().optional(), profile: z.enum(['default_agent', 'test_fixture', 'custom']).optional(), desiredState: ManagedRuntimeDesiredStateSchema, diff --git a/src/App.tsx b/src/App.tsx index e2b6cf569..867d069c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -97,7 +97,6 @@ import { ReconcileWarmingBanner } from '@/components/ReconcileWarmingBanner' import { SetupWizard } from '@/components/SetupWizard' import { RecoveryOfferPanel } from '@/components/RecoveryOfferPanel' import { MachineChooser } from '@/components/MachineChooser' -import { ManagedAgentRecoveryStatus } from '@/components/ManagedAgentRecoveryStatus' import { ManagedRuntimeNotices } from '@/components/ManagedRuntimeNotices' import VirtualDeckPanel from '@/components/VirtualDeckPanel' import { ErrorBoundary } from '@/components/ui/error-boundary' @@ -2526,7 +2525,6 @@ pnpm run serve`} {/* A server-owned machine hydrates its scoped durable workspace during bootstrap. Legacy servers retain the older opt-in recovery panel. */} {machineIdentity?.mode !== 'server-managed' ? : null} - {/* In-app Stream Deck emulator — self-hides unless deck.virtualDeckOpen */} diff --git a/src/components/AgentResourceLimits.tsx b/src/components/AgentResourceLimits.tsx deleted file mode 100644 index 1e00af60a..000000000 --- a/src/components/AgentResourceLimits.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useEffect, useMemo, useState, type FormEvent } from 'react' -import type { ManagedRuntimeMetrics, ManagedRuntimeSoul } from '@shared/managed-runtime' -import { - getManagedRuntimeSoul, - updateManagedRuntimeLimits, -} from '@/lib/api' - -const MIB = 1024 * 1024 - -function wholeNumber(value: number | undefined, fallback: number): string { - return String(Math.max(0, Math.round(value ?? fallback))) -} - -function formatBytes(bytes: number | undefined): string { - if (bytes === undefined) return 'Not reported' - if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB` - return `${(bytes / MIB).toFixed(0)} MiB` -} - -function formatCpu(milli: number | undefined): string { - return milli === undefined ? 'Not reported' : `${milli} millicores` -} - -function formatPids(pids: number | undefined): string { - return pids === undefined ? 'Not reported' : `${pids} processes` -} - -function Usage({ metrics }: { metrics?: ManagedRuntimeMetrics }) { - if (!metrics) return Not reported - return ( - - {formatBytes(metrics.memoryCurrentBytes)} memory · {metrics.pidsCurrent} processes ·{' '} - {(metrics.cpuUsageUsec / 1_000_000).toFixed(1)} CPU seconds - - ) -} - -export function AgentResourceLimits({ - soul, - onSaved, -}: { - soul: ManagedRuntimeSoul - onSaved: () => void | Promise -}) { - const source = soul.configuredLimits ?? soul.effectiveLimits - const [cpuMilli, setCpuMilli] = useState(() => wholeNumber(source?.cpuMilli, 2_000)) - const [memoryMiB, setMemoryMiB] = useState(() => wholeNumber( - source ? source.memoryBytes / MIB : undefined, - 4_096, - )) - const [swapMiB, setSwapMiB] = useState(() => wholeNumber( - source ? source.swapBytes / MIB : undefined, - 0, - )) - const [pidsMax, setPidsMax] = useState(() => wholeNumber(source?.pidsMax, 512)) - const [actual, setActual] = useState() - const [saving, setSaving] = useState(false) - const [message, setMessage] = useState() - - useEffect(() => { - const next = soul.configuredLimits ?? soul.effectiveLimits - setCpuMilli(wholeNumber(next?.cpuMilli, 2_000)) - setMemoryMiB(wholeNumber(next ? next.memoryBytes / MIB : undefined, 4_096)) - setSwapMiB(wholeNumber(next ? next.swapBytes / MIB : undefined, 0)) - setPidsMax(wholeNumber(next?.pidsMax, 512)) - }, [soul.soulId, soul.intentRevision, soul.configuredLimits, soul.effectiveLimits]) - - useEffect(() => { - let cancelled = false - getManagedRuntimeSoul(soul.soulId) - .then((detail) => { - if (!cancelled) setActual(detail.actualUsage ?? undefined) - }) - .catch(() => { - if (!cancelled) setActual(undefined) - }) - return () => { cancelled = true } - }, [soul.soulId, soul.incarnationId]) - - const validation = useMemo(() => { - const values = [Number(cpuMilli), Number(memoryMiB), Number(swapMiB), Number(pidsMax)] - if (!values.every(Number.isSafeInteger)) return 'Limits must be whole numbers.' - if (values[0] <= 0) return 'CPU must be greater than zero.' - if (values[1] <= 0) return 'Memory must be greater than zero.' - if (values[2] < 0) return 'Swap cannot be negative.' - if (values[3] <= 0) return 'PID limit must be greater than zero.' - return undefined - }, [cpuMilli, memoryMiB, swapMiB, pidsMax]) - - const submit = async (event: FormEvent) => { - event.preventDefault() - if (validation || saving) return - setSaving(true) - setMessage(undefined) - try { - const result = await updateManagedRuntimeLimits( - soul.soulId, - soul.intentRevision, - { - cpuMilli: Number(cpuMilli), - memoryBytes: Number(memoryMiB) * MIB, - swapBytes: Number(swapMiB) * MIB, - pidsMax: Number(pidsMax), - }, - ) - setMessage( - result.application === 'applied_now' - ? 'Limits applied to the running agent.' - : 'Limits saved. They will apply to the next agent incarnation.', - ) - await onSaved() - } catch (error) { - setMessage(error instanceof Error ? error.message : String(error)) - } finally { - setSaving(false) - } - } - - return ( -
- Resource limits and usage -
-
-
Configured:
-
- {formatCpu(soul.configuredLimits?.cpuMilli)} ·{' '} - {formatBytes(soul.configuredLimits?.memoryBytes)} ·{' '} - {formatPids(soul.configuredLimits?.pidsMax)} -
-
-
-
Effective:
-
- {formatCpu(soul.effectiveLimits?.cpuMilli)} ·{' '} - {formatBytes(soul.effectiveLimits?.memoryBytes)} ·{' '} - {formatPids(soul.effectiveLimits?.pidsMax)} -
-
-
-
Actual:
-
-
-
-
- - - - -
- - {validation} - - -
- {message && ( -

- {message} -

- )} -
-
- ) -} diff --git a/src/components/ManagedAgentRecoveryStatus.tsx b/src/components/ManagedAgentRecoveryStatus.tsx deleted file mode 100644 index 16dbbaa77..000000000 --- a/src/components/ManagedAgentRecoveryStatus.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { useMemo, useState } from 'react' -import { selectManagedRuntime } from '@/store/managedRuntimeSlice' -import type { ManagedRuntimeSoul, ManagedRuntimeViewIntent } from '@shared/managed-runtime' -import { useAppDispatch, useAppSelector, useAppStore } from '@/store/hooks' -import { - getManagedRuntimeIncidentSummary, - retryManagedRuntimeSoul, - stopManagedRuntimeSoul, - updateManagedRuntimeViewVisibility, -} from '@/lib/api' -import { queueManagedRuntimeRefresh } from '@/lib/recovery/managed-runtime-recovery' -import { closeTab } from '@/store/tabsSlice' -import { AgentResourceLimits } from '@/components/AgentResourceLimits' - -export type ManagedAgentStatusLabel = - | 'Reconnecting' - | 'Restarting agent' - | 'Recovery blocked' - | 'Lost' - | 'Ready' - | 'Stopped' - -export function managedAgentStatusLabel( - connectionStatus: string, - soul: ManagedRuntimeSoul, -): ManagedAgentStatusLabel { - if (connectionStatus !== 'ready') return 'Reconnecting' - if (soul.recoveryState === 'lost') return 'Lost' - if (soul.desiredState === 'stopped' || soul.recoveryState === 'stopped') return 'Stopped' - if (soul.recoveryState === 'blocked') return 'Recovery blocked' - if (soul.recoveryState === 'recovering' || soul.launchState !== 'running') { - return 'Restarting agent' - } - return 'Ready' -} - -function latestSoulRows(souls: ManagedRuntimeSoul[]): ManagedRuntimeSoul[] { - const latest = new Map() - for (const soul of souls) latest.set(soul.soulId, soul) - return [...latest.values()].sort((left, right) => left.soulId.localeCompare(right.soulId)) -} - -function providerLabel(provider?: string): string { - switch (provider) { - case 'claude': return 'Claude' - case 'codex': return 'Codex' - case 'opencode': return 'OpenCode' - case 'amplifier': return 'Amplifier' - case 'shell': return 'Shell' - default: return provider || 'Managed agent' - } -} - -function viewsForSoul( - views: ManagedRuntimeViewIntent[], - soulId: string, -): ManagedRuntimeViewIntent[] { - return views - .filter((view) => view.soulId === soulId && view.visibility !== 'hidden') - .sort((left, right) => left.viewId.localeCompare(right.viewId)) -} - -export function ManagedAgentRecoveryStatus() { - const dispatch = useAppDispatch() - const store = useAppStore() - const connectionStatus = useAppSelector((state) => state.connection.status) - const runtime = useAppSelector(selectManagedRuntime) - const tabs = useAppSelector((state) => state.tabs.tabs) - const [pending, setPending] = useState() - const [message, setMessage] = useState() - - const souls = useMemo(() => latestSoulRows(runtime.souls), [runtime.souls]) - if (!runtime.available) return null - - const labels = souls.map((soul) => managedAgentStatusLabel(connectionStatus, soul)) - const attention = labels.some((label) => label !== 'Ready') - const readyCount = labels.filter((label) => label === 'Ready').length - - const refresh = (reason: string) => queueManagedRuntimeRefresh(store, reason) - - const run = async (key: string, operation: () => Promise) => { - if (pending) return - setPending(key) - setMessage(undefined) - try { - await operation() - await refresh(key) - } catch (error) { - setMessage(error instanceof Error ? error.message : String(error)) - } finally { - setPending(undefined) - } - } - - const closeView = async (view: ManagedRuntimeViewIntent) => { - await run(`close-view:${view.viewId}`, async () => { - await updateManagedRuntimeViewVisibility( - view.viewId, - 'detached', - view.revision, - view.soulIntentRevision, - ) - const tab = tabs.find((candidate) => candidate.viewIntentId === view.viewId) - if (tab) await dispatch(closeTab(tab.id)).unwrap() - }) - } - - return ( - - ) -} diff --git a/src/components/ManagedRuntimeNotices.tsx b/src/components/ManagedRuntimeNotices.tsx index 9273b389c..9db953f13 100644 --- a/src/components/ManagedRuntimeNotices.tsx +++ b/src/components/ManagedRuntimeNotices.tsx @@ -8,9 +8,16 @@ import { recordManagedRuntimeNoticeReceipt, } from '@/lib/api' import { useAppSelector } from '@/store/hooks' +import { createLogger } from '@/lib/client-logger' -const AUTO_ACK_MS = 10_000 const POLL_MS = 2_000 +const log = createLogger('ManagedRuntimeNotices') + +type NoticeDetails = { + noticeId: string + profileId: string + summary: ManagedRuntimeIncidentSummary +} function noticeProfileId(deviceId: string | undefined): string { return `profile:${deviceId || 'local-user'}` @@ -22,6 +29,14 @@ function cleanupLabel(summary: ManagedRuntimeIncidentSummary): string { return 'Cleanup could not be verified; no unrelated process was touched.' } +function isRoutineNotice(notice: ManagedRuntimeNotice): boolean { + return notice.kind === 'cleanup_succeeded' || notice.kind === 'ended_without_process' +} + +function isCleanupFailureNotice(notice: ManagedRuntimeNotice): boolean { + return notice.kind === 'cleanup_failed' +} + export function ManagedRuntimeNotices() { const connectionStatus = useAppSelector((state) => state.connection.status) const available = useAppSelector((state) => selectManagedRuntime(state).available) @@ -29,10 +44,31 @@ export function ManagedRuntimeNotices() { const deviceId = useAppSelector((state) => state.tabRegistry?.deviceId) const profileId = useMemo(() => noticeProfileId(deviceId), [deviceId]) const [notices, setNotices] = useState([]) - const [details, setDetails] = useState() + const current = notices[0] + const [loadedDetails, setLoadedDetails] = useState() + const details = loadedDetails + && loadedDetails.noticeId === current?.noticeId + && loadedDetails.profileId === profileId + ? loadedDetails.summary + : undefined const [error, setError] = useState() const [pollTick, setPollTick] = useState(0) const inFlightRef = useRef() + const acknowledgedRoutineIdsRef = useRef(new Set()) + const renderedFailureIdsRef = useRef(new Set()) + // Polls replace notice objects; the stable notice/profile identity owns + // opened details and any response still pending when the warning changes. + const currentNoticeRef = useRef({ noticeId: current?.noticeId, profileId }) + currentNoticeRef.current = { noticeId: current?.noticeId, profileId } + + useEffect(() => { + setLoadedDetails(undefined) + }, [current?.noticeId, profileId]) + + useEffect(() => { + acknowledgedRoutineIdsRef.current.clear() + renderedFailureIdsRef.current.clear() + }, [profileId]) useEffect(() => { if (!available || connectionStatus !== 'ready') return @@ -52,16 +88,42 @@ export function ManagedRuntimeNotices() { getManagedRuntimeNotices(profileId, 20, { signal: controller.signal }) .then(async (pending) => { if (cancelled) return - setNotices(pending) - setDetails(undefined) + const routine = pending.filter(isRoutineNotice) + const failures = pending.filter(isCleanupFailureNotice) + setNotices(failures) setError(undefined) - await Promise.allSettled(pending.map((notice) => ( - recordManagedRuntimeNoticeReceipt(notice.noticeId, profileId, 'rendered') - ))) + const routineToAcknowledge = routine.filter((notice) => { + if (acknowledgedRoutineIdsRef.current.has(notice.noticeId)) return false + acknowledgedRoutineIdsRef.current.add(notice.noticeId) + return true + }) + const failuresToMarkRendered = failures.filter((notice) => { + if (notice.deliveryState !== 'pending' || renderedFailureIdsRef.current.has(notice.noticeId)) { + return false + } + renderedFailureIdsRef.current.add(notice.noticeId) + return true + }) + await Promise.allSettled([ + ...routineToAcknowledge.map(async (notice) => { + try { + await recordManagedRuntimeNoticeReceipt(notice.noticeId, profileId, 'acknowledged') + } catch { + acknowledgedRoutineIdsRef.current.delete(notice.noticeId) + } + }), + ...failuresToMarkRendered.map(async (notice) => { + try { + await recordManagedRuntimeNoticeReceipt(notice.noticeId, profileId, 'rendered') + } catch { + renderedFailureIdsRef.current.delete(notice.noticeId) + } + }), + ]) }) .catch((cause) => { - if (cancelled || isTransientRequestFailure(cause)) return - setError(cause instanceof Error ? cause.message : String(cause)) + if (cancelled || controller.signal.aborted || isTransientRequestFailure(cause)) return + log.warn({ event: 'managed_runtime_notices_fetch_failed', profileId, err: cause }) }) return () => { cancelled = true @@ -69,32 +131,12 @@ export function ManagedRuntimeNotices() { } }, [available, connectionStatus, inventoryRevision, pollTick, profileId]) - const current = notices[0] - const currentNoticeId = current?.noticeId - - useEffect(() => { - if (!currentNoticeId) return - const timer = window.setTimeout(() => { - void recordManagedRuntimeNoticeReceipt(currentNoticeId, profileId, 'acknowledged') - .then(() => { - setNotices((existing) => existing.filter((notice) => notice.noticeId !== currentNoticeId)) - setDetails(undefined) - }) - .catch((cause) => { - if (!isTransientRequestFailure(cause)) { - setError(cause instanceof Error ? cause.message : String(cause)) - } - }) - }, AUTO_ACK_MS) - return () => window.clearTimeout(timer) - }, [currentNoticeId, profileId]) - const dismiss = async () => { if (!current) return try { await recordManagedRuntimeNoticeReceipt(current.noticeId, profileId, 'dismissed') setNotices((existing) => existing.filter((notice) => notice.noticeId !== current.noticeId)) - setDetails(undefined) + setLoadedDetails(undefined) setError(undefined) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) @@ -103,74 +145,67 @@ export function ManagedRuntimeNotices() { const loadDetails = async () => { const incidentId = current?.incidentIds[0] - if (!incidentId) return + const noticeId = current?.noticeId + if (!incidentId || !noticeId) return + const isCurrentNotice = () => ( + currentNoticeRef.current.noticeId === noticeId + && currentNoticeRef.current.profileId === profileId + ) try { - setDetails(await getManagedRuntimeIncidentSummary(incidentId)) + const summary = await getManagedRuntimeIncidentSummary(incidentId) + if (!isCurrentNotice()) return + setLoadedDetails({ noticeId, profileId, summary }) setError(undefined) } catch (cause) { + if (!isCurrentNotice()) return setError(cause instanceof Error ? cause.message : String(cause)) } } - if (!current && !error) return null + if (!current) return null - const failed = current?.kind === 'cleanup_failed' return (
- {current && ( - <> -
-
-

- {current.kind === 'cleanup_succeeded' - ? 'Recovered runtime cleanup complete' - : current.kind === 'cleanup_failed' - ? 'Runtime cleanup needs attention' - : 'Terminal session ended'} -

-

{current.message}

- {details && ( -

- {details.observedCause} {cleanupLabel(details)} -

- )} -
-
- {current.incidentIds.length > 0 && ( - - )} - -
-
- {notices.length > 1 && ( +
+
+

+ Runtime cleanup needs attention +

+

{current.message}

+ {details && (

- {notices.length - 1} more runtime {notices.length === 2 ? 'notice' : 'notices'} pending. + {details.observedCause} {cleanupLabel(details)}

)} - - )} +
+
+ {current.incidentIds.length > 0 && ( + + )} + +
+
{error &&

{error}

}
) } -export const MANAGED_RUNTIME_NOTICE_AUTO_ACK_MS = AUTO_ACK_MS export const MANAGED_RUNTIME_NOTICE_POLL_MS = POLL_MS export { noticeProfileId } diff --git a/src/components/ManagedRuntimeRecoveryCard.tsx b/src/components/ManagedRuntimeRecoveryCard.tsx new file mode 100644 index 000000000..064bcc871 --- /dev/null +++ b/src/components/ManagedRuntimeRecoveryCard.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import { managedRecoveryBlockedMessage } from '@/lib/managed-runtime-recovery-message' +import type { ManagedRuntimeRecoverySummary } from '@shared/managed-runtime' + +export type ManagedRuntimeRecoveryCardProps = { + recoverySummary?: ManagedRuntimeRecoverySummary + onRetry: () => Promise + onStartFresh: () => void | Promise +} + +/** Whether a managed projection owns the pane's recovery decision. */ +export function isManagedRuntimeRecoveryDecision( + recoverySummary?: ManagedRuntimeRecoverySummary, +): boolean { + return recoverySummary?.recoveryState === 'blocked' + || recoverySummary?.recoveryState === 'lost' +} + +export function ManagedRuntimeRecoveryCard({ + recoverySummary, + onRetry, + onStartFresh, +}: ManagedRuntimeRecoveryCardProps) { + const [pending, setPending] = useState(false) + const [actionError, setActionError] = useState() + const recoveryState = recoverySummary?.recoveryState + + if (recoveryState !== 'blocked' && recoveryState !== 'lost') return null + + const blocked = recoveryState === 'blocked' + const handleAction = async () => { + if (pending) return + setPending(true) + setActionError(undefined) + try { + await (blocked ? onRetry() : onStartFresh()) + } catch (error) { + setActionError(error instanceof Error ? error.message : 'The action failed. Try again.') + } finally { + setPending(false) + } + } + + return ( +
+
+ + {blocked + ? `This session needs attention before it can continue. ${managedRecoveryBlockedMessage(recoverySummary?.reason)}` + : 'This session could not be recovered. Start a new conversation when you are ready.'} + + {actionError ? ( + + {actionError} + + ) : null} +
+ {blocked ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/TerminalLaunchFailureCard.tsx b/src/components/TerminalLaunchFailureCard.tsx index 04ee0ba59..cbcacced2 100644 --- a/src/components/TerminalLaunchFailureCard.tsx +++ b/src/components/TerminalLaunchFailureCard.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import type { LaunchFailure } from '@/store/paneTypes' /** @@ -19,9 +20,23 @@ export function TerminalLaunchFailureCard({ failure, onRetry, onAttach, onOpenFr onRetry: () => void onAttach?: () => void onOpenFresh?: () => void - onStartFresh?: () => void + onStartFresh?: () => void | Promise }) { + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState() const sessionMissing = failure.code === 'SESSION_MISSING' + const handleStartFresh = async () => { + if (starting || !onStartFresh) return + setStarting(true) + setStartError(undefined) + try { + await onStartFresh() + } catch (error) { + setStartError(error instanceof Error ? error.message : 'Cleanup failed. Your conversation has been kept.') + } finally { + setStarting(false) + } + } return (
- {failureTitle(failure)} +
+ {failureTitle(failure)} + {startError ? {startError} : null} +
{failure.terminalId !== undefined && onAttach !== undefined ? ( ) : null}
diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index d72e7d9b8..ca97abe69 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -24,13 +24,19 @@ import { RECONCILE_NOTICE_FRESH_BY_RACE, repairCodexIdentityMismatch, resetPaneForReconcileCreate, + startNewManagedRuntimeConversation, setPaneCrashTrace, setPaneLaunchFailure, clearPaneCrashTrace, splitPane, + mergePaneContent, updatePaneContent, updatePaneTitle, } from '@/store/panesSlice' +import { retryManagedConversation } from '@/lib/managed-runtime-retry' +import { confirmManagedRuntimeStopped } from '@/lib/managed-runtime-stop' +import { isManagedRuntimeRecoveryDecision, ManagedRuntimeRecoveryCard } from '@/components/ManagedRuntimeRecoveryCard' +import { isManagedRuntimeRecoveryPending } from '@/lib/managed-runtime-recovery-message' import { buildReconcileRequestForPanes, foldVerdicts } from '@/lib/pane-reconcile' import type { PaneReconcileRequest, SessionRuntimeOwnerMessage } from '@shared/ws-protocol' import { @@ -72,7 +78,7 @@ import { focusNextTerminalSearchMatch, focusPreviousTerminalSearchMatch, loadTer import { isFatalConnectionErrorCode } from '@/store/connectionSlice' import { flushPersistedLayoutNow } from '@/store/persistControl' import { getWsClient, RECONCILE_VERDICT_WAIT_MS } from '@/lib/ws-client' -import { resolveTerminalKillFence, sendTerminalKill } from '@/lib/terminal-kill' +import { resolveTerminalKillFence } from '@/lib/terminal-kill' import { foldRefusalFencePair, hasRefusalFencePair, STALE_REFUSAL_MESSAGE_PREFIX } from '@/lib/owner-fence-heal' import type { RefusalFencePair } from '@/lib/owner-fence-heal' import { sendTerminalKillAndAwait, type KillAck } from '@/lib/kill-ack' @@ -3458,6 +3464,15 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te opts?: AttachTerminalOptions, ) => { if (suppressNetworkEffects) return + if (isManagedRuntimeRecoveryDecision(contentRef.current?.recoverySummary)) { + log.debug('attach gate declined: managed runtime recovery requires an explicit decision', { + terminalId: tid, + paneId: paneIdRef.current, + intent, + recoveryState: contentRef.current?.recoverySummary?.recoveryState, + }) + return + } // kata b8ke (round-1 review — convergence is bidirectional): while the // canonical session's runtime owner is a FRESH-AGENT runtime, this pane's // terminal was reaped by the handoff — stop treating it as live. The @@ -3908,6 +3923,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te const currentContent = contentRef.current if (!tid || !currentContent) return false if (!paneRefreshTargetMatchesContent(request.target, currentContent)) return false + if (isManagedRuntimeRecoveryDecision(currentContent.recoverySummary)) return false handledRefreshRequestIdRef.current = request.requestId // An explicit pane refresh is user intent, not automatic recovery cycling: @@ -4066,6 +4082,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te useEffect(() => { if (suppressNetworkEffects) return if (!isTerminal || !terminalContent) return + if (isManagedRuntimeRecoveryDecision(terminalContent.recoverySummary)) return if (shouldWaitForProviderBehavior) return const termCandidate = termRef.current if (!termCandidate) return @@ -4145,6 +4162,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } const sendCreate = (requestId: string) => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return // Reconcile verdict precedence (Task 12): a folded respawn verdict's // server-named sessionRef WINS over any other inference (restore flag, // fresh-recovery intent); a folded fresh verdict omits resume identity @@ -4275,6 +4293,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } const scheduleCreateRetry = (requestId: string, kind: 'rate-limit' | 'launch') => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return true const retryState = rateLimitRetryRef.current if (retryState.count >= RATE_LIMIT_RETRY_MAX_ATTEMPTS) return false retryState.count += 1 @@ -4423,6 +4442,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te restore: boolean, deadTerminalId: string | undefined, ): boolean => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return true const reqId = requestIdRef.current if (!reqId) return false if (restore) addTerminalRestoreRequestId(reqId) @@ -4460,6 +4480,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // error card for a standoff, never a silent wedge, never a duplicate // (the reconcile verdict is folded, not blindly re-created). const resolveReserveExhaustionViaReconcile = () => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return resetReconcileRedrive() const request = buildReconcileRequestForPanes(appStore.getState(), [ { tabId, paneId: paneIdRef.current }, @@ -4475,6 +4496,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } const redriveAfterSessionReserved = (requestId: string, retryAfterMs?: number) => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return const redriveState = reconcileRedriveRef.current const now = Date.now() if (redriveState.reserveWindowStart === null) { @@ -4493,6 +4515,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te redriveState.timer = null if (requestIdRef.current !== requestId) return if (terminalIdRef.current) return // anchored meanwhile + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return // Re-send the SAME terminal.create — createRequestId is NEVER // re-minted (council rule 2). sendCreate(requestId) @@ -4506,6 +4529,19 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te foldRefusalFencePair(dispatch, appStore.getState(), contentRef.current ?? {}, refusal) } + const clearRejectedAttach = () => { + clearQuarantineRepair() + currentAttachRef.current = null + pacedReplayRef.current = null + deferredAttachStateRef.current = { + mode: 'none', + pendingIntent: null, + pendingSinceSeq: 0, + pendingReason: 'initial_hydrate', + } + setIsAttaching(false) + } + // b8ke fence-heal (Task 7 follow-up): the recovery-create lane shared // by the INVALID_TERMINAL_ID reconnect recovery (focused review 1 // removed the pane-terminal-scoped refused-arm routing — a refused @@ -4516,6 +4552,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // let the lifecycle effect's createRequestId dependency re-fire the // resume create. const resumeRecoveryCreate = (deadTerminalId?: string) => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return writeLocalXtermNotice(term, '\r\n[Reconnecting...]\r\n') const newRequestId = nanoid() if (debugRef.current) log.debug('[TRACE resumeSessionId] recovery-create', { @@ -4561,6 +4598,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // points at. Pump bounded same-requestId re-creates instead of minting a // fresh recovery identity for a pane that never finished launching. const redriveAfterLaunchInvalidTerminal = (deadTerminalId: string | undefined): boolean => { + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return true const requestId = requestIdRef.current if (!requestId) return false const redriveState = reconcileRedriveRef.current @@ -4593,6 +4631,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te const attempt = () => { if (requestIdRef.current !== requestId) return if (terminalIdRef.current) return // anchored — stop the pump + if (isManagedRuntimeRecoveryPending(contentRef.current?.recoverySummary)) return if (redriveState.invalidAttempts >= INVALID_TERMINAL_LAUNCH_RETRY_MAX_ATTEMPTS) { failLaunch('The server no longer knows this terminal and recreating it kept failing.', true) return @@ -4608,6 +4647,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } unsub = ws.onMessage((msg) => { + if (isManagedRuntimeRecoveryDecision(contentRef.current?.recoverySummary)) return const tid = terminalIdRef.current const reqId = requestIdRef.current @@ -6476,6 +6516,17 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } return } + // The supervisor owns managed recovery. A rejected old attach is + // transport evidence, never permission to replace the conversation. + if (isManagedRuntimeRecoveryPending(current?.recoverySummary)) { + log.debug('Managed runtime retains a rejected terminal target during recovery', { + event: 'terminal.managed_recovery_attach_rejected', terminalId: currentTerminalId, + paneId: paneIdRef.current, requestId: msg.requestId, recoveryState: current?.recoverySummary?.recoveryState, + }) + clearRateLimitRetry() + clearRejectedAttach() + return + } const failedDuringLaunch = Boolean( launchAttempt && currentTerminalId @@ -6543,16 +6594,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // reconcile result may confirm the same persisted identity, // so the pending-window close must retry the attach instead // of treating this rejected generation as still in flight. - clearQuarantineRepair() - currentAttachRef.current = null - pacedReplayRef.current = null - deferredAttachStateRef.current = { - mode: 'none', - pendingIntent: null, - pendingSinceSeq: 0, - pendingReason: 'initial_hydrate', - } - setIsAttaching(false) + clearRejectedAttach() } return } @@ -6983,6 +7025,8 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te shouldWaitForProviderBehavior, terminalContent?.createRequestId, terminalContent?.reconcileEpoch, + terminalContent?.recoverySummary?.desiredState, + terminalContent?.recoverySummary?.recoveryState, // reconcilePendingSince: re-run when the pane's pre-verdict wait state // changes -- the verdict fold (or the bounded timeout) clears the entry // and the deferred mount-create must then proceed (Task 8). @@ -7130,10 +7174,10 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // inherit the abandoned close identity and be omitted from recovery // after a server restart. The remint is the terminal lane's // clearTerminalContentForRecreate semantics (the dead-live-handle - // recovery's path) driven through updateContent: a fresh-nanoid - // createRequestId — the lifecycle effect re-fires sendCreate on the id - // change itself, so no reconcileEpoch bump (that is only the same-id - // fold's signal) — with the live handles cleared, status 'creating', and + // recovery's path) driven through the explicit start-new reducer: a + // fresh-nanoid createRequestId — the lifecycle effect re-fires sendCreate + // on the id change itself, so no reconcileEpoch bump (that is only the + // same-id fold's signal) — with the live handles cleared, status 'creating', and // the abandoned session identity + presentation state cleared // (startFreshConversation semantics: sessionRef / resumeSessionId / // codexDurability, plus the znhn#1 rule that the retired session's @@ -7141,24 +7185,18 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te const startFreshFromStuckPane = useCallback(async () => { const ack = await killStuckTerminalAndAwait('fresh') if (!ack) return - updateContent({ - createRequestId: nanoid(), - terminalId: undefined, - serverInstanceId: undefined, - streamId: undefined, - status: 'creating', - sessionRef: undefined, - resumeSessionId: undefined, - codexDurability: undefined, - crashTrace: undefined, - restoreError: undefined, - launchFailure: undefined, - handoffError: undefined, - // A user-driven fresh start is not a reconcile-verdict result — a - // stale verdict flag must never steer the new create. - pendingReconcile: undefined, - }) - }, [killStuckTerminalAndAwait, updateContent]) + dispatch(startNewManagedRuntimeConversation({ tabId, paneId })) + }, [dispatch, killStuckTerminalAndAwait, paneId, tabId]) + + const retryManagedRecovery = useCallback(async () => { + const current = contentRef.current + if (!current) return + await retryManagedConversation(current, () => { + const root = appStore.getState().panes.layouts[tabId] + const latest = root ? findPaneContent(root, paneId) : null + return latest?.kind === 'terminal' ? latest : null + }, appStore) + }, [appStore, paneId, tabId]) // NOW we can do the conditional return - after all hooks if (!isTerminal || !terminalContent) { @@ -7166,9 +7204,19 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } const hasFatalConnectionError = isFatalConnectionErrorCode(connectionErrorCode) - const showBlockingSpinner = terminalContent.status === 'creating' && !hasFatalConnectionError + const managedRecoveryDecision = isManagedRuntimeRecoveryDecision(terminalContent.recoverySummary) + const managedTerminal = Boolean(terminalContent.soulId || terminalContent.recoverySummary) + const managedAutomaticRecovery = (managedTerminal && !terminalContent.recoverySummary) + || terminalContent.recoverySummary?.recoveryState === 'live' + || terminalContent.recoverySummary?.recoveryState === 'recovering' + // Keep recovery diagnostics in state, but show only actionable failures. + const visibleNotice = managedTerminal ? null : activeNotice + const visibleCrashTrace = managedTerminal ? null : terminalContent.crashTrace + const showBlockingSpinner = terminalContent.status === 'creating' + && !isManagedRuntimeRecoveryPending(terminalContent.recoverySummary) + && !hasFatalConnectionError const showInlineOfflineStatus = connectionStatus !== 'ready' && !hasFatalConnectionError - const showInlineRecoveringStatus = connectionStatus === 'ready' && isAttaching && terminalContent.status !== 'creating' && !wasCreatedFreshRef.current + const showInlineRecoveringStatus = !managedTerminal && connectionStatus === 'ready' && isAttaching && terminalContent.status !== 'creating' && !wasCreatedFreshRef.current const inlineStatusMessage = showInlineOfflineStatus ? 'Offline: input will queue until reconnected.' : (showInlineRecoveringStatus ? 'Recovering terminal output...' : null) @@ -7212,8 +7260,12 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te && freshAgentOwnerDivergence === null && terminalRuntimeOwner?.ownerKind === 'vacant' ) + const showSettledDead = settledDead && (!managedAutomaticRecovery || Boolean( + autoResumeSettle && autoResumeSettle.exitCode !== 0 && !activeNotice + )) const showExitBanner = Boolean( - isAgentPane && (activeNotice || terminalContent.crashTrace || settledDead || killedSessionVacant) + !managedRecoveryDecision + && isAgentPane && (visibleNotice || visibleCrashTrace || showSettledDead || killedSessionVacant) ) // ── kata b8ke: typed recovery surfaces ── @@ -7253,14 +7305,14 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te })) } - // The typed launch-failure card: rendered only while NOT divergent — the - // live owner record (the divergence card) is the authoritative surface - // when both would show. - const typedLaunchFailure = freshAgentOwnerDivergence === null + // Managed recovery and owner divergence own the current decision. An old + // launch failure must not add a competing alert or obsolete retry action. + const typedLaunchFailure = !managedRecoveryDecision && freshAgentOwnerDivergence === null ? terminalContent.launchFailure : undefined const retryLaunch = () => { + if (managedRecoveryDecision) return // The Relaunch discipline: a reconcile-driven respawn create re-fires // the lifecycle effect (the reconcileEpoch bump is its ONLY re-fire // signal — createRequestId is preserved, never re-minted). @@ -7273,6 +7325,7 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te } const attachToNamedTerminal = () => { + if (managedRecoveryDecision) return const terminalId = terminalContent.launchFailure?.terminalId if (!terminalId) return dispatch(applyReattachToLiveTerminal({ tabId, paneId, terminalId })) @@ -7281,17 +7334,28 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te // b8ke ext r16 F3: the typed missing state's explicit START-FRESH // action — the ONLY new-session path (operator-initiated, clearly a NEW // conversation, never a resume). The stale sessionRef is cleared (a - // fresh create spawns identity-less) and the reconcileEpoch bump - // re-fires the lifecycle effect into a genuinely new create. - const startFreshConversation = () => { - dispatch(resetPaneForReconcileCreate({ - tabId, - paneId, - // 'fresh' clears sessionRef/resumeSessionId/codexDurability — a - // genuinely new identity-less conversation. - intent: 'fresh', - reason: 'session_missing', - })) + // fresh create spawns identity-less). The explicit start-new transition + // mints a new create key and clears the managed projection before the + // lifecycle effect drives the genuinely new create. + const startFreshConversation = async () => { + const current = contentRef.current + if (!current) return + if (current.soulId || isManagedRuntimeRecoveryDecision(current.recoverySummary)) { + const confirmed = await confirmManagedRuntimeStopped(current, { + getCurrent: () => { + const root = appStore.getState().panes.layouts[tabId] + const latest = root ? findPaneContent(root, paneId) : null + return latest?.kind === 'terminal' ? latest : null + }, + applyIntentRevision: (soulIntentRevision) => { + const latest = contentRef.current + if (latest) contentRef.current = { ...latest, soulIntentRevision } + dispatch(mergePaneContent({ tabId, paneId, updates: { soulIntentRevision } })) + }, + }) + if (!confirmed) return + } + dispatch(startNewManagedRuntimeConversation({ tabId, paneId })) } return ( @@ -7402,6 +7466,16 @@ function TerminalView({ tabId, paneId, paneContent, hidden, focusEpoch = 0 }: Te />
) : null} + {isManagedRuntimeRecoveryDecision(terminalContent.recoverySummary) ? ( +
+ +
+ ) : null} {isMobile && (
0 + && next.turns.length === 0 + && next.status === 'idle' + && providerState?.ownerKind === 'vacant' + && providerState.statusFromLiveState !== true + && providerState.nativeHistoryAvailable !== true + ) return previous + // Native reads and live actors have different revision bases. Requests fence + // source transitions; the revision comparison applies within one source. + const sameRevisionSource = isNativeHistoryOnlySnapshot(previous) === isNativeHistoryOnlySnapshot(next) + if ( + sameRevisionSource + && typeof previous.revision === 'number' && typeof next.revision === 'number' && next.revision < previous.revision ) { @@ -685,6 +728,8 @@ export function FreshAgentView({ const dispatch = useAppDispatch() const ws = getWsClient() const appStore = useAppStore() + const managedRecoveryPending = isManagedRuntimeRecoveryPending(paneContent.recoverySummary) + const supervisorRecoveryOwned = isSupervisorRecoveryOwned(paneContent) const terminalFontSize = useAppSelector( (state) => state.settings.settings.terminal?.fontSize, ) ?? 16 @@ -737,6 +782,11 @@ export function FreshAgentView({ }) return state.freshAgent.sessions[sessionKey] }) + // A live projection precedes attachment truth. Keep stale lost status quiet + // until the existing session's snapshot proves the reattachment completed. + const managedRecoveryActive = isSupervisorRecoveryActive(paneContent, agentSession?.lost) + const agentSessionLostRef = useRef(agentSession?.lost) + agentSessionLostRef.current = agentSession?.lost // Status-strip context meter source: the unified usage map stamped by // committed sidebar refreshes (fresh rows + out-of-band extras). Deliberately // NOT the fresh-agent snapshot tokenUsage — that channel never carries @@ -821,6 +871,17 @@ export function FreshAgentView({ const [loadError, setLoadError] = useState(null) const [snapshotRefreshNonce, setSnapshotRefreshNonce] = useState(0) const snapshotRefreshTriggerRef = useRef('identity') + const snapshotRequestAuthorityRef = useRef({ next: 0, applied: 0, generation: 0, readOnlySource: false }) + const snapshotAuditViewRef = useRef(null) + const snapshotAuditInputsRef = useRef | null>(null) + // Independent of log levels. Construct diagnostic data only while the opt-in bridge exists. + const recordSnapshotAudit = useCallback((stage: SnapshotAuditStage, data: () => Record) => { + const bridge = getInstalledPerfAuditBridge() + if (!bridge) return + snapshotAuditViewRef.current ??= ++nextSnapshotAuditView + bridge.addPerfEvent({ event: 'fresh_agent.snapshot_request', stage, timestamp: performance.now(), + view: snapshotAuditViewRef.current, ...data() }) + }, []) // A hidden pane keeps its last good transcript until a transcript-changing // event says that it is no longer current. On reveal, the old DOM remains // mounted but is concealed behind a refresh state so the user never reads a @@ -1092,6 +1153,7 @@ export function FreshAgentView({ // explicit recovery decision; queued/timer retries keep the same attempt. const attachmentAttemptRef = useRef(null) const attachDecisionSerialRef = useRef(0) + const previousManagedAttachPendingRef = useRef(managedRecoveryPending) // Pre-verdict create wait (fresh-agent leg of Task 8's pattern): a pane // named in an outgoing pane.reconcile request defers its mount-time create // until its verdict folds -- bounded by RECONCILE_VERDICT_WAIT_MS, then the @@ -1135,7 +1197,9 @@ export function FreshAgentView({ const pendingAutoTitleBySessionIdRef = useRef>(new Map()) const handledRefreshRequestIdRef = useRef(null) const preferredResumeSessionId = getPreferredResumeSessionId(claudeSession) ?? paneContent.resumeSessionId - const snapshotThreadId = getFreshAgentSnapshotThreadId(paneContent, claudeSession) + const bootstrapHistoryThreadId = getManagedBootstrapHistoryThreadId(paneContent) + const snapshotReadOnly = managedRecoveryPending || Boolean(bootstrapHistoryThreadId) + const snapshotThreadId = bootstrapHistoryThreadId ?? getFreshAgentSnapshotThreadId(paneContent, claudeSession) const snapshotThreadIdRef = useRef(snapshotThreadId) snapshotThreadIdRef.current = snapshotThreadId const snapshotHydrationIdentity = `${paneContent.createRequestId}:${paneContent.sessionType}:${paneContent.provider}:${snapshotThreadId ?? ''}` @@ -1163,7 +1227,8 @@ export function FreshAgentView({ && claudeSession?.restoreFailureMessage, ) const isRestoring = Boolean( - paneContent.provider === 'claude' + !managedRecoveryActive + && paneContent.provider === 'claude' && paneContent.sessionId && !snapshot && Boolean(claudeSession?.latestTurnId !== undefined || claudeSession?.lost) @@ -1252,6 +1317,7 @@ export function FreshAgentView({ const requestSnapshotRefresh = useCallback((trigger: SnapshotTrigger) => { snapshotRefreshTriggerRef.current = trigger snapshotRefreshSerialRef.current += 1 + recordSnapshotAudit('refresh_requested', () => ({ trigger, refreshSerial: snapshotRefreshSerialRef.current })) setSnapshotRefreshNonce((value) => value + 1) }, []) @@ -1300,7 +1366,7 @@ export function FreshAgentView({ const sendFencedFreshAgentAttach = useCallback((attempt: AttachmentAttempt): boolean => { const content = paneContentRef.current - if (!isMountedRef.current || !content.sessionId) return false + if (!isMountedRef.current || !content.sessionId || isManagedRuntimeRecoveryPending(content.recoverySummary)) return false // One state read (review N1): the suppression check and the current-round // identity check observe the same store snapshot — nothing dispatches in // between. A queued callback may never upgrade itself to a newer fence. @@ -1402,7 +1468,7 @@ export function FreshAgentView({ * retry frame carries exactly the same fields, plus the route cwd. */ const sendFreshAgentSendFrame = useCallback((requestId: string, text: string, cwd?: string) => { const current = paneContentRef.current - if (!current.sessionId) return + if (!current.sessionId || isManagedRuntimeRecoveryPending(current.recoverySummary)) return // b8ke ext r8 F5: the send is a lifecycle producer — it carries the // observed (epoch, generation) fence so a queued send landing after a // crash + generation advance is typed-refused server-side, never an @@ -1425,7 +1491,7 @@ export function FreshAgentView({ ...(getEffectiveFreshAgentEffort(current, providerDefaults) ? { effort: getEffectiveFreshAgentEffort(current, providerDefaults) } : {}), }, }) - }, [providerDefaults, sendFreshAgentMessage]) + }, [appStore, providerDefaults, sendFreshAgentMessage]) /** Task 10: re-issue a failed send under a fresh requestId with the * retained text + route cwd. The retry gets its own pending-metadata entry @@ -1571,22 +1637,27 @@ export function FreshAgentView({ } as const }, [providerDefaults, tabId]) - const startNewConversation = useCallback(() => { + const startNewConversation = useCallback(async () => { const current = paneContentRef.current - // Focused-episode-6 round 2 (Finding 6): a session-bearing conversation - // replacement AWAITS the old session's durable close before swapping the - // pane — a close the server cannot record is not a close, and dropping - // the conversation anyway would leave a live server session open on no - // tab. On failure the current conversation stays (the killed fold's - // session-error banner — or the await's timeout write — explains it). - void (async () => { - // b8ke ext F2: the kill target is the pane's DURABLE session — - // content.sessionId OR the restored pane's sessionRef.sessionId - // (the sessionRef's provider must match the pane's). Pre-ext the - // content.sessionId gate skipped the awaited kill entirely for a - // sessionRef-only restored pane, clearing the durable reference - // and starting a blank conversation while the prior runtime - // stayed live and unrepresented. + const getCurrent = () => { + const root = appStore.getState().panes.layouts[tabId] + const latest = root ? findPaneContent(root, paneId) : null + return latest?.kind === 'fresh-agent' ? latest : null + } + // A managed loss may already be stopped and absent from the web alias cache. + // Its persisted soul is the cleanup authority before replacing identity. + if (current.soulId || isManagedRuntimeRecoveryPending(current.recoverySummary)) { + const confirmed = await confirmManagedRuntimeStopped(current, { + getCurrent, + applyIntentRevision: (soulIntentRevision) => { + paneContentRef.current = { ...paneContentRef.current, soulIntentRevision } + dispatch(mergePaneContent({ tabId, paneId, updates: { soulIntentRevision } })) + }, + }) + if (!confirmed) return + } else { + // Unmanaged sessions still await their durable close acknowledgement, + // including a restored pane whose only identity is its sessionRef. const killSessionId = current.sessionId ?? (current.sessionRef?.provider === current.provider ? current.sessionRef.sessionId @@ -1619,36 +1690,38 @@ export function FreshAgentView({ return } } - commitSnapshot(null) - setLoadError(null) - setQueuedMessages([]) - setLocalEcho(null) - alwaysAllowToolsRef.current.clear() - pendingAutoTitleBySessionIdRef.current.clear() - dispatch(updatePaneContent({ - tabId, - paneId, - content: { - ...current, - createRequestId: nanoid(), - sessionId: undefined, - sessionRef: undefined, - resumeSessionId: undefined, - restoreError: undefined, - createError: undefined, - status: 'creating', - pendingLocalEcho: undefined, - // Unified agent names: a deliberate NEW conversation never - // inherits the previous conversation's pre-durable identity or - // canonical projection — the new conversation mints its own - // handle and gets its own name lifecycle. - namingHandle: undefined, - nameRef: undefined, - }, - })) - })() + } + const before = getCurrent() + if (!before) return + dispatch(startNewManagedRuntimeConversation({ tabId, paneId })) + const after = getCurrent() + // A close can start while cleanup is awaited. Keep local conversation + // state when the reducer refuses to replace that still-displayed pane. + if (!after || after.createRequestId === before.createRequestId) return + commitSnapshot(null) + setLoadError(null) + setQueuedMessages([]) + setLocalEcho(null) + alwaysAllowToolsRef.current.clear() + pendingAutoTitleBySessionIdRef.current.clear() }, [appStore, commitSnapshot, dispatch, paneId, sendFreshAgentMessage, setLocalEcho, tabId]) + const handleStartNewConversation = useCallback(() => { + void startNewConversation().catch((error: unknown) => { + setLoadError(error instanceof Error ? error.message : 'Cleanup failed. Your conversation has been kept.') + }) + }, [startNewConversation]) + + const retryManagedRecovery = useCallback(async () => { + const current = paneContentRef.current + if (!current) return + await retryManagedConversation(current, () => { + const root = appStore.getState().panes.layouts[tabId] + const latest = root ? findPaneContent(root, paneId) : null + return latest?.kind === 'fresh-agent' ? latest : null + }, appStore) + }, [appStore, paneId, tabId]) + const sendFork = useCallback((atTurnId?: string) => { const current = paneContentRef.current if (!current.sessionId) return @@ -1714,7 +1787,7 @@ export function FreshAgentView({ const runSlashCommand = useCallback((command: FreshAgentSlashCommand, args: string) => { const current = paneContentRef.current if (command.action === 'new') { - startNewConversation() + handleStartNewConversation() return } if (command.action === 'model') { @@ -1777,13 +1850,17 @@ export function FreshAgentView({ sendRollback(direction, 'step') return } - }, [descriptor?.label, sendFork, sendFreshAgentMessage, sendRollback, startNewConversation]) + }, [appStore, descriptor?.label, sendFork, sendFreshAgentMessage, sendRollback, handleStartNewConversation]) useEffect(() => { if (!refreshRequest) return if (handledRefreshRequestIdRef.current === refreshRequest.requestId) return const current = paneContentRef.current if (!paneRefreshTargetMatchesContent(refreshRequest.target, current)) return + if (isManagedRuntimeRecoveryPending(current.recoverySummary)) { + dispatch(consumePaneRefreshRequest({ tabId, paneId, requestId: refreshRequest.requestId })) + return + } handledRefreshRequestIdRef.current = refreshRequest.requestId commitSnapshot(null) @@ -1839,8 +1916,9 @@ export function FreshAgentView({ clearTimeout(restoreTimeoutRef.current) restoreTimeoutRef.current = null } - const nextRequestId = nanoid() const current = paneContentRef.current + if (isSupervisorRecoveryActive(current, agentSessionLostRef.current)) return + const nextRequestId = nanoid() // Codex threads don't carry Claude's UUID-format durable identity, so they // resolve their canonical resume id through the codex-specific helper // instead of getCanonicalDurableSessionId/getCanonicalPaneResumeSessionId @@ -1891,6 +1969,7 @@ export function FreshAgentView({ const restartStuckSidecar = useCallback(() => { if (recoveryStopPendingRef.current) return const current = paneContentRef.current + if (isSupervisorRecoveryActive(current, agentSessionLostRef.current)) return // b8ke ext F2: the kill target is the pane's DURABLE session — // content.sessionId OR the restored pane's sessionRef.sessionId // (pre-ext a sessionRef-only pane skipped the kill and re-drove @@ -1949,6 +2028,7 @@ export function FreshAgentView({ const lostReconcileRef = useRef(null) const reconcileLostPane = useCallback(() => { + if (isSupervisorRecoveryOwned(paneContentRef.current)) return const request = buildReconcileRequestForPanes(appStore.getState(), [{ tabId, paneId }]) if (!request) { // The pane lost its reconcilable state (no createRequestId) -- fall @@ -1998,6 +2078,7 @@ export function FreshAgentView({ state.timer = setTimeout(() => { state.timer = null const current = paneContentRef.current + if (isManagedRuntimeRecoveryPending(current.recoverySummary)) return if (current.sessionId) { // Attach loser: re-send the (fenced, divergence-gated) attach // directly — the attach effect keys on sessionId, which has not @@ -2012,6 +2093,7 @@ export function FreshAgentView({ }, [captureFreshAgentAttachmentAttempt, clearReserveRedrive, dispatch, paneId, reconcileLostPane, sendFencedFreshAgentAttach, tabId]) useEffect(() => { + if (managedRecoveryPending) return if (paneContent.sessionId) return if (paneContent.restoreError) return if ( @@ -2083,6 +2165,10 @@ export function FreshAgentView({ return } const current = paneContentRef.current + if (isManagedRuntimeRecoveryPending(current.recoverySummary)) { + release?.() + return + } if (current.sessionId) { release?.() return @@ -2136,6 +2222,7 @@ export function FreshAgentView({ appStore, buildCreateMessage, dispatch, + managedRecoveryPending, paneId, paneContent, // reconcilePendingSince: re-run when the pane's pre-verdict wait state @@ -2161,6 +2248,10 @@ export function FreshAgentView({ return } const latest = paneContentRef.current + if (isManagedRuntimeRecoveryPending(latest.recoverySummary)) { + release?.() + return + } if (latest.sessionId) { release?.() return @@ -2210,14 +2301,22 @@ export function FreshAgentView({ paneId, paneContent.sessionId, paneContent.status, + paneContent.recoverySummary?.desiredState, + paneContent.recoverySummary?.recoveryState, releasePendingRebind, sendFreshAgentMessage, ws, ]) useEffect(() => { + const wasManagedRecoveryPending = previousManagedAttachPendingRef.current + previousManagedAttachPendingRef.current = managedRecoveryPending + if (managedRecoveryPending) return if (!paneContent.sessionId) return - const attempt = captureFreshAgentAttachmentAttempt(paneContent) + // Supervisor recovery is a new attachment decision for the same conversation. + // Capture its current owner fence; old queued attempts retain their old decision. + if (wasManagedRecoveryPending) attachDecisionSerialRef.current += 1 + const attempt = captureFreshAgentAttachmentAttempt(paneContentRef.current) const sendAttach = () => { sendFencedFreshAgentAttach(attempt) } @@ -2243,6 +2342,7 @@ export function FreshAgentView({ paneContent.createRequestId, paneContent.reconcileEpoch, paneContent.provider, + managedRecoveryPending, paneContent.sessionId, paneContent.sessionRef?.provider, paneContent.sessionRef?.sessionId, @@ -2260,6 +2360,7 @@ export function FreshAgentView({ queueMicrotask(() => { if (!isMountedRef.current) return const current = paneContentRef.current + if (isManagedRuntimeRecoveryPending(current.recoverySummary)) return if (!current.sessionId) return const attempt = captureFreshAgentAttachmentAttempt(current) const sendAttach = () => { @@ -2289,6 +2390,7 @@ export function FreshAgentView({ paneId, paneContent.sessionId, paneContent.reconcileEpoch, + managedRecoveryPending, markSnapshotDirty, requestSnapshotRefresh, sendFencedFreshAgentAttach, @@ -2319,6 +2421,7 @@ export function FreshAgentView({ useEffect(() => { if (typeof ws.onMessage !== 'function') return const unsubscribe = ws.onMessage((message) => { + if (isManagedRuntimeRecoveryDecision(paneContentRef.current.recoverySummary)) return if (message.type === 'pane.reconcile.result') { // Fold-ownership rule (pane-reconcile.ts): fold ONLY the result whose // reconcileId this view minted for its .lost reconcile; foreign @@ -2326,6 +2429,13 @@ export function FreshAgentView({ const lostRequest = lostReconcileRef.current if (lostRequest && message.reconcileId === lostRequest.reconcileId) { lostReconcileRef.current = null + if (isSupervisorRecoveryOwned(paneContentRef.current)) { + log.debug('Ignoring legacy reconcile after supervisor adoption', { + event: 'fresh_agent.legacy_reconcile_ignored', paneId, + sessionId: paneContentRef.current.sessionId, soulId: paneContentRef.current.soulId, + }) + return + } foldVerdicts(dispatch, lostRequest, message) // markSessionLost's counterpart: an attach fold where the durable id // equals the old sessionId leaves the SAME freshAgent session entry @@ -2635,44 +2745,105 @@ export function FreshAgentView({ }, [agentSession?.cwd, appStore, captureFreshAgentAttachmentAttempt, clearReserveRedrive, commitSnapshot, descriptor?.label, dispatch, markSnapshotDirty, migratePendingAutoTitle, paneContent, paneContent.createRequestId, paneId, recordPendingSendMetadata, redriveAfterSessionReserved, releasePendingRebind, requestRevealRefresh, requestSnapshotRefresh, resendPendingMessage, sendFencedFreshAgentAttach, sendFreshAgentMessage, setLocalEcho, tabId, ws]) useEffect(() => { - if (!snapshotThreadId) return + if (!snapshotThreadId) { + recordSnapshotAudit('effect_skipped', () => ({ reason: 'no_thread' })) + return + } // kata b8ke: a divergent pane (the canonical session's runtime owner is // the other kind) stops ALL old-kind snapshot traffic — polling, event // refreshes, and this identity fetch alike. Read via the ref so the // identity-deps discipline below is not disturbed. - if (ownerDivergenceRef.current) return - // agentSession is the provider-agnostic session-meta selector (see above); - // for claude it's the same entry as claudeSession, so this also covers - // claude's existing behavior. Skip the snapshot fetch while a resumable - // provider is lost -- fetching against a dead thread id is a guaranteed - // 404 and triggerRecovery (below) is what should react to `.lost`. - if ((paneContent.provider === 'claude' || paneContent.provider === 'codex') && agentSession?.lost) return + if (ownerDivergenceRef.current) { + recordSnapshotAudit('effect_skipped', () => ({ reason: 'divergence' })) + return + } + // Unmanaged lost threads use lifecycle recovery below. Managed + // recovery retains durable identity: read-only GETs show saved history + // while the supervisor resumes automatically or awaits a decision. + if (!supervisorRecoveryOwned && (paneContent.provider === 'claude' || paneContent.provider === 'codex') && agentSession?.lost) { + recordSnapshotAudit('effect_skipped', () => ({ reason: 'unmanaged_lost' })) + return + } setLoadError(null) const sessionId = snapshotThreadId const provider = paneContent.provider const requestSessionType = paneContent.sessionType const requestCreateRequestId = paneContent.createRequestId - const isStaleSnapshotRequest = () => ( - paneContentRef.current.createRequestId !== requestCreateRequestId - || paneContentRef.current.provider !== provider - || paneContentRef.current.sessionType !== requestSessionType - || snapshotThreadIdRef.current !== sessionId - // kata b8ke: a divergence flip (the session's runtime owner became the - // other kind while this request was in flight) makes the result stale — - // result-application fencing, never an AbortSignal (the run-closure - // contract). - || ownerDivergenceRef.current !== null - ) + const requestPaneSoulId = paneContent.soulId + const requestPaneSoulRevision = paneContent.soulIntentRevision + const requestOwnerFence = selectPaneOwnerFence(appStore.getState(), paneContent) + const requestBootId = appStore.getState().connection.bootId + // A missing soul uses the owned snapshot route, but remains a history-only + // read: its status and errors cannot authorize runtime recovery. + const requestReadOnly = snapshotReadOnly + const requestSoulId = requestReadOnly ? requestPaneSoulId : undefined + const requestSerial = ++snapshotRequestAuthorityRef.current.next + if (snapshotRequestAuthorityRef.current.readOnlySource !== requestReadOnly) { + snapshotRequestAuthorityRef.current.readOnlySource = requestReadOnly + snapshotRequestAuthorityRef.current.generation += 1 + } + const requestReadGeneration = snapshotRequestAuthorityRef.current.generation + const auditRequest = (stage: SnapshotAuditStage, data: () => Record = () => ({})) => + recordSnapshotAudit(stage, () => ({ requestSerial, readGeneration: requestReadGeneration, + requestReadOnly, hasSoul: Boolean(requestPaneSoulId), trigger, refreshSerial, appliedSerial: snapshotRequestAuthorityRef.current.applied, + currentGeneration: snapshotRequestAuthorityRef.current.generation, ...data() })) + const isStaleSnapshotRequest = (check: 'native-error' | 'before-soul' | 'outcome' | 'error-fold') => { + const rejected = (fence: string) => { + auditRequest('currentness_checked', () => ({ check, stale: true, fence })) + return true + } + // Keep the original order and short circuit: later fences are unobserved after rejection. + if (paneContentRef.current.createRequestId !== requestCreateRequestId) return rejected('create') + if (paneContentRef.current.soulId !== requestPaneSoulId) return rejected('soul') + if (paneContentRef.current.soulIntentRevision !== requestPaneSoulRevision) return rejected('revision') + if (appStore.getState().connection.bootId !== requestBootId) return rejected('boot') + if (JSON.stringify(selectPaneOwnerFence(appStore.getState(), paneContentRef.current)) !== JSON.stringify(requestOwnerFence)) return rejected('owner') + if (requestSerial < snapshotRequestAuthorityRef.current.applied) return rejected('applied_serial') + // Ordinary reads predating managed recovery never regain authority after Retry. + // The initial history read can still supply history while a resumed live read waits. + if (requestReadGeneration !== snapshotRequestAuthorityRef.current.generation + && (!requestReadOnly || snapshotRequestAuthorityRef.current.readOnlySource)) return rejected('read_generation') + if (!requestReadOnly && isManagedRuntimeRecoveryPending(paneContentRef.current.recoverySummary)) return rejected('recovery') + if (paneContentRef.current.provider !== provider) return rejected('provider') + if (paneContentRef.current.sessionType !== requestSessionType) return rejected('session_type') + if (snapshotThreadIdRef.current !== sessionId) return rejected('thread') + if (ownerDivergenceRef.current !== null) return rejected('divergence') + auditRequest('currentness_checked', () => ({ check, stale: false })) + return false + } // A1: resolve the cwd ONCE (route cwd falls through initialCwd -> session // cwd) and use the SAME value for both the scheduler key and the request, // so sibling panes whose raw initialCwd diverges ('' vs '/w') still share // one key -- keying on raw initialCwd would let the N-pane fan-out survive. const requestCwd = freshOpenCodeRouteCwdRef.current ?? paneContentRef.current.initialCwd const requestAgentSessionStatusVersion = agentSessionStatusVersionRef.current + const requestSessionWasLost = agentSessionLostRef.current const requestOutgoingTurnId = outgoingTurnRef.current?.requestId const trigger = snapshotRefreshTriggerRef.current const refreshSerial = snapshotRefreshSerialRef.current + const snapshotAuditData = (value: FreshAgentSnapshot) => ({ historyOnly: isNativeHistoryOnlySnapshot(value), + live: value.extensions?.[provider]?.statusFromLiveState === true, + vacant: value.extensions?.[provider]?.ownerKind === 'vacant', rows: value.turns.length }) + auditRequest('request_captured', () => { + const inputs = { createChanged: requestCreateRequestId, soulChanged: requestPaneSoulId, + revisionChanged: requestPaneSoulRevision, bootChanged: requestBootId, + ownerChanged: JSON.stringify(requestOwnerFence), providerChanged: provider, typeChanged: requestSessionType, + threadChanged: sessionId, paneSessionChanged: paneContent.sessionId, + recoveryChanged: paneContent.recoverySummary?.recoveryState, lostChanged: agentSession?.lost, + supervisorChanged: supervisorRecoveryOwned, refreshChanged: snapshotRefreshNonce } + const previous = snapshotAuditInputsRef.current + snapshotAuditInputsRef.current = inputs + return { initialCapture: previous === null, ...Object.fromEntries(Object.entries(inputs) + .map(([name, value]) => [name, previous !== null && previous[name] !== value])) } + }) const applySnapshot = (next: FreshAgentSnapshot) => { + const historyOnly = isNativeHistoryOnlySnapshot(next) + if ((historyOnly || (requestPaneSoulId && next.extensions?.[provider]?.statusFromLiveState === true)) + && (next.provider !== provider || next.sessionType !== requestSessionType || next.threadId !== sessionId)) { + auditRequest('identity_rejected', () => ({ providerMatches: next.provider === provider, + typeMatches: next.sessionType === requestSessionType, threadMatches: next.threadId === sessionId, historyOnly })) + return + } const snapshotIdentity = currentAutoTitleIdentityRef.current const resolved = next as FreshAgentSnapshot const resolvedHasUserTurns = freshAgentSnapshotHasUserTurn(resolved) @@ -2684,10 +2855,15 @@ export function FreshAgentView({ autoTitleSentRef.current = true } const previousSnapshot = snapshotRef.current - const displaySnapshot = mergeSnapshotForDisplay(previousSnapshot, resolved) + const displaySnapshot = mergeSnapshotForDisplay( + previousSnapshot, + resolved, + isManagedRuntimeRecoveryPending(paneContentRef.current.recoverySummary), + ) const snapshotAccepted = displaySnapshot !== previousSnapshot - const snapshotStatusAuthoritative = provider === 'codex' - || resolved.extensions?.[provider]?.statusFromLiveState === true + if (snapshotAccepted) snapshotRequestAuthorityRef.current.applied = requestSerial + const snapshotStatusAuthoritative = !requestReadOnly && !historyOnly && (provider === 'codex' + || resolved.extensions?.[provider]?.statusFromLiveState === true) const outgoing = outgoingTurnRef.current if ( outgoing && outgoing.requestId === requestOutgoingTurnId @@ -2706,6 +2882,9 @@ export function FreshAgentView({ refreshOutgoingTurn() } commitSnapshot(displaySnapshot) + auditRequest('display_committed', () => ({ ...snapshotAuditData(resolved), accepted: snapshotAccepted, + previousRows: previousSnapshot?.turns.length ?? 0, rows: displaySnapshot.turns.length, + ...(typeof resolved.revision === 'number' && Number.isFinite(resolved.revision) ? { revision: resolved.revision } : {}) })) setSnapshotAutoTitleIdentity(snapshotIdentity) const revealRefreshIsCurrent = ( trigger === 'reveal' @@ -2742,6 +2921,20 @@ export function FreshAgentView({ }, Math.min(250, remaining)) } } + // This read has no live actor authority, even if Retry cleared the intervention while it ran. + const liveProviderState = resolved.extensions?.[provider] + if (requestReadOnly || historyOnly) return + if ( + snapshotAccepted && paneContentRef.current.soulId && requestSessionWasLost && agentSessionLostRef.current + && agentSessionStatusVersionRef.current === requestAgentSessionStatusVersion + && resolved.provider === provider && resolved.sessionType === requestSessionType + && resolved.threadId === sessionId + && liveProviderState?.statusFromLiveState === true + && liveProviderState.ownerKind !== 'vacant' + ) { + dispatch(clearSessionLost({ sessionId: paneContentRef.current.sessionId!, sessionType: requestSessionType, provider })) + dispatch(clearSessionError({ sessionId: paneContentRef.current.sessionId!, sessionType: requestSessionType, provider })) + } const echo = localEchoRef.current const echoPendingMetadata = echo ? pendingSendMetadataRef.current.get(echo.requestId) : undefined const landedEcho = echo @@ -2785,6 +2978,7 @@ export function FreshAgentView({ idleIncompleteRetryCountRef.current = 0 } const fresh = paneContentRef.current + if (requestReadOnly || isManagedRuntimeRecoveryPending(fresh.recoverySummary)) return const nextStatus = (resolved.status as FreshAgentPaneContent['status']) ?? fresh.status const snapshotSessionRef = provider === 'opencode' && resolved.sessionId && resolved.sessionId !== sessionId ? { provider, sessionId: resolved.sessionId } @@ -2813,6 +3007,7 @@ export function FreshAgentView({ && (snapshotIsBusy || snapshotStatusAuthoritative)) if ( sessionStatus + && (agentSessionStatusRef.current === undefined || currentSessionStatus !== sessionStatus) && nextSessionId && canAdoptSnapshotStatus && !wouldRegressStatus @@ -2880,7 +3075,19 @@ export function FreshAgentView({ // AbortError swallow kept as harmless dead armor: scheduler-path // fetches carry no signal (A2), so this can no longer fire. if (error instanceof Error && error.name === 'AbortError') return - if (isStaleSnapshotRequest()) return + if (isStaleSnapshotRequest('error-fold')) return + // A history refusal must not initiate an attach/resume or clear the + // saved identity while the supervisor owns recovery. + if (requestReadOnly || isManagedRuntimeRecoveryPending(paneContentRef.current.recoverySummary)) { + if (paneContentRef.current.recoverySummary?.recoveryState === 'recovering') { + log.debug({ event: 'fresh_agent.recovery_history_unavailable', provider, + sessionType: requestSessionType, sessionId, soulId: requestPaneSoulId, error }) + setLoadError(null) + } else { + setLoadError(error instanceof Error ? error.message : 'Failed to load session') + } + return + } if (paneContent.provider === 'claude' && claudeSession && isRestoring) { // While a restore is in flight the snapshot legitimately 404s. // Outside of restore, swallowing here left dead Claude sessions as @@ -2890,6 +3097,7 @@ export function FreshAgentView({ } if (paneContent.provider === 'codex' && isUnmaterializedCodexThreadError(error)) { const fresh = paneContentRef.current + if (isManagedRuntimeRecoveryPending(fresh.recoverySummary)) return setLoadError(null) commitSnapshot(null) dispatch(updatePaneContent({ @@ -2909,6 +3117,7 @@ export function FreshAgentView({ } if (paneContent.provider === 'opencode' && isLostFreshOpencodeThreadError(error)) { const fresh = paneContentRef.current + if (isManagedRuntimeRecoveryPending(fresh.recoverySummary)) return setLoadError(null) commitSnapshot(null) dispatch(updatePaneContent({ @@ -2997,17 +3206,41 @@ export function FreshAgentView({ } setLoadError(error instanceof Error ? error.message : 'Failed to load session') } - const key = makeSnapshotKey({ sessionType: requestSessionType, provider, threadId: sessionId, cwd: requestCwd }) - void getSnapshotScheduler().schedule(key, trigger, () => + // Keep interactive reads and recovery history reads distinct, with pane authority in both keys. + const key = makeSnapshotKey({ sessionType: requestSessionType, provider, threadId: sessionId, cwd: requestCwd, + soulId: requestPaneSoulId, soulIntentRevision: requestPaneSoulRevision }) + + `:read-generation:${requestReadGeneration}:boot:${requestBootId ?? ''}:owner:${requestOwnerFence?.epoch ?? ''}:${requestOwnerFence?.generation ?? ''}` + let ran = false + auditRequest('request_queued') + void getSnapshotScheduler().schedule(key, trigger, async () => { + ran = true + auditRequest('run_started') // NO signal: the run may execute on behalf of other panes sharing the // key, or after this effect cleaned up (A2). Staleness is handled by // isStaleSnapshotRequest() when the outcome is applied, not by aborting. - getFreshAgentThreadSnapshot(requestSessionType, provider, sessionId, { - ...(requestCwd ? { cwd: requestCwd } : {}), - trigger, - }), + const options = { ...(requestCwd ? { cwd: requestCwd } : {}), trigger } + if (requestSoulId) { + auditRequest('soul_started', () => ({ source: 'direct' })) + return getFreshAgentThreadSnapshot(requestSessionType, provider, sessionId, { ...options, soulId: requestSoulId }) + } + try { + auditRequest('native_started') + const snapshot = await getFreshAgentThreadSnapshot(requestSessionType, provider, sessionId, options) + auditRequest('native_completed', () => snapshotAuditData(snapshot)) + if (!requestPaneSoulId || !isNativeHistoryOnlySnapshot(snapshot)) return snapshot + } catch (error) { + auditRequest('native_failed', () => ({ errorKind: error instanceof Error && error.name === 'AbortError' ? 'abort' : 'error' })) + if (!requestPaneSoulId || isStaleSnapshotRequest('native-error')) throw error + } + // A matching native ID in the web store does not prove the managed provider source. + // Capture the soul with the request and retain the existing application fences. + if (isStaleSnapshotRequest('before-soul')) throw new Error('Conversation source changed during snapshot read') + auditRequest('soul_started', () => ({ source: 'fallback' })) + return getFreshAgentThreadSnapshot(requestSessionType, provider, sessionId, { ...options, soulId: requestPaneSoulId }) + }, ).then((outcome) => { - if (isStaleSnapshotRequest()) return + auditRequest('outcome_received', () => ({ outcome: outcome.status, ran })) + if (isStaleSnapshotRequest('outcome')) return if (outcome.status === 'ok') { applySnapshot(outcome.value as FreshAgentSnapshot) return @@ -3051,12 +3284,22 @@ export function FreshAgentView({ // paneContentRef.current inside the effect. }, [ agentSession?.lost, + appStore, captureFreshAgentAttachmentAttempt, claudeSession, isRestoring, dispatch, paneContent.provider, + paneContent.soulId, + paneContent.soulIntentRevision, paneContent.createRequestId, + managedRecoveryPending, + supervisorRecoveryOwned, + connectionBootId, + runtimeOwner?.epoch, + runtimeOwner?.generation, + runtimeOwner?.transition, + paneContent.recoverySummary?.recoveryState, paneContent.sessionId, paneContent.sessionType, paneId, @@ -3068,6 +3311,7 @@ export function FreshAgentView({ setLocalEcho, snapshotThreadId, snapshotRefreshNonce, + snapshotReadOnly, tabId, ]) @@ -3169,6 +3413,13 @@ export function FreshAgentView({ // elsewhere in this file) that predates this effect and must not be // double-driven. useEffect(() => { + if (supervisorRecoveryOwned) { + if (agentSession?.lost) log.debug('Supervisor recovery retains the lost conversation', { + event: 'fresh_agent.managed_recovery_retains_lost', paneId, + provider: paneContent.provider, sessionId: paneContent.sessionId, soulId: paneContent.soulId, + }) + return + } if (paneContent.provider !== 'claude' && paneContent.provider !== 'codex') return if (!paneContent.sessionId || !agentSession?.lost) return // fresh-eyes F4: the connectionStatus dep also fires on ready->disconnected. @@ -3184,7 +3435,8 @@ export function FreshAgentView({ restoreTimeoutRef.current = window.setTimeout(() => { restoreTimeoutRef.current = null if (paneContentRef.current.sessionId !== sessionIdForRecovery) return - if (!agentSession?.lost) return + if (isSupervisorRecoveryOwned(paneContentRef.current)) return + if (!agentSessionLostRef.current) return if (isFreshAgentReconcileActive()) reconcileLostPane() else triggerRecovery() }, 0) @@ -3202,7 +3454,9 @@ export function FreshAgentView({ agentSession?.latestTurnId, agentSession?.lost, connectionStatus, + paneId, paneContent.provider, + supervisorRecoveryOwned, paneContent.sessionId, reconcileLostPane, triggerRecovery, @@ -3238,7 +3492,8 @@ export function FreshAgentView({ : (agentSession as { lastError?: string } | undefined)?.lastError ?? null // sessionEnded gates everything: a stale snapshot can still claim // capabilities.send after the provider process died. - const canSend = !sessionEnded && (snapshot?.capabilities?.send === true || ( + const snapshotHistoryOnly = isNativeHistoryOnlySnapshot(snapshot) + const canSend = !snapshotHistoryOnly && !managedRecoveryActive && !sessionEnded && (snapshot?.capabilities?.send === true || ( paneContent.provider === 'claude' && Boolean(paneContent.sessionId) && !isRestoring @@ -3254,7 +3509,7 @@ export function FreshAgentView({ // disabled so a user cannot submit text, get a local echo, and issue an // old-kind send the server's generation fence would refuse with a // misleading failure instead of the pane's recoverable attach action. - const composerDisabled = !paneContent.sessionId || sessionEnded || (!canSend && !isBusy) || Boolean(ownerDivergence) + const composerDisabled = snapshotHistoryOnly || managedRecoveryActive || !paneContent.sessionId || sessionEnded || (!canSend && !isBusy) || Boolean(ownerDivergence) useEffect(() => { const outgoing = outgoingTurnRef.current @@ -3284,7 +3539,7 @@ export function FreshAgentView({ // transport event is missed, the pane self-heals within a few seconds // instead of stranding on an empty turn with a stop button. useEffect(() => { - if (hidden || !paneContent.sessionId) return + if (hidden || managedRecoveryPending || !paneContent.sessionId) return // kata b8ke: the runtime-owner transition stops old-kind scheduling // IMMEDIATELY — while the canonical session is owned by the other kind, // no fallback poll re-arms (the effect re-runs on the divergence flip @@ -3296,7 +3551,7 @@ export function FreshAgentView({ requestSnapshotRefresh('poll') }, 3000) return () => window.clearInterval(timer) - }, [effectiveStatus, hidden, isBusy, ownerDivergence, paneContent.sessionId, requestSnapshotRefresh]) + }, [effectiveStatus, hidden, isBusy, managedRecoveryPending, ownerDivergence, paneContent.sessionId, requestSnapshotRefresh]) useEffect(() => { if (!notice) return @@ -3307,7 +3562,7 @@ export function FreshAgentView({ /** Core outgoing-message path shared by direct sends and queue flushes. */ const sendUserText = useCallback((text: string) => { const current = paneContentRef.current - if (!current.sessionId) return + if (!current.sessionId || isManagedRuntimeRecoveryPending(current.recoverySummary)) return const requestId = nanoid() outgoingTurnRef.current = { requestId, text, sawBusy: false, previousTurns: snapshotRef.current?.turns ?? [] } // Task 16: a new send starts a fresh idle-incomplete re-poll budget. @@ -3500,6 +3755,8 @@ export function FreshAgentView({ }, [dispatch, freshOpenCodeRouteCwd]) const content = useMemo(() => { + const retainedHistory = snapshot?.extensions?.[snapshot.provider]?.nativeHistoryRetention + const partialHistory = isRecord(retainedHistory) && retainedHistory.partial === true const turns = snapshot?.turns ?? [] const pendingApprovals = snapshot?.pendingApprovals ?? [] const pendingQuestions = snapshot?.pendingQuestions ?? [] @@ -3517,12 +3774,12 @@ export function FreshAgentView({ // owns the session; an old-kind interrupt would at best fail the // server's generation fence and at worst tear at a writer the // diverged pane no longer owns). - const canInterrupt = !ownerDivergence && isBusy && (snapshot?.capabilities?.interrupt === true || ( + const canInterrupt = !snapshotHistoryOnly && !managedRecoveryActive && !ownerDivergence && isBusy && (snapshot?.capabilities?.interrupt === true || ( paneContent.provider === 'claude' && Boolean(paneContent.sessionId) && ['connected', 'running', 'compacting'].includes(effectiveStatus) )) - const canFork = snapshot?.capabilities?.fork === true + const canFork = !managedRecoveryActive && snapshot?.capabilities?.fork === true const questionAgentLabel = getQuestionAgentLabel(paneContent, descriptor?.label) // Session-record locator for the dismissal dispatches below — the same // triple the agentSession selector keys on. `sessionId` is non-empty @@ -3539,8 +3796,9 @@ export function FreshAgentView({ const visiblePaneRestoreFailure = visibleRestoreFailure ? null : (paneContent.restoreError ? getRestoreErrorMessage(paneContent.restoreError.reason) : null) - const visibleLoadError = visibleRestoreFailure || visiblePaneRestoreFailure || isRestoring ? null : loadError - const revealRefreshBlocking = !hidden && snapshotDirty + const visibleLoadError = paneContent.recoverySummary?.recoveryState === 'recovering' + || visibleRestoreFailure || visiblePaneRestoreFailure || isRestoring ? null : loadError + const revealRefreshBlocking = !managedRecoveryPending && !hidden && snapshotDirty const revealRefreshStatus = snapshotRevealError ? 'The conversation could not be refreshed.' : 'Refreshing conversation' @@ -3641,6 +3899,7 @@ export function FreshAgentView({ type="button" className="fresh-agent-error-action rounded border border-border/70 px-2 py-1" onClick={() => { + if (isManagedRuntimeRecoveryPending(paneContentRef.current.recoverySummary)) return const nextRequestId = nanoid() dispatch(updatePaneContent({ tabId, @@ -3693,7 +3952,13 @@ export function FreshAgentView({ onDismiss={() => dispatch(clearSessionError(sessionRecordLocator))} /> ) : null} - {effectiveStatus === 'stuck' ? ( + {paneContent.closeError ? ( + dispatch(clearPaneCloseError({ tabId, paneId }))} + /> + ) : null} + {effectiveStatus === 'stuck' && !managedRecoveryActive ? (
Start new conversation @@ -3785,7 +4050,15 @@ export function FreshAgentView({ paneId={paneId} /> ) : null} - {sessionEnded ? ( + {isManagedRuntimeRecoveryDecision(paneContent.recoverySummary) ? ( + + ) : null} + {!managedRecoveryActive && sessionEnded ? (
This session has ended{sessionErrorMessage ? '' : ' (the agent process exited)'}.
@@ -3803,7 +4076,7 @@ export function FreshAgentView({ @@ -3875,6 +4148,11 @@ export function FreshAgentView({ ? { 'aria-hidden': true, 'data-testid': 'fresh-agent-stale-transcript' } : {})} > + {partialHistory ? ( +
+ Showing retained conversation history. Older turns or large content were omitted from this view. The saved conversation has not been changed. +
+ ) : null} { diff --git a/src/lib/api.ts b/src/lib/api.ts index 93256d508..36099857a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -23,6 +23,7 @@ import { ManagedRuntimeRepairAuditSchema, ManagedRuntimeSoulDetailSchema, ManagedRuntimeUpdateLimitsResultSchema, + ManagedRuntimeViewIntentSchema, type ManagedRuntimeIncidentSummary, type ManagedRuntimeInventorySnapshot, type ManagedRuntimeLimits, @@ -34,6 +35,7 @@ import { type ManagedRuntimeRolloutMode, type ManagedRuntimeSoulDetail, type ManagedRuntimeUpdateLimitsResult, + type ManagedRuntimeViewIntent, type ManagedRuntimeViewVisibility, } from '@shared/managed-runtime' import { parseFreshAgentModelCapabilitiesResponse } from '@/lib/fresh-agent-model-capabilities' @@ -458,15 +460,25 @@ export async function retryManagedRuntimeSoul( }) } +const ManagedRuntimeStopResultSchema = z.object({ + outcome: z.enum(['verified_empty', 'blocked_ownership', 'backend_unavailable', 'termination_unconfirmed']), + soul: z.object({ + soulId: z.string().min(1), + intentRevision: z.number().int().nonnegative(), + }), +}) + +export type ManagedRuntimeStopResult = z.infer + export async function stopManagedRuntimeSoul( soulId: string, expectedIntentRevision: number, requestId = createManagedRuntimeRequestId(), -): Promise { - return api.post(`/api/runtime/souls/${encodeURIComponent(soulId)}/stop`, { +): Promise { + return ManagedRuntimeStopResultSchema.parse(await api.post(`/api/runtime/souls/${encodeURIComponent(soulId)}/stop`, { requestId, expectedIntentRevision, - }) + })) } export async function updateManagedRuntimeLimits( @@ -490,13 +502,16 @@ export async function updateManagedRuntimeViewVisibility( expectedRevision: number, expectedSoulIntentRevision: number, requestId = createManagedRuntimeRequestId(), -): Promise { - return api.patch(`/api/runtime/views/${encodeURIComponent(viewId)}`, { - requestId, - visibility, - expectedRevision, - expectedSoulIntentRevision, - }) + options: ApiRequestOptions = {}, +): Promise { + return ManagedRuntimeViewIntentSchema.parse( + await api.patch(`/api/runtime/views/${encodeURIComponent(viewId)}`, { + requestId, + visibility, + expectedRevision, + expectedSoulIntentRevision, + }, options), + ) } export async function getManagedRuntimeIncidentSummary( @@ -668,11 +683,12 @@ export async function getFreshAgentThreadSnapshot( sessionType: string, provider: string, threadId: string, - query: { revision?: number; cwd?: string; trigger?: string; signal?: AbortSignal } = {}, + query: { revision?: number; cwd?: string; trigger?: string; signal?: AbortSignal; soulId?: string } = {}, options: ApiRequestOptions = {}, ): Promise { const signal = query.signal ?? options.signal const data = await api.get( + query.soulId ? `/api/runtime/souls/${encodeURIComponent(query.soulId)}/history` : `/api/fresh-agent/threads/${encodeURIComponent(sessionType)}/${encodeURIComponent(provider)}/${encodeURIComponent(threadId)}${buildQueryString([ ['revision', query.revision], ['cwd', query.cwd], diff --git a/src/lib/fresh-agent-snapshot-scheduler.ts b/src/lib/fresh-agent-snapshot-scheduler.ts index 81931e4b7..3581bd8d1 100644 --- a/src/lib/fresh-agent-snapshot-scheduler.ts +++ b/src/lib/fresh-agent-snapshot-scheduler.ts @@ -34,9 +34,9 @@ const BACKOFF_MAX_MS = 30_000 const DEBOUNCED_TRIGGERS: ReadonlySet = new Set(['event', 'send-accepted', 'reveal', 'reconnect']) export function makeSnapshotKey(input: { - sessionType: string; provider: string; threadId: string; cwd?: string + sessionType: string; provider: string; threadId: string; cwd?: string; soulId?: string; soulIntentRevision?: number }): string { - return `${input.sessionType}:${input.provider}:${input.threadId}:${input.cwd ?? ''}` + return `${input.sessionType}:${input.provider}:${input.threadId}:${input.cwd ?? ''}${input.soulId ? `:soul:${input.soulId}:${input.soulIntentRevision ?? ''}` : ''}` } type Resolver = (outcome: SnapshotOutcome) => void diff --git a/src/lib/fresh-agent-snapshot-thread.ts b/src/lib/fresh-agent-snapshot-thread.ts index 434da96bd..9f98a0732 100644 --- a/src/lib/fresh-agent-snapshot-thread.ts +++ b/src/lib/fresh-agent-snapshot-thread.ts @@ -1,12 +1,24 @@ import type { FreshAgentPaneContent } from '@/store/paneTypes' import { getCanonicalDurableSessionId, type SessionIdentityState } from '@/store/persistControl' import { isValidClaudeSessionId } from '@/lib/claude-session-id' +import { isDurableProviderSessionId } from '@shared/session-flavor' // Same create/start gate the FreshAgentView/panesSlice early-state sets apply: // while a new session is still being created, the pane must not read an older // durable ref. const EARLY_STATES = new Set(['creating', 'starting']) +/** A known managed conversation remains readable while its same-request + * bootstrap has no live handle. This is exclusively an owned history source; + * it cannot supply the settings probe or certify a live runtime. */ +export function getManagedBootstrapHistoryThreadId(pane: FreshAgentPaneContent): string | undefined { + if (!pane.soulId || !pane.createRequestId || pane.sessionId + || (pane.pendingReconcile !== 'fresh' && pane.pendingReconcile !== 'respawn')) return undefined + const ref = pane.sessionRef + if (ref?.provider !== pane.provider || !isDurableProviderSessionId(pane.provider, ref.sessionId)) return undefined + return ref.sessionId +} + function getCanonicalPaneResumeSessionId(pane: FreshAgentPaneContent): string | undefined { if (pane.sessionRef?.provider === 'claude' && isValidClaudeSessionId(pane.sessionRef.sessionId)) { return pane.sessionRef.sessionId diff --git a/src/lib/managed-runtime-recovery-message.ts b/src/lib/managed-runtime-recovery-message.ts new file mode 100644 index 000000000..578967307 --- /dev/null +++ b/src/lib/managed-runtime-recovery-message.ts @@ -0,0 +1,38 @@ +import type { ManagedRuntimeRecoverySummary } from '@shared/managed-runtime' + +/** Pending supervisor recovery owns lifecycle changes, including quiet automatic recovery. */ +export function isManagedRuntimeRecoveryPending(summary?: ManagedRuntimeRecoverySummary): boolean { + return summary?.recoveryState === 'recovering' + || summary?.recoveryState === 'blocked' + || summary?.recoveryState === 'lost' +} + +const BLOCKED_REASONS: Record = { + CAPABILITY_PENDING: 'The provider is still preparing its recovery support. Wait, then retry recovery.', + CREDENTIALS_EXPIRED: 'Refresh the provider sign-in, then retry recovery.', + RATE_LIMITED: 'The provider is limiting requests. Wait, then retry recovery.', + PROVIDER_UNAVAILABLE: 'The provider is unavailable. Check its availability, then retry recovery.', + STORE_UNREADABLE: 'Check that the saved conversation store is readable, then retry recovery.', + STORE_MISSING: 'Restore the saved conversation store, then retry recovery.', + WORKSPACE_UNAVAILABLE: 'Restore access to the project folder, then retry recovery.', + INCOMPATIBLE_BINARY: 'The installed provider version cannot recover this session. Check the provider installation, then retry recovery.', + UNSUPPORTED_PROTOCOL: 'The installed provider cannot use this recovery connection. Check the provider installation, then retry recovery.', + AMBIGUOUS_IDENTITY: 'The saved conversation could not be identified with certainty. Check the provider conversation store before retrying recovery.', + IMPLEMENTATION_UNAVAILABLE: 'Recovery support is unavailable for this provider. Check the provider installation before retrying recovery.', + INSUFFICIENT_RESOURCES: 'There are not enough system resources. Free resources, then retry recovery.', + RETRY_BUDGET: 'Automatic recovery attempts have been exhausted. Check the provider and saved conversation store, then retry recovery.', + STOP_INTENT: 'This session was requested to stop. Check its state before retrying recovery.', + OLD_RUNTIME_NOT_EMPTY: 'The previous agent process could not be confirmed stopped. Check it before retrying recovery.', + WRONG_NATIVE_IDENTITY: 'The provider returned a different conversation. Check the saved conversation identity before retrying recovery.', + COMMAND_AMBIGUOUS: 'The provider command could not be confirmed. Check its state before retrying recovery.', + INTERRUPTED_BEFORE_OWNERSHIP_COMMIT: 'The previous agent startup was interrupted. Check its process state before retrying recovery.', +} + +export function managedRecoveryBlockedMessage(reason?: string): string { + const code = reason?.trim().toUpperCase() + // The registry persists this verdict with a prefix; probes use RETRY_BUDGET. + const known = code && BLOCKED_REASONS[code === 'BLOCKED_RETRY_BUDGET' ? 'RETRY_BUDGET' : code] + return known || (reason?.trim() + ? `Recovery is blocked: ${reason.trim()}. Address this problem, then retry recovery.` + : 'Recovery is still blocked. Check the provider, project folder, and saved conversation store, then retry recovery.') +} diff --git a/src/lib/managed-runtime-retry.ts b/src/lib/managed-runtime-retry.ts new file mode 100644 index 000000000..6da56a4e8 --- /dev/null +++ b/src/lib/managed-runtime-retry.ts @@ -0,0 +1,63 @@ +import { z } from 'zod' +import { retryManagedRuntimeSoul } from '@/lib/api' +import { managedRecoveryBlockedMessage } from '@/lib/managed-runtime-recovery-message' +import { queueManagedRuntimeRefresh } from '@/lib/recovery/managed-runtime-recovery' +import type { AppStore } from '@/store/store' +import type { ManagedRuntimeProjectionFields } from '@shared/managed-runtime' + +type ManagedConversation = ManagedRuntimeProjectionFields & { createRequestId: string } + +// The supervisor serializes the blocked probe with snake_case data keys; +// RetryHint itself uses camelCase. Only fields needed for pane feedback are read. +const RetryResultSchema = z.object({ + outcome: z.string(), + view: z.object({ + soulId: z.string(), + intentRevision: z.number().int().nonnegative(), + recoveryReason: z.string().optional(), + }), + probe: z.object({ + kind: z.literal('blocked'), + data: z.object({ + reason: z.string(), + retry_hint: z.object({ repair: z.string().optional() }).optional(), + }), + }).nullish().catch(undefined), +}) + +/** Retry the same conversation and keep an unresolved decision actionable. */ +export async function retryManagedConversation( + content: ManagedConversation, + getCurrent: () => ManagedConversation | null | undefined, + store: Pick, +): Promise { + if (!content.soulId || typeof content.soulIntentRevision !== 'number') { + throw new Error('Managed recovery is missing its current revision.') + } + const isCurrent = (revision = content.soulIntentRevision) => { + const latest = getCurrent() + return Boolean(latest && latest.soulId === content.soulId + && latest.createRequestId === content.createRequestId + && latest.soulIntentRevision === revision + && latest.recoverySummary?.recoveryState === 'blocked') + } + let response: unknown + try { + response = await retryManagedRuntimeSoul(content.soulId, content.soulIntentRevision) + } catch (error) { + if (!isCurrent()) return + throw error + } + if (!isCurrent()) return + const parsed = RetryResultSchema.safeParse(response) + const result = parsed.success ? parsed.data : undefined + if (result && (result.view.soulId !== content.soulId + || result.view.intentRevision < content.soulIntentRevision)) return + await queueManagedRuntimeRefresh(store, 'pane-recovery-retry') + if (!isCurrent(result?.view.intentRevision ?? content.soulIntentRevision)) return + // A completed HTTP request is not evidence that recovery succeeded. If the + // refreshed pane still needs a decision, retain the supervisor's guidance. + const repair = result?.outcome === 'blocked' ? result.probe?.data.retry_hint?.repair?.trim() : undefined + const reason = result?.probe?.data.reason ?? result?.view.recoveryReason ?? getCurrent()?.recoverySummary?.reason + throw new Error(repair || managedRecoveryBlockedMessage(reason)) +} diff --git a/src/lib/managed-runtime-stop.ts b/src/lib/managed-runtime-stop.ts new file mode 100644 index 000000000..488dce4f1 --- /dev/null +++ b/src/lib/managed-runtime-stop.ts @@ -0,0 +1,41 @@ +import { stopManagedRuntimeSoul, type ManagedRuntimeStopResult } from '@/lib/api' +import type { ManagedRuntimeProjectionFields } from '@shared/managed-runtime' + +type ManagedConversation = ManagedRuntimeProjectionFields & { createRequestId: string } +type StopContext = { + getCurrent: () => ManagedConversation | null | undefined + applyIntentRevision: (revision: number) => void +} + +const CLEANUP_UNCONFIRMED_MESSAGE = 'Cleanup could not be confirmed. Your conversation has been kept. Try again.' + +/** Confirm cleanup through the persisted soul, including an already stopped lost soul. */ +export async function confirmManagedRuntimeStopped(content: ManagedConversation, context: StopContext): Promise { + if (!content.soulId || typeof content.soulIntentRevision !== 'number') { + throw new Error('Cleanup is missing its current session revision. Your conversation has been kept.') + } + const isCurrent = (latest: ManagedConversation | null | undefined): latest is ManagedConversation & { soulIntentRevision: number } => latest?.soulId === content.soulId + && latest?.createRequestId === content.createRequestId + && typeof latest?.soulIntentRevision === 'number' + let result: ManagedRuntimeStopResult + try { + result = await stopManagedRuntimeSoul(content.soulId, content.soulIntentRevision) + } catch (error) { + const latest = context.getCurrent() + if (!isCurrent(latest) || latest.soulIntentRevision > content.soulIntentRevision) return false + throw error + } + const latest = context.getCurrent() + if (!isCurrent(latest)) return false + if (result.soul.soulId !== content.soulId || result.soul.intentRevision < content.soulIntentRevision) { + throw new Error(CLEANUP_UNCONFIRMED_MESSAGE) + } + if (latest.soulIntentRevision > result.soul.intentRevision) return false + // Stop commits its intent before attempting cleanup. Even an uncertain + // result is the revision authority for the user's immediate retry. + context.applyIntentRevision(result.soul.intentRevision) + if (result.outcome !== 'verified_empty') { + throw new Error(CLEANUP_UNCONFIRMED_MESSAGE) + } + return true +} diff --git a/src/lib/recovery/managed-runtime-recovery.ts b/src/lib/recovery/managed-runtime-recovery.ts index eec2950e7..1d96650a8 100644 --- a/src/lib/recovery/managed-runtime-recovery.ts +++ b/src/lib/recovery/managed-runtime-recovery.ts @@ -1,3 +1,4 @@ +import { resolveFreshAgentRuntimeProvider } from '@shared/fresh-agent' import type { AppStore, RootState } from '@/store/store' import { addTab, updateTab } from '@/store/tabsSlice' import { initLayout, updatePaneContent } from '@/store/panesSlice' @@ -99,9 +100,17 @@ export function managedProjectionFields( } } +function clientProviderFor(soul: ManagedRuntimeSoul) { + // Kilroy is a distinct managed provider using the public Claude transport. + return soul.provider === 'kilroy' && soul.freshAgentSessionType === 'kilroy' + ? resolveFreshAgentRuntimeProvider(soul.freshAgentSessionType) + : soul.provider +} + function sessionRefFor(soul: ManagedRuntimeSoul) { - return soul.provider && soul.nativeSessionId - ? { provider: soul.provider, sessionId: soul.nativeSessionId } + const provider = clientProviderFor(soul) + return provider && soul.nativeSessionId + ? { provider, sessionId: soul.nativeSessionId } : undefined } @@ -182,34 +191,61 @@ function updateExistingContent( return existing } -function paneMatchesView( +/** Prefer view identity, then source creation, runtime, and saved-session identity. */ +function paneMatchPriority( location: PaneLocation, soul: ManagedRuntimeSoul, view: ManagedRuntimeViewIntent, - exactOnly: boolean, -): boolean { +): number { const content = location.content - if (content.kind !== 'terminal' && content.kind !== 'fresh-agent') return false - if (content.viewIntentId === view.viewId) return true - if (exactOnly) return false - if (content.soulId === soul.soulId) return true - if (content.kind === 'terminal' && soul.terminalId && content.terminalId === soul.terminalId) { - return true - } + if (content.kind !== 'terminal' && content.kind !== 'fresh-agent') return 0 + if (content.viewIntentId === view.viewId) return 4 + // A conversation may have several views. Its creation seed and shared + // session identity must never overwrite another view's existing binding. + if (view.kind === 'explicit' || content.viewIntentId) return 0 // The originating pane knows its createRequestId long before the server // answers with a terminalId. Without this, the whole create round trip is a // window in which the pane is invisible to the matcher and the reconciler // manufactures a SECOND view of the same soul — a duplicate tab over one // writer, and a pane whose output the user never sees. if (soul.terminalCreateRequestId && content.createRequestId === soul.terminalCreateRequestId) { - return true + return 3 } + // The persisted create request binds inventory before the created ack; + // the runtime session ID also supports older inventory after that ack. + const freshProvider = clientProviderFor(soul) + const freshAgent = content.kind === 'fresh-agent' + && content.provider === freshProvider + && content.sessionType === soul.freshAgentSessionType + ? content : undefined + if (freshAgent && soul.freshAgentCreateRequestId && freshAgent.createRequestId === soul.freshAgentCreateRequestId) return 3 + if (content.soulId === soul.soulId) return 2 + if (content.kind === 'terminal' && soul.terminalId && content.terminalId === soul.terminalId) return 2 + if (freshAgent && soul.freshAgentSessionId && freshAgent.sessionId === soul.freshAgentSessionId) return 1 const sessionRef = sessionRefFor(soul) - return Boolean( - sessionRef - && content.sessionRef?.provider === sessionRef.provider - && content.sessionRef.sessionId === sessionRef.sessionId, - ) + if (sessionRef + && content.sessionRef?.provider === sessionRef.provider + && content.sessionRef.sessionId === sessionRef.sessionId) return 1 + return 0 +} + +function findPaneForView( + locations: PaneLocation[], + soul: ManagedRuntimeSoul, + view: ManagedRuntimeViewIntent, + claimed: Set, +): PaneLocation | undefined { + let location: PaneLocation | undefined + let bestPriority = 0 + for (const candidate of locations) { + if (claimed.has(`${candidate.tabId}:${candidate.paneId}`)) continue + const priority = paneMatchPriority(candidate, soul, view) + if (priority > bestPriority) { + location = candidate + bestPriority = priority + } + } + return location } function collisionFreeTabId( @@ -253,13 +289,9 @@ export function buildManagedRuntimeMergePlan( for (const view of views) { const soul = souls.get(view.soulId) - if (!soul || soul.desiredState !== 'running') continue + if (!soul) continue - const exactOnly = view.kind === 'explicit' - const location = locations.find((candidate) => { - const key = `${candidate.tabId}:${candidate.paneId}` - return !claimed.has(key) && paneMatchesView(candidate, soul, view, exactOnly) - }) + const location = findPaneForView(locations, soul, view, claimed) const fields = managedProjectionFields(soul, view) const status = terminalStatus(soul) const sessionRef = sessionRefFor(soul) @@ -287,6 +319,12 @@ export function buildManagedRuntimeMergePlan( continue } + // A stopped/lost soul is a decision that belongs to an existing pane. It + // must never reconstruct a view after the supervisor has certified that + // the old conversation cannot continue. Only a desired running soul may + // create a missing visible view. + if (soul.desiredState !== 'running') continue + // Detached/hidden intents update a still-present local view honestly but // never manufacture a new one. A later supervisor startup may promote an // automatic primary intent back to visible, at which point it is restored. diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 9ce8fbe27..702fa018a 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -302,6 +302,8 @@ export type FreshAgentPaneContent = { restoreError?: RestoreError initialCwd?: string createError?: FreshAgentCreateError + /** Volatile close failure, shown in the pane's dismissible yellow error banner. */ + closeError?: string modelSelection?: FreshAgentModelSelection model?: string permissionMode?: string diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index bb43b1bcb..d2cf99776 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -35,6 +35,7 @@ import { sanitizeRestoreError, sanitizeCrashTrace, sanitizeSessionRef, type Rest import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' import { migrateLegacyFreshAgentContent, migrateLegacyFreshAgentDurableState, preservedDurableFreshAgentIdentity } from '@shared/fresh-agent' import { normalizeFreshAgentStyleOverride } from '@shared/settings' +import { isDurableProviderSessionId } from '@shared/session-flavor' import { parsePaneNamingIdentityInput } from '@/lib/tab-name-source' import { ManagedRuntimeProjectionFieldsSchema, type ManagedRuntimeProjectionFields } from '@shared/managed-runtime' @@ -174,6 +175,7 @@ function normalizePaneContent( const pendingLocalEcho = normalizeFreshAgentPendingLocalEcho(rawFreshAgent.pendingLocalEcho) const modelEffortLevels = normalizeFreshAgentModelEffortLevels(rawFreshAgent.modelEffortLevels) const freshHandoffError = normalizeHandoffError(rawFreshAgent.handoffError) + const closeError = typeof input.closeError === 'string' ? input.closeError : undefined const rawModelLabel = rawFreshAgent.modelLabel const modelLabel = rawModelLabel && typeof rawModelLabel === 'object' @@ -227,6 +229,7 @@ function normalizePaneContent( restoreError: existingRestoreError, initialCwd: input.initialCwd, createError: input.createError, + closeError, modelSelection: normalizeFreshAgentModelSelection( (input as { modelSelection?: unknown }).modelSelection, (input as { model?: unknown }).model, @@ -298,6 +301,7 @@ function normalizePaneContent( return { kind: 'fresh-agent', ...normalizeManagedRuntimeProjection(rawFreshAgent), + closeError, sessionType: input.sessionType, provider: input.provider, sessionId: input.sessionId, @@ -674,6 +678,25 @@ function findReconcilePaneContent( return undefined } +/** Clear supervisor-owned identity/projection fields for a user-chosen new + * conversation. Reconcile folds deliberately do not use this helper: those + * folds preserve the existing create key and managed identity until the + * supervisor supplies the next authoritative projection. */ +function clearManagedRuntimeProjection( + content: TerminalPaneContent | FreshAgentPaneContent, +): void { + content.soulId = undefined + content.incarnationId = undefined + content.runtimeState = undefined + content.viewIntentId = undefined + content.viewIntentRevision = undefined + content.soulIntentRevision = undefined + content.incidentId = undefined + content.placementGroup = undefined + content.resourceSummary = undefined + content.recoverySummary = undefined +} + function freshAgentPaneMatchesMaterializedSession( content: FreshAgentPaneContent, materialized: FreshAgentSessionMaterializedPayload, @@ -1868,6 +1891,67 @@ export const panesSlice = createSlice({ reconcileRefreshRequestsForTab(state, tabId) }, + /** + * Start a genuinely new user-chosen conversation after the old one has + * been closed or certified lost. This is intentionally separate from + * reconcile folds: a deliberate new conversation mints a new lifecycle + * key and drops every managed-runtime projection so an old inventory + * snapshot cannot reattach the retired soul. + */ + startNewManagedRuntimeConversation: ( + state, + action: PayloadAction<{ tabId: string; paneId: string }>, + ) => { + const { tabId, paneId } = action.payload + const root = state.layouts[tabId] + if (!root) return + if (refuseRekeyWhileClosing(state, tabId, paneId, 'startNewManagedRuntimeConversation')) return + + const leaf = findLeaf(root, paneId) + if (!leaf || (leaf.content.kind !== 'terminal' && leaf.content.kind !== 'fresh-agent')) return + + const content = leaf.content + if (content.kind === 'terminal') { + content.terminalId = undefined + content.serverInstanceId = undefined + content.streamId = undefined + content.status = 'creating' + content.createRequestId = nanoid() + content.sessionRef = undefined + content.resumeSessionId = undefined + content.codexDurability = undefined + content.restoreError = undefined + content.reconcileNotice = undefined + content.pendingReconcile = undefined + content.reconcileEpoch = undefined + content.crashTrace = undefined + content.launchFailure = undefined + content.handoffError = undefined + content.namingHandle = undefined + content.nameRef = undefined + clearManagedRuntimeProjection(content) + } else { + content.sessionId = undefined + content.serverInstanceId = undefined + content.status = 'creating' + content.createRequestId = nanoid() + content.sessionRef = undefined + content.resumeSessionId = undefined + content.restoreError = undefined + content.createError = undefined + content.closeError = undefined + content.reconcileNotice = undefined + content.pendingReconcile = undefined + content.reconcileEpoch = undefined + content.pendingLocalEcho = undefined + content.handoffError = undefined + content.namingHandle = undefined + content.nameRef = undefined + clearManagedRuntimeProjection(content) + } + reconcileRefreshRequestsForTab(state, tabId) + }, + requestPaneRefresh: ( state, action: PayloadAction<{ tabId: string; paneId: string }> @@ -2643,6 +2727,16 @@ export const panesSlice = createSlice({ let { intent } = action.payload const content = findReconcilePaneContent(state, tabId, paneId) if (!content || content.kind !== 'fresh-agent') return + // A missing web-runtime observation does not erase an owned conversation. + // Preserve only its saved locator; the live handle must still clear so + // the original-request bootstrap runs. Invalid respawn is never this case. + const savedRef = action.payload.intent === 'fresh' + && reason === 'identity_never_observed' + && content.soulId && content.createRequestId + ? sanitizeSessionRef(content.sessionRef) : undefined + const retainedManagedRef = savedRef?.provider === content.provider + && isDurableProviderSessionId(content.provider, savedRef.sessionId) + ? savedRef : undefined if (intent === 'respawn' && (!sessionRef?.sessionId || sessionRef.provider !== content.provider)) { log.error('fresh-agent respawn verdict without a usable sessionRef — degrading to fresh', { tabId, @@ -2659,6 +2753,9 @@ export const panesSlice = createSlice({ if (intent === 'respawn' && sessionRef) { content.sessionRef = { provider: sessionRef.provider, sessionId: sessionRef.sessionId } content.resumeSessionId = sessionRef.sessionId + } else if (retainedManagedRef) { + content.sessionRef = retainedManagedRef + content.resumeSessionId = retainedManagedRef.sessionId } else { content.sessionRef = undefined content.resumeSessionId = undefined @@ -2682,13 +2779,13 @@ export const panesSlice = createSlice({ // Delta-r7-r3 (focused-episode-7 round 2 Finding F2): the close gate's // failure surface — the unconfirmed-close reason carried on the pane // itself (TerminalView renders it as the xterm "[Close failed]" notice - // and clears it). Terminal panes only: the fresh-agent lane has its own - // session-error banner. + // and clears it). Fresh Agent panes show it through their existing + // yellow error banner, independently of any preceding kill's result. setPaneCloseError: ( state, action: PayloadAction<{ tabId: string; paneId: string; error: string }> ) => { - const content = findReconcileTerminalContent(state, action.payload.tabId, action.payload.paneId) + const content = findReconcilePaneContent(state, action.payload.tabId, action.payload.paneId) if (!content) return content.closeError = action.payload.error }, @@ -2697,7 +2794,7 @@ export const panesSlice = createSlice({ state, action: PayloadAction<{ tabId: string; paneId: string }> ) => { - const content = findReconcileTerminalContent(state, action.payload.tabId, action.payload.paneId) + const content = findReconcilePaneContent(state, action.payload.tabId, action.payload.paneId) if (!content) return content.closeError = undefined }, @@ -2884,6 +2981,7 @@ export const { resetPaneForReconcileCreate, applyFreshAgentReconcileAttach, resetFreshAgentPaneForReconcileCreate, + startNewManagedRuntimeConversation, setPaneReconcileNotice, clearPaneReconcileNotice, setPaneCloseError, diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 645f2500c..7ff5fdaaf 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit' import type { Tab, TerminalStatus, TabMode, ShellType, CodingCliProviderName } from './types' -import type { ManagedRuntimeProjectionFields } from '@shared/managed-runtime' +import type { ManagedRuntimeProjectionFields, ManagedRuntimeViewIntent } from '@shared/managed-runtime' import { nanoid } from 'nanoid' import { closePane, initLayout, restoreLayout, removeLayout, replacePane, setPaneCloseError, updatePaneContent, updatePaneTitleByTerminalId, updatePaneTitle, markTabClosing, clearTabClosing, markPaneClosing, clearPaneClosing, hasAnyClosePending } from './panesSlice' import { clearTabAttention, clearPaneAttention } from './turnCompletionSlice.js' @@ -27,6 +27,7 @@ import type { RootState } from './store' import { selectTabIdByTerminalId } from './selectors/paneTerminalSelectors' import { loadPersistedLayout, markTabsLoadRecovery } from './persistMiddleware' import { createLogger } from '@/lib/client-logger' +import { getManagedRuntimeSoul, updateManagedRuntimeViewVisibility } from '@/lib/api' import { mergeSessionMetadataByKey, sessionMetadataKey } from '@/lib/session-metadata' import { mergeSessionMetadataForPreferredResumeId } from './persistControl' import { migrateLegacyTerminalDurableState, sanitizeSessionRef } from '@shared/session-contract' @@ -546,6 +547,628 @@ function collectPaneIds(node: PaneNode | undefined): string[] { return [...collectPaneIds(node.children[0]), ...collectPaneIds(node.children[1])] } +type FrozenManagedViewProjection = { + paneId: string + createRequestId?: string + soulId?: string + viewId: string + viewRevision: number + soulRevision: number +} + +type ManagedViewCloseProjection = FrozenManagedViewProjection & { + canRepair: () => boolean + allowOlderRepairs: () => void +} + +// Close ownership is ephemeral and belongs to one Redux store. A retry takes +// its view only when it reaches the visibility transaction after evidence. +const managedViewCloseOwners = new WeakMap<() => unknown, Map>() + +function ownManagedViewProjection( + projection: FrozenManagedViewProjection, + tabId: string, + getState: () => unknown, +): ManagedViewCloseProjection { + let owners = managedViewCloseOwners.get(getState) + if (!owners) { + owners = new Map() + managedViewCloseOwners.set(getState, owners) + } + const owner = { failed: false } + owners.set(projection.viewId, owner) + return { + ...projection, + canRepair: () => { + const state = getState() as RootState + const layout = state.panes.layouts[tabId] + const content = layout ? findPaneContent(layout, projection.paneId) : undefined + if ( + !content || (content.kind !== 'terminal' && content.kind !== 'fresh-agent') + || content.createRequestId !== projection.createRequestId + || content.viewIntentId !== projection.viewId || content.soulId !== projection.soulId + ) return false + // A newer pending visibility transaction decides visibility. If it + // fails and keeps the pane, older late outcomes can repair it again. + const latest = owners.get(projection.viewId) + return latest === owner || latest?.failed === true + }, + allowOlderRepairs: () => { owner.failed = true }, + } +} + +class ManagedViewRepairSupersededError extends Error {} + +export const MANAGED_VIEW_DETACH_TIMEOUT_MS = KILL_ACK_TIMEOUT_MS +/** + * Keep the original timed-out PATCH observed for one additional bounded + * window. A late result after this point is still handled, but the client has + * already recorded that it could not establish the outcome in time. + */ +export const MANAGED_VIEW_LATE_SETTLEMENT_GRACE_MS = KILL_ACK_TIMEOUT_MS * 2 + +class ManagedRuntimeRequestTimeoutError extends Error { + constructor(operation: string) { + super(`${operation} timed out`) + this.name = 'ManagedRuntimeRequestTimeoutError' + } +} + +function isManagedRuntimeRequestTimeout(error: unknown): error is ManagedRuntimeRequestTimeoutError { + return error instanceof ManagedRuntimeRequestTimeoutError +} + +type ManagedRuntimeRequestOutcome = + | { ok: true; value: T } + | { ok: false; error: unknown } + +type ManagedRuntimeRequestHooks = { + onTimeout?: () => void + onGraceExpired?: () => void + onLateSettlement?: (outcome: ManagedRuntimeRequestOutcome) => void | Promise +} + +function managedRuntimeErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function logManagedRuntimeLateSettlementFailure( + operation: string, + error: unknown, +) { + log.error('managed runtime late-settlement repair failed', { + event: 'managed_view_visibility_late_settlement_repair_failed', + operation, + error: managedRuntimeErrorMessage(error), + }) +} + +function logManagedRuntimeUncertainOutcome( + projection: ManagedViewCloseProjection, + operation: 'detach' | 'rollback', + phase: string, + error?: unknown, +) { + if (!projection.canRepair()) return + log.error('managed view visibility outcome is uncertain', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase, + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + ...(error ? { error: managedRuntimeErrorMessage(error) } : {}), + }) +} + +/** + * Keep managed-runtime close work bounded while still attaching a rejection + * handler to the original request. The server may commit a PATCH after the + * client-side wait expires, so the original request must remain observable + * through the reconciliation window instead of being aborted at the timeout. + * `onLateSettlement` is deliberately separate from the bounded promise: a + * timeout settles the close gate, while the original request remains observed + * until it resolves or rejects. + */ +function awaitBoundedManagedRuntimeRequest( + operation: string, + request: (signal: AbortSignal) => Promise, + hooks: ManagedRuntimeRequestHooks = {}, +): Promise { + const controller = new AbortController() + return new Promise((resolve, reject) => { + let settled = false + let timedOut = false + let lateSettled = false + let lateGraceTimer: ReturnType | undefined + const timer = setTimeout(() => { + timedOut = true + settled = true + lateGraceTimer = setTimeout(() => { + if (lateSettled) return + try { + hooks.onGraceExpired?.() + } catch (error) { + logManagedRuntimeLateSettlementFailure(`${operation} grace-expired handler`, error) + } + }, MANAGED_VIEW_LATE_SETTLEMENT_GRACE_MS) + try { + hooks.onTimeout?.() + } catch (error) { + logManagedRuntimeLateSettlementFailure(`${operation} timeout handler`, error) + } + reject(new ManagedRuntimeRequestTimeoutError(operation)) + }, MANAGED_VIEW_DETACH_TIMEOUT_MS) + + const handleLateSettlement = (outcome: ManagedRuntimeRequestOutcome) => { + if (!timedOut) return + lateSettled = true + if (lateGraceTimer) clearTimeout(lateGraceTimer) + if (!hooks.onLateSettlement) return + try { + void Promise.resolve(hooks.onLateSettlement(outcome)).catch((error) => { + logManagedRuntimeLateSettlementFailure(operation, error) + }) + } catch (error) { + logManagedRuntimeLateSettlementFailure(operation, error) + } + } + + const finish = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timer) + callback() + } + + Promise.resolve() + .then(() => request(controller.signal)) + .then( + (view) => { + if (timedOut) { + handleLateSettlement({ ok: true, value: view }) + return + } + finish(() => resolve(view)) + }, + (error) => { + if (timedOut) { + handleLateSettlement({ ok: false, error }) + return + } + finish(() => reject(error)) + }, + ) + }) +} + +function updateManagedViewVisibilityWithSignal( + viewId: string, + visibility: 'visible' | 'detached', + expectedRevision: number, + expectedSoulIntentRevision: number, + signal: AbortSignal, +): Promise { + return updateManagedRuntimeViewVisibility( + viewId, + visibility, + expectedRevision, + expectedSoulIntentRevision, + undefined, + { signal }, + ) +} + +function updateManagedViewRepairWithSignal( + projection: ManagedViewCloseProjection, + fence: ManagedViewRepairFence, + signal: AbortSignal, +): Promise { + // The bounded request schedules its callback in a microtask, so check at + // the actual send as well as after each asynchronous read/response. + if (!projection.canRepair()) throw new ManagedViewRepairSupersededError() + return updateManagedViewVisibilityWithSignal( + projection.viewId, 'visible', fence.viewRevision, fence.soulRevision, signal, + ) +} + +/** + * Freeze the managed-view fences carried by the panes before a close starts. + * The pane projection is the last-known view identity for that exact layout; + * reading the live inventory while a close is in flight could pair the close + * with a newer view revision or a different incarnation. + */ +function collectManagedViewProjections( + layout: PaneNode | undefined, +): FrozenManagedViewProjection[] { + const byViewId = new Map() + const visit = (node: PaneNode) => { + if (node.type === 'split') { + visit(node.children[0]) + visit(node.children[1]) + return + } + const content = node.content + if ( + (content.kind !== 'terminal' && content.kind !== 'fresh-agent') + || !content.viewIntentId + || typeof content.viewIntentRevision !== 'number' + || typeof content.soulIntentRevision !== 'number' + ) { + return + } + if (!byViewId.has(content.viewIntentId)) { + byViewId.set(content.viewIntentId, { + paneId: node.id, + createRequestId: content.createRequestId, + soulId: content.soulId, + viewId: content.viewIntentId, + viewRevision: content.viewIntentRevision, + soulRevision: content.soulIntentRevision, + }) + } + } + if (layout) visit(layout) + return [...byViewId.values()] +} + +type ManagedViewRepairFence = { + viewRevision: number + soulRevision: number +} + +const MANAGED_VIEW_AUTHORITATIVE_REPAIR_ATTEMPTS = 2 + +/** + * An authoritative read is a snapshot, so even a visible result needs an + * acknowledged visible PATCH to fence a queued detach. The caller owns the + * finite attempt budget; one reread handles a detach winning between read + * and PATCH without an unbounded retry loop. + */ +async function repairManagedViewFromAuthoritativeDetail( + projection: ManagedViewCloseProjection, + operation: 'detach' | 'rollback', + authoritative: Awaited>, + previousError: unknown, + attemptsRemaining: number, +): Promise { + if (!projection.canRepair()) return + const currentView = authoritative.viewIntents.find((view) => view.viewId === projection.viewId) + if (!currentView) { + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'read', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + reason: 'view_missing', + error: previousError ? managedRuntimeErrorMessage(previousError) : undefined, + }) + return + } + if (currentView.visibility !== 'visible' && currentView.visibility !== 'detached') { + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'read', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + reason: 'unexpected_visibility', + visibility: currentView.visibility, + error: previousError ? managedRuntimeErrorMessage(previousError) : undefined, + }) + return + } + + try { + // A visible read can precede the queued detach's commit. The acknowledged + // PATCH advances the view revision even when it is already visible, so + // that original detach can no longer commit using its old fence. + const repaired = await awaitBoundedManagedRuntimeRequest( + 'managed view authoritative visibility repair', + (signal) => updateManagedViewRepairWithSignal(projection, { + viewRevision: currentView.revision, + soulRevision: authoritative.soul.intentRevision, + }, signal), + { + onGraceExpired: () => { + logManagedRuntimeUncertainOutcome(projection, operation, 'authoritative_repair_grace_expired') + }, + onLateSettlement: (outcome) => { + return repairManagedViewAfterLateOutcome(projection, operation, outcome) + }, + }, + ) + if (!projection.canRepair()) return + if (repaired.visibility === 'visible') return + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'retry', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + reason: 'retry_returned_non_visible', + visibility: repaired.visibility, + error: previousError ? managedRuntimeErrorMessage(previousError) : undefined, + }) + } catch (error) { + if (error instanceof ManagedViewRepairSupersededError || !projection.canRepair()) return + if (attemptsRemaining > 0 && !isManagedRuntimeRequestTimeout(error)) { + // The detach may have won between GET and PATCH. Re-read once with a + // finite budget; persistent failure remains a diagnosed uncertainty. + await repairManagedViewAuthoritatively(projection, operation, error, attemptsRemaining) + return + } + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'retry', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + error: managedRuntimeErrorMessage(error), + }) + } +} + +async function repairManagedViewAuthoritatively( + projection: ManagedViewCloseProjection, + operation: 'detach' | 'rollback', + previousError?: unknown, + attemptsRemaining = MANAGED_VIEW_AUTHORITATIVE_REPAIR_ATTEMPTS, +): Promise { + if (!projection.canRepair()) return + if (!projection.soulId) { + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'read', + paneId: projection.paneId, + viewId: projection.viewId, + reason: 'missing_soul_id', + error: previousError ? managedRuntimeErrorMessage(previousError) : undefined, + }) + return + } + + try { + const authoritative = await awaitBoundedManagedRuntimeRequest( + 'managed runtime soul read', + (signal) => getManagedRuntimeSoul(projection.soulId!, { signal }), + { + onGraceExpired: () => { + logManagedRuntimeUncertainOutcome(projection, operation, 'authoritative_read_grace_expired', previousError) + }, + onLateSettlement: (outcome) => { + if (!projection.canRepair()) return + if (!outcome.ok) { + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'late_read', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + error: managedRuntimeErrorMessage(outcome.error), + }) + return + } + return repairManagedViewFromAuthoritativeDetail( + projection, + operation, + outcome.value, + previousError, + attemptsRemaining - 1, + ) + }, + }, + ) + await repairManagedViewFromAuthoritativeDetail(projection, operation, authoritative, previousError, attemptsRemaining - 1) + } catch (error) { + if (!projection.canRepair()) return + log.error('managed view authoritative repair failed', { + event: 'managed_view_visibility_uncertain_outcome', + operation, + phase: 'read', + paneId: projection.paneId, + soulId: projection.soulId, + viewId: projection.viewId, + error: managedRuntimeErrorMessage(error), + }) + } +} + +async function repairManagedViewAfterLateOutcome( + projection: ManagedViewCloseProjection, + operation: 'detach' | 'rollback', + outcome: ManagedRuntimeRequestOutcome, +): Promise { + if (!projection.canRepair()) return + if (!outcome.ok) { + await repairManagedViewAuthoritatively(projection, operation, outcome.error) + return + } + + try { + const repaired = await awaitBoundedManagedRuntimeRequest( + 'managed view late visibility repair', + (signal) => updateManagedViewRepairWithSignal(projection, { + viewRevision: outcome.value.revision, + soulRevision: outcome.value.soulIntentRevision, + }, signal), + { + onGraceExpired: () => { + logManagedRuntimeUncertainOutcome(projection, operation, 'late_repair_grace_expired') + }, + onLateSettlement: (lateOutcome) => { + return repairManagedViewAfterLateOutcome(projection, operation, lateOutcome) + }, + }, + ) + if (!projection.canRepair()) return + if (repaired.visibility === 'visible') return + await repairManagedViewAuthoritatively( + projection, + operation, + new Error(`late repair returned ${repaired.visibility}`), + ) + } catch (error) { + if (error instanceof ManagedViewRepairSupersededError || !projection.canRepair()) return + await repairManagedViewAuthoritatively(projection, operation, error) + } +} + +async function repairManagedViewAfterTimeout( + projection: ManagedViewCloseProjection, + fence: ManagedViewRepairFence, + operation: 'detach' | 'rollback', +): Promise { + if (!projection.canRepair()) return + let immediateError: unknown + try { + const repaired = await awaitBoundedManagedRuntimeRequest( + 'managed view visibility repair', + (signal) => updateManagedViewRepairWithSignal(projection, fence, signal), + { + onGraceExpired: () => { + logManagedRuntimeUncertainOutcome(projection, operation, 'repair_grace_expired') + }, + onLateSettlement: (outcome) => { + return repairManagedViewAfterLateOutcome(projection, operation, outcome) + }, + }, + ) + if (!projection.canRepair()) return + if (repaired.visibility === 'visible') return + immediateError = new Error(`repair returned ${repaired.visibility}`) + } catch (error) { + if (error instanceof ManagedViewRepairSupersededError || !projection.canRepair()) return + immediateError = error + } + + await repairManagedViewAuthoritatively(projection, operation, immediateError) +} + +function managedViewTimeoutHooks( + projection: ManagedViewCloseProjection, + fence: ManagedViewRepairFence, + operation: 'detach' | 'rollback', +): ManagedRuntimeRequestHooks { + let timeoutRepair = Promise.resolve() + return { + onTimeout: () => { + timeoutRepair = repairManagedViewAfterTimeout(projection, fence, operation) + }, + onGraceExpired: () => { + logManagedRuntimeUncertainOutcome(projection, operation, 'mutation_grace_expired') + }, + onLateSettlement: (outcome) => { + return timeoutRepair.then(() => repairManagedViewAfterLateOutcome(projection, operation, outcome)) + }, + } +} + +/** + * Confirm every frozen managed view detached or already closed as one close + * transaction. If a later view refuses the mutation, use each completed + * detach response's new fences to return its view to visible before the + * pane/tab can be removed. Every + * timeout also starts a bounded late-outcome repair while the original + * visibility mutation remains observable. Other failed responses reconcile + * the failing view too, because a rejected response does not prove refusal. + */ +async function detachManagedViews( + projections: FrozenManagedViewProjection[], + tabId: string, + getState: () => unknown, +): Promise { + const completedDetaches: Array<{ projection: ManagedViewCloseProjection; view: ManagedRuntimeViewIntent }> = [] + const owned: ManagedViewCloseProjection[] = [] + for (const frozenProjection of projections) { + const projection = ownManagedViewProjection(frozenProjection, tabId, getState) + owned.push(projection) + try { + const view = await awaitBoundedManagedRuntimeRequest( + 'managed view visibility mutation', + (signal) => updateManagedViewVisibilityWithSignal( + projection.viewId, + 'detached', + projection.viewRevision, + projection.soulRevision, + signal, + ), + managedViewTimeoutHooks(projection, { + viewRevision: projection.viewRevision, + soulRevision: projection.soulRevision, + }, 'detach'), + ) + // A verified stop may already have hidden this view before the + // browser saw its new fences. That successful no-op has no detach + // mutation to roll back if a later view refuses this close. + if (view.visibility === 'detached') completedDetaches.push({ projection, view }) + } catch (error) { + log.warn('managed view detach refused during close; rolling back earlier detaches', { + event: 'managed_view_visibility_detach_unconfirmed', + viewId: projection.viewId, + paneId: projection.paneId, + error: managedRuntimeErrorMessage(error), + }) + if (!isManagedRuntimeRequestTimeout(error)) { + // Transport and response-body failures can follow a committed PATCH + // just as a timeout can. Read current fences before compensating it. + await repairManagedViewAuthoritatively(projection, 'detach', error) + } + for (const completed of [...completedDetaches].reverse()) { + try { + await awaitBoundedManagedRuntimeRequest( + 'managed view rollback mutation', + (signal) => updateManagedViewRepairWithSignal(completed.projection, { + viewRevision: completed.view.revision, + soulRevision: completed.view.soulIntentRevision, + }, signal), + managedViewTimeoutHooks(completed.projection, { + viewRevision: completed.view.revision, + soulRevision: completed.view.soulIntentRevision, + }, 'rollback'), + ) + } catch (rollbackError) { + if (rollbackError instanceof ManagedViewRepairSupersededError || !completed.projection.canRepair()) continue + if (!isManagedRuntimeRequestTimeout(rollbackError)) { + log.error('managed view detach rollback failed after close refusal', { + event: 'managed_view_visibility_rollback_failed', + viewId: completed.view.viewId, + paneId: completed.projection.paneId, + error: managedRuntimeErrorMessage(rollbackError), + }) + await repairManagedViewAuthoritatively(completed.projection, 'rollback', rollbackError) + } + } + } + for (const view of owned) view.allowOlderRepairs() + return false + } + } + return true +} + +// A view-update failure must not be described as a close-evidence failure. +function surfaceManagedViewDetachFailure( + dispatch: (action: unknown) => void, + tabId: string, + panes: ReadonlyArray<{ paneId: string }>, +) { + for (const pane of panes) { + dispatch(setPaneCloseError({ + tabId, + paneId: pane.paneId, + error: 'The pane could not be closed, so it was left open. Try again.', + })) + } +} + /** * Delta-r7-round-3 (focused-episode-7 round 2, Finding F2) — the acknowledged * close gate. EVERY user- or system-initiated pane removal routes through one @@ -715,6 +1338,7 @@ export const closePaneWithCleanup = createAsyncThunk( } // F2: confirm the durable close evidence BEFORE the layout loses the pane. const identity = collectPaneCloseIdentities(before).filter((i) => i.paneId === paneId) + const managedViews = collectManagedViewProjections(before).filter((view) => view.paneId === paneId) if (identity.length > 0) { // Focused-episode-7 round 5 (Finding F2): freeze THIS pane's identity // while the acknowledgement is outstanding — the one shared guard @@ -724,7 +1348,9 @@ export const closePaneWithCleanup = createAsyncThunk( // mergePaneContent / restartFreshAgentCreate folds, hydrate re-keys) // so the ack always covers exactly the identity the removal drops. dispatch(markPaneClosing({ tabId, paneId })) - try { + } + try { + if (identity.length > 0) { const failed = await awaitPaneCloseEvidence(identity) if (failed.length > 0) { log.warn('pane close evidence was not confirmed; the pane stays', { tabId, paneId }) @@ -734,11 +1360,17 @@ export const closePaneWithCleanup = createAsyncThunk( reassertKeptPanesOpen((getState() as RootState).panes.layouts[tabId], tabId, identity) return } - } finally { - // The removal flows next (removals are never refused); the freeze - // lifts with the wait either way. - dispatch(clearPaneClosing({ tabId, paneId })) } + if (managedViews.length > 0 && !await detachManagedViews(managedViews, tabId, getState)) { + log.warn('managed view detach was not confirmed; the pane stays', { tabId, paneId }) + surfaceManagedViewDetachFailure(dispatch, tabId, identity.length > 0 ? identity : managedViews) + reassertKeptPanesOpen((getState() as RootState).panes.layouts[tabId], tabId, identity) + return + } + } finally { + // The removal flows next (removals are never refused); the freeze + // lifts with the evidence and managed-view transaction either way. + if (identity.length > 0) dispatch(clearPaneClosing({ tabId, paneId })) } dispatch(closePane({ tabId, paneId })) const after = (getState() as RootState).panes.layouts[tabId] @@ -820,6 +1452,7 @@ export const closeTab = createAsyncThunk( const frozenPaneTitles = stateAtClose.panes.paneTitles[tabId] const frozenPaneTitleSetByUser = stateAtClose.panes.paneTitleSetByUser?.[tabId] const identities = collectPaneCloseIdentities(frozenLayout) + const managedViews = collectManagedViewProjections(frozenLayout) dispatch(markTabClosing({ tabId })) try { if (identities.length > 0) { @@ -843,6 +1476,12 @@ export const closeTab = createAsyncThunk( markPaneCloseEvidenceConfirmed(identity.createRequestId) } } + if (managedViews.length > 0 && !await detachManagedViews(managedViews, tabId, getState)) { + log.warn('managed view detach was not confirmed; the tab stays', { tabId }) + surfaceManagedViewDetachFailure(dispatch, tabId, identities.length > 0 ? identities : managedViews) + reassertKeptPanesOpen((getState() as RootState).panes.layouts[tabId], tabId, identities) + return + } const tabRegistryState = (stateAtClose as { tabRegistry?: RootState['tabRegistry'] }).tabRegistry const serverInstanceId = stateAtClose.connection?.serverInstanceId || UNKNOWN_SERVER_INSTANCE_ID if (frozenTab && frozenLayout && tabRegistryState) { @@ -979,15 +1618,17 @@ export const replacePaneWithCleanup = createAsyncThunk( log.warn('refusing to start a pane replace while a close is already in flight for its tab', { tabId, paneId }) return } - const identity = collectPaneCloseIdentities( - (getState() as RootState).panes.layouts[tabId], - ).filter((i) => i.paneId === paneId) + const layout = (getState() as RootState).panes.layouts[tabId] + const identity = collectPaneCloseIdentities(layout).filter((i) => i.paneId === paneId) + const managedViews = collectManagedViewProjections(layout).filter((view) => view.paneId === paneId) if (identity.length > 0) { // Focused-episode-7 round 5 (Finding F2): freeze the discarded pane's // identity while the acknowledgement is outstanding (the same shared // guard as the single-pane close). dispatch(markPaneClosing({ tabId, paneId })) - try { + } + try { + if (identity.length > 0) { const failed = await awaitPaneCloseEvidence(identity) if (failed.length > 0) { log.warn('replace-pane close evidence was not confirmed; the pane keeps its content', { @@ -1000,13 +1641,17 @@ export const replacePaneWithCleanup = createAsyncThunk( reassertKeptPanesOpen((getState() as RootState).panes.layouts[tabId], tabId, identity) return } - // The gate's own follow-through: lift the freeze first — replacePane - // is itself a guarded (identity-changing) reducer and must not be - // refused by its owner's mark. Synchronous dispatch, no interleave. - dispatch(clearPaneClosing({ tabId, paneId })) - } finally { - dispatch(clearPaneClosing({ tabId, paneId })) // idempotent: failure/throw paths lift the freeze here } + if (managedViews.length > 0 && !await detachManagedViews(managedViews, tabId, getState)) { + log.warn('managed view detach was not confirmed; the pane keeps its content', { tabId, paneId }) + surfaceManagedViewDetachFailure(dispatch, tabId, identity.length > 0 ? identity : managedViews) + reassertKeptPanesOpen((getState() as RootState).panes.layouts[tabId], tabId, identity) + return + } + } finally { + // The gate's own follow-through: lift the freeze before replacePane — + // replacePane is itself a guarded identity-changing reducer. + if (identity.length > 0) dispatch(clearPaneClosing({ tabId, paneId })) } dispatch(replacePane({ tabId, paneId })) } diff --git a/test/e2e-browser/fixtures/fake-opencode.cjs b/test/e2e-browser/fixtures/fake-opencode.cjs index ff6e5f6f8..4ecc5e8b9 100755 --- a/test/e2e-browser/fixtures/fake-opencode.cjs +++ b/test/e2e-browser/fixtures/fake-opencode.cjs @@ -660,6 +660,7 @@ const sessionArg = argValue('--session') const sessionEventGatePath = process.env.FAKE_OPENCODE_SESSION_EVENT_GATE_PATH const holdSummarizeGatePath = process.env.FAKE_OPENCODE_HOLD_SUMMARIZE_GATE_PATH const requireDirectoryRoute = process.env.FAKE_OPENCODE_REQUIRE_DIRECTORY_ROUTE === '1' +const snapshotReadFailureMarkerPath = process.env.FAKE_OPENCODE_SNAPSHOT_READ_FAILURE_MARKER if (!Number.isInteger(port) || port <= 0 || port > 65535) { process.stdout.write('fake opencode: no server port requested\n') @@ -1239,6 +1240,17 @@ const server = http.createServer(async (req, res) => { return } + // Fail only this valid native snapshot request; the daemon, SSE, and saved + // conversation stay intact. Cargo tests can exercise unavailable reads + // without a process-death fault or a destructive host fallback. + if (req.method === 'GET' && (action === '' || action === 'message') + && snapshotReadFailureMarkerPath && fs.existsSync(snapshotReadFailureMarkerPath)) { + appendAudit({ event: 'snapshot_read_failed', sessionId, method: req.method, + pathname: url.pathname, routeDirectory: directory }) + req.socket.destroy() + return + } + if (action === '' && req.method === 'GET') { appendAudit({ event: 'session_get', diff --git a/test/e2e-browser/helpers/managed-runtime.ts b/test/e2e-browser/helpers/managed-runtime.ts index 761b34a8c..7807cc94a 100644 --- a/test/e2e-browser/helpers/managed-runtime.ts +++ b/test/e2e-browser/helpers/managed-runtime.ts @@ -160,6 +160,9 @@ export class ManagedRuntimeBrowserRig { preserveHomeOnStop: true, homeDir: this.webHomeDir, env: { + // Managed MCP calls back from Docker workloads, so this owned + // ephemeral-port server must override RustServer's loopback default. + FRESHELL_BIND_HOST: '0.0.0.0', FRESHELL_MANAGED_RUNTIME_V1: '1', ...(this.freshAgentModes.length > 0 ? { FRESHELL_MANAGED_FRESH_AGENT_V1: '1' } @@ -305,6 +308,21 @@ export class ManagedRuntimeBrowserRig { )) ?? null } + signalOwnedSessionHostExact(containerId: string, incarnationId: string, signal: 'SIGSTOP' | 'SIGCONT', expectedPid?: number): number { + const pid = this.runtime.ownedContainerHostPidExact(containerId) + if (expectedPid !== undefined && pid !== expectedPid) throw new Error('owned session-host PID changed') + const rows = this.runtime.topOwnedContainerExact(containerId, ['-eo', 'pid,args']).split('\n') + const row = rows.find((line) => Number(line.trim().split(/\s+/)[0]) === pid) + if (!row || !/(?:^|\s)\/[^ ]*freshell-session-host\s+serve\s+--control-socket\s/.test(row) + || !row.includes(`--incarnation-id ${incarnationId}`)) { + throw new Error('owned container PID is not the requested session host') + } + // The session host is namespace PID1. SIGSTOP from inside that namespace + // is ignored; signal only its receipt-proven PID from the ancestor namespace. + process.kill(pid, signal) + return pid + } + ownedContainerExec(containerId: string, args: string[]): string { return this.runtime.execOwnedContainerExact(containerId, args) } diff --git a/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts b/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts new file mode 100644 index 000000000..d6f11bcb9 --- /dev/null +++ b/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts @@ -0,0 +1,986 @@ +import nativeCodexHistory from '../../fixtures/managed-native-history/codex.json' with { type: 'json' } +import fs from 'node:fs/promises' +import path from 'node:path' +import type { Page } from '@playwright/test' +import type { ManagedRuntimeNotice, ManagedRuntimeRecoverySummary } from '@shared/managed-runtime.js' +import { FRESHCODEX_DEFAULT_MODEL } from '@shared/fresh-agent-models.js' +import { test, expect } from '../helpers/fixtures.js' +import { RawWsClient } from '../helpers/raw-clients.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness, selectShellFromPicker } from '../helpers/test-harness.js' + +type PaneKind = 'terminal' | 'fresh-agent' +type RecoveryState = ManagedRuntimeRecoverySummary['recoveryState'] + +const SESSION_ID = 'd4430000-0000-4444-8444-000000000091' +const SOUL_ID = 'contextual-recovery-soul' +const INTENT_REVISION = 19 +const CREATE_REQUEST_ID = 'contextual-recovery-create' +const SAVED_HISTORY_TEXT = 'Saved conversation before recovery' +const readiness = { + inventoryRevision: 100, + initialScanState: 'complete', + blockedSubsystems: [], + startupRecoveryConcurrencyLimit: 4, + startupRecoveryPeak: 0, +} + +function recoverySummary(recoveryState: RecoveryState): ManagedRuntimeRecoverySummary { + return { + desiredState: recoveryState === 'lost' ? 'stopped' : 'running', + recoveryState, + reason: 'provider_unavailable', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + } +} + +async function paneContent(page: Page) { + return page.evaluate(() => { + const state = window.__FRESHELL_TEST_HARNESS__!.getState() + const tabId = state.tabs.activeTabId! + const root = state.panes.layouts[tabId] + if (root?.type !== 'leaf') throw new Error('Expected the fixture to have one pane') + return root.content + }) +} + +async function installPane(page: Page, kind: PaneKind, recoveryState: RecoveryState, missingSoul = false, staleStatus?: 'running' | 'starting') { + await page.route('**/api/runtime/notices?**', (route) => route.fulfill({ json: { notices: [] } })) + if (!missingSoul) await page.route('**/api/fresh-agent/threads/**', (route) => route.fulfill({ json: { + sessionType: 'freshcodex', provider: 'codex', sessionId: SESSION_ID, threadId: SESSION_ID, + revision: 1, latestTurnId: null, status: 'idle', + capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: false }, + settings: { model: FRESHCODEX_DEFAULT_MODEL, effort: 'low' }, + tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, + pendingApprovals: [], pendingQuestions: [], + turns: [{ id: 'saved-turn', turnId: 'saved-turn', source: 'durable', role: 'assistant', summary: '', + items: [{ id: 'saved-text', kind: 'text', text: SAVED_HISTORY_TEXT }] }], + extensions: {}, + } })) + await page.route(`**/api/runtime/souls/${SOUL_ID}/history`, (route) => route.fulfill({ json: { + ...nativeCodexHistory, threadId: SESSION_ID, + turns: nativeCodexHistory.turns.map((turn) => ({ ...turn, items: turn.items.map((item) => ( + turn.role === 'assistant' && item.kind === 'text' ? { ...item, text: SAVED_HISTORY_TEXT } : item + )) })), + } })) + await page.evaluate(({ kind, summary, sessionId, soulId, revision, createRequestId, model, missingSoul, staleStatus }) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + const root = state.panes.layouts[tabId] + if (root?.type !== 'leaf' || root.content.kind !== 'terminal') { + throw new Error('Expected the fixture terminal') + } + // Fresh suppression records attempted sends without starting a sidecar. + // Terminal decision tests leave the real lifecycle effect enabled: its + // production managed-recovery guard must stop creates and attaches. + harness.setFreshAgentNetworkEffectsSuppressed(paneId, true) + harness.setTerminalNetworkEffectsSuppressed(paneId, summary.recoveryState === 'live') + if (kind === 'fresh-agent' && staleStatus) { + const locator = { sessionType: 'freshcodex', provider: 'codex', sessionId } + harness.dispatch({ type: 'freshAgent/sessionInit', payload: locator }) + harness.dispatch({ type: 'freshAgent/setSessionStatus', payload: { ...locator, status: staleStatus } }) + } + const managed = { + soulId: missingSoul ? undefined : soulId, soulIntentRevision: revision, incarnationId: 'contextual-incarnation', + viewIntentId: 'contextual-view', viewIntentRevision: 4, + resourceSummary: { configured: { cpuMilli: 1000, memoryBytes: 1024 ** 3, swapBytes: 0, pidsMax: 128 } }, + recoverySummary: summary, + } + const identity = { + sessionRef: { provider: 'codex', sessionId }, resumeSessionId: sessionId, createRequestId, + } + const content = kind === 'terminal' ? { + ...root.content, ...identity, ...managed, mode: 'codex', + status: summary.recoveryState === 'live' ? 'running' : 'error', + } : { + kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', + ...(summary.recoveryState === 'lost' && !staleStatus ? {} : { sessionId }), + ...identity, ...managed, status: staleStatus ?? (missingSoul ? 'error' : 'idle'), model, effort: 'low', + ...(missingSoul ? { closeError: 'Previous close was not confirmed' } : {}), + initialCwd: '/tmp', settingsDismissed: true, + } + harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content } }) + harness.clearSentWsMessages?.() + }, { + kind, summary: recoverySummary(recoveryState), sessionId: SESSION_ID, soulId: SOUL_ID, + revision: INTENT_REVISION, createRequestId: CREATE_REQUEST_ID, model: FRESHCODEX_DEFAULT_MODEL, missingSoul, staleStatus, + }) +} + +test('fresh-agent: a close started during verified start-new cleanup preserves the displayed conversation when close fails', async ({ page, serverInfo, harness, terminal }) => { + let targetTabId: string | undefined + let closeRequestId: string | undefined + let closeStarted = false + let failClose = () => {} + let releaseStop!: () => void + const heldStop = new Promise((resolve) => { releaseStop = resolve }) + let finishStop!: () => void + const stopFinished = new Promise((resolve) => { finishStop = resolve }) + let stopRequests = 0 + let historyReads = 0 + const draft = 'Retained draft during refused Start new' + const draftKey = `fresh-agent-draft:freshcodex:${CREATE_REQUEST_ID}` + // A controlled refusal executes the real tab-close thunk without writing + // a successful close record on the server for the still-displayed fixture. + await page.routeWebSocket('**/ws', (socket) => { + const upstream = socket.connectToServer() + socket.onMessage((data) => { + const message = JSON.parse(String(data)) as { type?: string; tabId?: string; requestId?: string } + if (message.type === 'panes.closed' && message.tabId === targetTabId) { + closeStarted = true + closeRequestId = message.requestId + return + } + upstream.send(data) + }) + upstream.onMessage((data) => socket.send(data)) + failClose = () => { + if (!closeRequestId) return + socket.send(JSON.stringify({ type: 'panes.closed.result', requestId: closeRequestId, success: false })) + closeRequestId = undefined + } + }) + page.on('request', (request) => { + if (request.url().includes(`/api/runtime/souls/${SOUL_ID}/history`)) historyReads += 1 + }) + await page.route(`**/api/runtime/souls/${SOUL_ID}/stop`, async (route) => { + stopRequests += 1 + try { + expect(route.request().postDataJSON()).toEqual({ expectedIntentRevision: INTENT_REVISION, requestId: expect.any(String) }) + await heldStop + await route.fulfill({ json: { outcome: 'verified_empty', soul: { soulId: SOUL_ID, intentRevision: INTENT_REVISION } } }) + } finally { + finishStop() + } + }) + try { + await page.goto(`${serverInfo.baseUrl}/?token=${serverInfo.token}&e2e=1`, { timeout: 60_000 }) + await harness.waitForHarness(60_000) + await harness.waitForConnection() + await selectShellFromPicker(page) + await terminal.waitForTerminal() + targetTabId = (await harness.getState()).tabs.activeTabId! + await page.locator('[data-context="tab-add"]').click() + await harness.waitForTabCount(2) + const originalTab = page.locator('[data-context="tab"]').first() + await originalTab.click() + await page.evaluate(({ key, text }) => sessionStorage.setItem(key, text), { key: draftKey, text: draft }) + await installPane(page, 'fresh-agent', 'lost') + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + const composer = page.getByRole('textbox', { name: 'Chat message input' }) + await expect(composer).toHaveValue(draft) + const before = await paneContent(page) + const settledReads = historyReads + expect(settledReads).toBe(1) + const card = page.getByTestId('managed-runtime-recovery-card') + await card.getByRole('button', { name: 'Start new conversation', exact: true }).click() + await expect.poll(() => stopRequests).toBe(1) + await expect(card.getByRole('button', { name: 'Starting…', exact: true })).toBeDisabled() + await originalTab.getByRole('button', { name: /close/i }).click() + await expect.poll(() => closeRequestId).toBeTruthy() + await expect.poll(async () => (await harness.getState()).panes.closingTabs?.[targetTabId!]).toBe(true) + releaseStop() + await stopFinished + await expect(card.getByRole('button', { name: 'Start new conversation', exact: true })).toBeEnabled() + const assertRetained = async (closeError: string | undefined) => { + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + await expect(composer).toHaveValue(draft) + expect(await page.evaluate((key) => sessionStorage.getItem(key), draftKey)).toBe(draft) + expect(await paneContent(page)).toMatchObject({ ...before, closeError }) + expect(historyReads).toBe(settledReads) + const messages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(messages.filter((message) => ['freshAgent.create', 'freshAgent.attach', 'freshAgent.send', 'pane.reconcile.request'].includes(message.type ?? ''))).toEqual([]) + } + // Prove retention BEFORE the close result; no refresh can rescue a clear. + await assertRetained(undefined) + expect(closeRequestId).toBeTruthy() + failClose() + await expect.poll(async () => (await harness.getState()).panes.closingTabs?.[targetTabId!]).toBeUndefined() + await expect(page.getByText(/Close failed:/)).toBeVisible() + await harness.waitForTabCount(2) + await assertRetained('the pane close could not be recorded durably; the pane was left open') + expect(stopRequests).toBe(1) + } finally { + releaseStop() + if (stopRequests) await stopFinished + failClose() + try { + if (closeStarted) await expect.poll(async () => (await harness.getState()).panes.closingTabs?.[targetTabId!]).toBeUndefined() + } finally { + await harness.killAllTerminals(serverInfo) + } + } +}) + +for (const recoveryState of ['blocked', 'lost'] as const) { + for (const status of ['running', 'starting'] as const) { + test(`fresh-agent: ${recoveryState} stale ${status} reads history once while awaiting intervention`, async ({ freshellPage, page, terminal, harness }) => { + await terminal.waitForTerminal() + let historyReads = 0 + page.on('request', (request) => { + if (request.url().includes(`/api/runtime/souls/${SOUL_ID}/history`)) historyReads += 1 + }) + await installPane(page, 'fresh-agent', recoveryState, false, status) + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + expect(historyReads).toBe(1) + const before = await paneContent(page) + await page.waitForTimeout(6_500) + expect(historyReads).toBe(1) + expect(await paneContent(page)).toEqual(before) + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeVisible() + const messages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(messages.filter((message) => ['freshAgent.create', 'freshAgent.attach', 'pane.reconcile.request'].includes(message.type ?? ''))).toEqual([]) + }) + } +} + +test('background notices failure stays quiet and preserves an existing cleanup warning', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + let fail = true + let failures = 0 + await page.route('**/api/runtime/notices?**', (route) => { + if (fail) { + failures += 1 + return route.fulfill({ status: 400, json: { message: 'Background notices refused' } }) + } + return route.fulfill({ json: { notices: [{ noticeId: 'cleanup-failed', kind: 'cleanup_failed', + message: 'Cleanup still needs a decision.', reference: 'FAIL0001', incidentIds: ['incident-one'], + deliveryState: 'pending', createdAt: '2026-10-02T00:00:00.000Z' }] } }) + }) + await page.route('**/api/runtime/notices/*/receipt', (route) => route.fulfill({ json: { ok: true } })) + await page.route('**/api/runtime/incidents/incident-one/summary', (route) => route.fulfill({ json: { + incidentId: 'incident-one', correlationId: 'correlation-one', soulId: SOUL_ID, provider: 'codex', + state: 'cleanup_failed', reasonCode: 'cleanup_unconfirmed', observedCause: 'Saved actionable cleanup cause.', + cleanup: { ownedHandleRef: 'registry://contextual-incarnation', ownershipVerified: false, gracefulAttempt: 'not_attempted', forcedAttempt: 'not_attempted', verifiedEmpty: false, foreignObjectsTouched: 0 }, + createdAt: '2026-10-02T00:00:00.000Z', updatedAt: '2026-10-02T00:00:01.000Z', + } })) + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.dispatch({ type: 'managedRuntime/setManagedRuntimeAvailable', payload: true })) + await expect.poll(() => failures).toBeGreaterThan(0) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + const popup = page.getByRole('alert', { name: 'Managed runtime notice' }) + await expect(popup).toBeHidden() + fail = false + await expect(popup).toBeVisible() + await popup.getByRole('button', { name: 'Details', exact: true }).click() + await expect(popup).toContainText('Saved actionable cleanup cause.') + fail = true + const priorFailures = failures + await expect.poll(() => failures).toBeGreaterThan(priorFailures) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await expect(popup).toContainText('Cleanup still needs a decision.') + await expect(popup).toContainText('Saved actionable cleanup cause.') + await expect(popup).not.toContainText('Background notices refused') + await expect(popup.getByRole('button', { name: 'Details', exact: true })).toBeVisible() + await expect(popup.getByRole('button', { name: 'Dismiss', exact: true })).toBeVisible() +}) + +test('fresh-agent: cold lost pane without a soul retains saved history and a close warning after refused start-new', async ({ freshellPage, page, terminal, harness, serverInfo }) => { + await terminal.waitForTerminal() + const sessions = path.join(serverInfo.homeDir, '.codex', 'sessions', '2026', '03', '01') + await fs.mkdir(sessions, { recursive: true }) + const events = await fs.readFile('test/fixtures/coding-cli/codex/task-events.sanitized.jsonl', 'utf8') + const tools = await fs.readFile('test/fixtures/managed-native-history/codex-tools.jsonl', 'utf8') + const arrayAnswer = 'Latest saved array answer FRESHELL_NATIVE_HISTORY_OMITTED_SHA256:body' + const recentParts = (kind: string, tail: string) => [ + ...Array.from({ length: 24 }, (_, index) => ({ type: kind, text: `Earlier array part ${index} ${'saved '.repeat(32_000)}` })), + { type: kind, text: tail }, + ] + const transcript = events.replace('session-activity', SESSION_ID) + .replace('Sanitized completion', SAVED_HISTORY_TEXT) + tools.split('\n').slice(1).join('\n') + + '\n' + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', call_id: 'large-history-tool', name: 'exec_command', arguments: '{"cmd":"pwd"}' } }) + + '\n' + JSON.stringify({ type: 'response_item', payload: { type: 'function_call_output', call_id: 'large-history-tool', output: 'Saved large tool output '.repeat(800_000) } }) + + '\n' + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', call_id: 'array-history-tool', name: 'echo', arguments: '{}' } }) + + '\n' + JSON.stringify({ type: 'response_item', payload: { type: 'function_call_output', call_id: 'array-history-tool', output: recentParts('input_text', 'Latest saved array tool output') } }) + + '\n' + JSON.stringify({ type: 'response_item', payload: { type: 'message', id: 'array-history-answer', role: 'assistant', content: recentParts('output_text', arrayAnswer) } }) + const rollout = path.join(sessions, `rollout-${SESSION_ID}.jsonl`) + await fs.writeFile(rollout, transcript) + expect(Buffer.byteLength(transcript)).toBeGreaterThan(16 * 1024 * 1024) + const modified = (await fs.stat(rollout)).mtimeMs + let stopRequests = 0 + await page.route('**/api/runtime/souls/*/stop', async (route) => { + stopRequests += 1 + await route.fulfill({ status: 404, json: { message: 'No managed soul' } }) + }) + const historyRead = page.waitForResponse('**/api/fresh-agent/threads/**') + await installPane(page, 'fresh-agent', 'lost', true) + const response = await historyRead + expect(response.request().method()).toBe('GET') + expect(response.status()).toBe(200) + const snapshot = await response.json() + await expect(page.getByText('Sanitized prompt', { exact: true })).toBeVisible() + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + await expect(page.getByText(arrayAnswer, { exact: false })).toBeVisible() + expect(JSON.stringify(snapshot.turns)).toContain('Latest saved array tool output') + expect(snapshot.extensions.codex.nativeHistoryAvailable).toBe(true) + expect(snapshot.extensions.codex.nativeHistoryRetention.partial).toBe(true) + expect(snapshot.turns.flatMap((turn: { items: Array<{ id: string }> }) => turn.items).some((item: { id: string }) => item.id === 'large-history-tool')).toBe(true) + await expect(page.getByRole('note', { name: 'Retained conversation history' })).toContainText('The saved conversation has not been changed.') + const closeWarning = page.getByText('Close failed: Previous close was not confirmed', { exact: true }) + await expect(closeWarning).toBeVisible() + const before = await paneContent(page) + expect(before).toMatchObject({ status: 'error', createRequestId: CREATE_REQUEST_ID, + sessionRef: { provider: 'codex', sessionId: SESSION_ID } }) + expect(before.soulId).toBeUndefined() + await expect(page.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + const card = page.getByTestId('managed-runtime-recovery-card') + await card.getByRole('button', { name: 'Start new conversation', exact: true }).click() + await expect(card.getByRole('status')).toContainText('Your conversation has been kept') + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + await expect(closeWarning).toBeVisible() + await expect(page.getByText(arrayAnswer, { exact: false })).toBeVisible() + expect(await paneContent(page)).toEqual(before) + expect(stopRequests).toBe(0) + expect(await fs.readFile(rollout, 'utf8')).toBe(transcript) + expect((await fs.stat(rollout)).mtimeMs).toBe(modified) + const messages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(messages.filter((message) => ['freshAgent.kill', 'freshAgent.create', 'freshAgent.attach', 'pane.reconcile.request'].includes(message.type ?? ''))).toEqual([]) +}) + +async function changeRecoveryState(page: Page, recoveryState: RecoveryState) { + await page.evaluate((summary) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const root = state.panes.layouts[tabId] + if (root?.type !== 'leaf') throw new Error('Expected one pane') + harness.dispatch({ type: 'panes/updatePaneContent', payload: { + tabId, paneId: root.id, content: { ...root.content, recoverySummary: summary }, + } }) + }, recoverySummary(recoveryState)) +} + +for (const savedIdentity of [false, true]) { + for (const status of ['running', 'creating'] as const) { + test(`terminal: real rejected attach during automatic recovery retains identity (saved reference ${savedIdentity}, stale ${status})`, async ({ freshellPage, page, terminal, harness }) => { + await terminal.waitForTerminal() + const original = await paneContent(page) + if (original.kind !== 'terminal' || !original.terminalId) throw new Error('Expected a live fixture terminal') + const invalidTerminalId = `missing-automatic-${original.terminalId}` + await page.evaluate(({ invalidTerminalId, savedIdentity, status, summary, sessionId, soulId }) => { + const rig = window.__FRESHELL_TEST_HARNESS__! + const state = rig.getState() + const tabId = state.tabs.activeTabId! + const root = state.panes.layouts[tabId] + if (root.type !== 'leaf' || root.content.kind !== 'terminal') throw new Error('Expected a terminal') + rig.setTerminalNetworkEffectsSuppressed(root.id, false) + rig.clearSentWsMessages?.() + rig.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId: root.id, content: { + ...root.content, terminalId: invalidTerminalId, mode: 'codex', status, + soulId, soulIntentRevision: 19, recoverySummary: summary, + sessionRef: savedIdentity ? { provider: 'codex', sessionId } : undefined, + resumeSessionId: savedIdentity ? sessionId : undefined, + } } }) + }, { invalidTerminalId, savedIdentity, status, summary: recoverySummary('recovering'), sessionId: SESSION_ID, soulId: SOUL_ID }) + await expect.poll(async () => (await harness.getReceivedWsMessages() as Array<{ code?: string; terminalId?: string }>).some( + (frame) => frame.code === 'INVALID_TERMINAL_ID' && frame.terminalId === invalidTerminalId, + )).toBe(true) + const sent = await harness.getSentWsMessages() as Array<{ type?: string; terminalId?: string; attachRequestId?: string }> + const rejected = sent.find((frame) => frame.type === 'terminal.attach' && frame.terminalId === invalidTerminalId) + expect(rejected?.attachRequestId).toEqual(expect.any(String)) + const received = await harness.getReceivedWsMessages() as Array<{ code?: string; requestId?: string; terminalId?: string }> + expect(received).toContainEqual(expect.objectContaining({ code: 'INVALID_TERMINAL_ID', terminalId: invalidTerminalId, + requestId: rejected!.attachRequestId })) + expect(sent.filter((frame) => frame.type === 'terminal.create')).toEqual([]) + expect(await paneContent(page)).toMatchObject({ terminalId: invalidTerminalId, + createRequestId: original.createRequestId, soulId: SOUL_ID }) + expect((await paneContent(page)).sessionRef).toEqual(savedIdentity ? { provider: 'codex', sessionId: SESSION_ID } : undefined) + expect(await terminal.getVisibleText()).not.toMatch(/Reconnecting|Starting a new terminal/) + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeHidden() + await expect(page.getByText('Starting terminal...', { exact: true })).toBeHidden() + // The managed replacement frame rebinds to an owned live terminal, whose attach/stream is real. + await harness.receiveWsMessage({ type: 'terminal.replaced', oldTerminalId: invalidTerminalId, + newTerminalId: original.terminalId, exitCode: 137, attempt: 1, maxAttempts: 3 }) + await changeRecoveryState(page, 'live') + await expect.poll(() => paneContent(page)).toMatchObject({ terminalId: original.terminalId, + createRequestId: original.createRequestId, soulId: SOUL_ID }) + let replacementAttach: { attachRequestId?: string } | undefined + await expect.poll(async () => { + replacementAttach = (await harness.getSentWsMessages() as Array<{ type?: string; terminalId?: string; attachRequestId?: string }>).find( + (frame) => frame.type === 'terminal.attach' && frame.terminalId === original.terminalId, + ) + return replacementAttach?.attachRequestId + }).toEqual(expect.any(String)) + await expect.poll(async () => (await harness.getReceivedWsMessages() as Array<{ type?: string; terminalId?: string; attachRequestId?: string }>).some( + (frame) => frame.type === 'terminal.attach.ready' && frame.terminalId === original.terminalId + && frame.attachRequestId === replacementAttach!.attachRequestId, + )).toBe(true) + await terminal.executeCommandInserted("printf 'AUTOMATIC_%s\\n' 'RECOVERY_RETAINED_OUTPUT'") + await terminal.waitForOutput('AUTOMATIC_RECOVERY_RETAINED_OUTPUT', { terminalId: original.terminalId }) + expect((await paneContent(page)).sessionRef).toEqual(savedIdentity ? { provider: 'codex', sessionId: SESSION_ID } : undefined) + expect(await terminal.getVisibleText(original.terminalId)).not.toMatch(/Reconnecting|Starting a new terminal/) + }) + } +} + +test('fresh-agent: automatic recovery reads actual saved Codex history without changing the conversation', async ({ freshellPage, page, terminal, harness, serverInfo }) => { + await terminal.waitForTerminal() + const sessions = path.join(serverInfo.homeDir, '.codex', 'sessions', '2026', '03', '01') + await fs.mkdir(sessions, { recursive: true }) + const events = await fs.readFile('test/fixtures/coding-cli/codex/task-events.sanitized.jsonl', 'utf8') + const transcript = events.replace('session-activity', SESSION_ID).replace('Sanitized completion', SAVED_HISTORY_TEXT) + const rollout = path.join(sessions, `rollout-${SESSION_ID}.jsonl`) + await fs.writeFile(rollout, transcript) + const historyRead = page.waitForResponse('**/api/fresh-agent/threads/**') + await installPane(page, 'fresh-agent', 'recovering', true) + const response = await historyRead + expect(response.request().method()).toBe('GET') + expect(response.status()).toBe(200) + expect((await response.json()).extensions.codex.nativeHistoryAvailable).toBe(true) + const workArea = page.getByTestId('terminal-work-area') + const userBody = workArea.getByLabel('You transcript turn', { exact: true }) + .getByText('Sanitized prompt', { exact: true }) + const assistantBody = workArea.getByLabel('Freshcodex transcript turn', { exact: true }) + .getByText(SAVED_HISTORY_TEXT, { exact: true }) + await expect(userBody).toBeVisible() + await expect(assistantBody).toBeVisible() + // Session naming hydrates independently of history. Deliver its automatic + // projection explicitly so the real header duplicate is deterministic. + await page.evaluate((sessionId) => { + const ref = { kind: 'session', provider: 'codex', sessionId } + window.__FRESHELL_TEST_HARNESS__!.dispatch({ type: 'sessionNames/receiveSessionNameProjections', payload: [{ + ref, record: { ref, name: 'Sanitized prompt', source: 'first_message', revision: 1 }, + }] }) + }, SESSION_ID) + await expect(workArea.getByTitle('Sanitized prompt', { exact: true })).toBeVisible() + await expect(userBody).toBeVisible() + await expect(assistantBody).toBeVisible() + const content = await paneContent(page) + expect(content).toMatchObject({ sessionId: SESSION_ID, sessionRef: { provider: 'codex', sessionId: SESSION_ID }, + resumeSessionId: SESSION_ID, createRequestId: CREATE_REQUEST_ID, recoverySummary: { recoveryState: 'recovering' } }) + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeHidden() + await expect(page.getByText('Restoring session...', { exact: true })).toBeHidden() + await expect(page.getByText('Close failed: Previous close was not confirmed', { exact: true })).toBeVisible() + const messages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(messages.filter((frame) => ['freshAgent.create', 'freshAgent.attach', 'pane.reconcile.request'].includes(frame.type ?? ''))).toEqual([]) + expect(await fs.readFile(rollout, 'utf8')).toBe(transcript) +}) + +test('legacy provider fixture: injected managed projection preserves the conversation while real cold resume awaits its snapshot', async ({ page }) => { + test.setTimeout(120_000) + let rollout = '' + let operations = '' + let transcript = '' + const fixtureEnv: Record = { + CODEX_CMD: `${process.execPath} ${path.resolve('test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs')}`, + } + const server = new RustServer({ + env: fixtureEnv, + setupHome: async (homeDir) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile(path.join(freshellDir, 'config.json'), JSON.stringify({ version: 1, + settings: { codingCli: { enabledProviders: ['codex'] }, freshAgent: { enabled: true } } })) + const sessions = path.join(homeDir, '.codex', 'sessions', '2026', '03', '01') + await fs.mkdir(sessions, { recursive: true }) + rollout = path.join(sessions, `rollout-${SESSION_ID}.jsonl`) + transcript = (await fs.readFile('test/fixtures/coding-cli/codex/task-events.sanitized.jsonl', 'utf8')) + .replace('session-activity', SESSION_ID).replace('Sanitized completion', 'Fixture turn') + await fs.writeFile(rollout, transcript) + operations = path.join(homeDir, 'codex-operations.jsonl') + // This is the existing provider protocol fixture, reached through the real Rust backend. + fixtureEnv.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ threadStartThreadId: SESSION_ID, + appendThreadOperationLogPath: operations }) + }, + }) + let client: RawWsClient | undefined + try { + const info = await server.start() + client = await RawWsClient.connect(info.wsUrl) + client.hello(info.token) + await client.nextJsonMessage('ready', 10_000) + client.sendJson({ type: 'freshAgent.create', requestId: CREATE_REQUEST_ID, + sessionType: 'freshcodex', provider: 'codex', cwd: info.homeDir }) + const created = await client.nextJsonMessage<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>('freshAgent.created', 20_000) + const sent: Array> = [] + const received: Array> = [] + const held: Array = [] + let hold = false + let release = () => {} + await page.routeWebSocket('**/ws', (socket) => { + const upstream = socket.connectToServer() + socket.onMessage((data) => { + sent.push(JSON.parse(String(data))) + upstream.send(data) + }) + upstream.onMessage((data) => { + const frame = JSON.parse(String(data)) + received.push(frame) + if (hold && frame.type === 'freshAgent.event' && frame.sessionId === created.sessionId) held.push(data) + else socket.send(data) + }) + release = () => { hold = false; for (const data of held.splice(0)) socket.send(data) } + }) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await selectShellFromPicker(page) + await page.evaluate(({ sessionId, sessionRef, requestId, soulId, summary, cwd }) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content: { + kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId, + sessionRef, resumeSessionId: sessionRef?.sessionId ?? sessionId, createRequestId: requestId, + soulId, soulIntentRevision: 7, recoverySummary: summary, initialCwd: cwd, status: 'idle', settingsDismissed: true, + } } }) + }, { sessionId: created.sessionId, sessionRef: created.sessionRef ?? { provider: 'codex', sessionId: SESSION_ID }, + requestId: CREATE_REQUEST_ID, soulId: SOUL_ID, summary: recoverySummary('live'), cwd: info.homeDir }) + await expect(page.getByText('Fixture turn', { exact: true })).toBeVisible() + const composer = page.getByRole('textbox', { name: 'Chat message input' }) + await expect(composer).toBeEnabled() + await composer.fill('Draft retained while reattaching') + hold = true + await changeRecoveryState(page, 'recovering') + const previousAttach = sent.findLast((frame) => frame.type === 'freshAgent.attach' && frame.sessionId === created.sessionId) + expect(previousAttach).toBeDefined() + // Stop only this fixture's provider using the supported conversation-preserving stop. + // Its next attach must actually resume, so it produces new attachment truth. + client.sendJson({ type: 'freshAgent.recovery.stop', requestId: 'contextual-test-provider-stop', + sessionId: created.sessionId, sessionType: 'freshcodex', provider: 'codex', + observedEpoch: previousAttach!.observedEpoch, observedGeneration: previousAttach!.observedGeneration }) + const stopped = await client.nextJsonMessage<{ requestId: string; success: boolean }>('freshAgent.recovery.stopped', 20_000) + expect(stopped).toMatchObject({ requestId: 'contextual-test-provider-stop', success: true }) + await expect.poll(async () => (await harness.getReceivedWsMessages() as Array<{ type?: string; requestId?: string }>).some( + (frame) => frame.type === 'freshAgent.recovery.stopped' && frame.requestId === 'contextual-test-provider-stop', + )).toBe(true) + await expect.poll(() => page.evaluate((sessionId) => { + const owners = Object.values(window.__FRESHELL_TEST_HARNESS__!.getState().freshAgent.runtimeOwners) as Array<{ sessionId: string; ownerKind: string }> + return owners.find((owner) => owner.sessionId === sessionId)?.ownerKind + }, created.sessionId)).toBe('vacant') + // The explicit stop clears the client's entry; seed the retained entry + // that a supervisor loss leaves behind before delivering stale loss evidence. + await page.evaluate((sessionId) => window.__FRESHELL_TEST_HARNESS__!.dispatch({ type: 'freshAgent/sessionInit', + payload: { sessionId, sessionType: 'freshcodex', provider: 'codex' } }), created.sessionId) + await harness.receiveWsMessage({ type: 'freshAgent.event', sessionId: created.sessionId, + sessionType: 'freshcodex', provider: 'codex', event: { type: 'freshAgent.exit', code: 137 } }) + await harness.receiveWsMessage({ type: 'freshAgent.event', sessionId: created.sessionId, + sessionType: 'freshcodex', provider: 'codex', event: { type: 'freshAgent.error', code: 'INVALID_SESSION_ID' } }) + const recovering = await paneContent(page) + const firstSend = sent.length + await changeRecoveryState(page, 'live') + await expect.poll(() => sent.slice(firstSend).filter((frame) => frame.type === 'freshAgent.attach').length).toBe(1) + await expect.poll(() => ({ + truth: held.map((data) => JSON.parse(String(data))).some((frame) => ( + frame.type === 'freshAgent.event' && frame.sessionId === created.sessionId && frame.event?.type === 'freshAgent.session.snapshot' + )), + sent: sent.slice(firstSend), + received: received.slice(-6), + })).toMatchObject({ truth: true }) + // The received truth frame is held at the actual socket boundary, not injected or fabricated. + const attachments = sent.slice(firstSend).filter((frame) => frame.type === 'freshAgent.attach') + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ sessionId: created.sessionId, + sessionRef: recovering.sessionRef, sessionType: 'freshcodex', provider: 'codex' }) + expect(sent.slice(firstSend).filter((frame) => ['freshAgent.create', 'pane.reconcile.request'].includes(frame.type))).toEqual([]) + const beforeTruth = await paneContent(page) + expect(beforeTruth).toEqual({ ...recovering, recoverySummary: recoverySummary('live') }) + await expect(composer).toBeDisabled() + await expect(composer).toHaveValue('Draft retained while reattaching') + await expect(page.getByText('Fixture turn', { exact: true })).toBeVisible() + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeHidden() + await expect(page.getByRole('button', { name: 'Resume session', exact: true })).toBeHidden() + await expect(page.getByRole('button', { name: 'Start new session', exact: true })).toBeHidden() + const lost = () => page.evaluate((sessionId) => { + const sessions = Object.values(window.__FRESHELL_TEST_HARNESS__!.getState().freshAgent.sessions) as Array<{ sessionId: string; lost?: boolean }> + return sessions.find((session) => session.sessionId === sessionId)?.lost + }, created.sessionId) + expect(await lost()).toBe(true) + release() + await expect.poll(lost).toBe(false) + await expect(composer).toBeEnabled() + await expect(composer).toHaveValue('Draft retained while reattaching') + await expect(page.getByText('Fixture turn', { exact: true })).toBeVisible() + expect(await paneContent(page)).toMatchObject({ sessionId: created.sessionId, createRequestId: CREATE_REQUEST_ID, + sessionRef: recovering.sessionRef, resumeSessionId: recovering.resumeSessionId, soulId: SOUL_ID }) + expect(sent.slice(firstSend).filter((frame) => ['freshAgent.create', 'pane.reconcile.request'].includes(frame.type))).toEqual([]) + const providerOperations = (await fs.readFile(operations, 'utf8')).trim().split('\n').map((row) => JSON.parse(row)) + expect(providerOperations.filter((operation) => operation.method === 'thread/start')).toHaveLength(1) + const resumed = providerOperations.filter((operation) => operation.method === 'thread/resume') + expect(resumed).toHaveLength(1) + expect(resumed[0].params.threadId).toBe(recovering.resumeSessionId) + expect(await fs.readFile(rollout, 'utf8')).toBe(transcript) + } finally { + await client?.dispose() + await server.stop() + } +}) + +test('managed terminal status and replacement events keep automatic recovery invisible', async ({ freshellPage, page, terminal, harness, serverInfo }) => { + await terminal.waitForTerminal() + const client = await RawWsClient.connect(serverInfo.wsUrl) + let replacementId: string + try { + client.hello(serverInfo.token) + await client.nextJsonMessage('ready', 10_000) + client.sendJson({ type: 'terminal.create', requestId: 'contextual-replacement-shell', mode: 'shell', shell: 'system' }) + replacementId = (await client.nextJsonMessage<{ terminalId: string }>('terminal.created', 10_000)).terminalId + } finally { await client.dispose() } + await installPane(page, 'terminal', 'live') + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + harness.setTerminalNetworkEffectsSuppressed(paneId, false) + harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content: { ...state.panes.layouts[tabId].content } } }) + }) + await expect.poll(async () => (await harness.getSentWsMessages() as Array<{ type?: string }>).some((frame) => frame.type === 'terminal.attach')).toBe(true) + const before = await paneContent(page) + if (before.kind !== 'terminal' || !before.terminalId) throw new Error('Expected the retained terminal identity') + await harness.receiveWsMessage({ type: 'terminal.status', terminalId: before.terminalId, status: 'recovering', + attempt: 2, maxAttempts: 3, exitCode: 137 }) + await expect(page.getByText(/auto-resuming/)).toBeHidden() + await changeRecoveryState(page, 'recovering') + await harness.receiveWsMessage({ type: 'terminal.replaced', oldTerminalId: before.terminalId, + newTerminalId: replacementId, exitCode: 137, attempt: 2, maxAttempts: 3 }) + await expect.poll(() => paneContent(page)).toMatchObject({ terminalId: replacementId, + crashTrace: { exitCode: 137 }, sessionRef: before.sessionRef, soulId: before.soulId, + createRequestId: before.createRequestId }) + await expect(page.getByTestId('terminal-xterm-container')).toBeVisible() + await expect(page.getByTestId('crash-trace')).toBeHidden() + await expect(page.getByText(/auto-resumed|auto-resuming|Recovering terminal output/)).toBeHidden() + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeHidden() + await changeRecoveryState(page, 'blocked') + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeVisible() + await expect(page.getByRole('button', { name: 'Retry recovery', exact: true })).toBeVisible() +}) + +for (const kind of ['terminal', 'fresh-agent'] as const) { + test(`${kind}: healthy and recovering managed panes leave routine recovery chrome hidden`, async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + await installPane(page, kind, 'live') + if (kind === 'terminal') { + await expect(page.getByTestId('terminal-xterm-container')).toBeVisible() + } else { + await expect(page.getByRole('textbox', { name: 'Chat message input' })).toBeVisible() + } + for (const state of ['live', 'recovering'] as const) { + await changeRecoveryState(page, state) + await expect(page.getByTestId('managed-runtime-recovery-card')).toBeHidden() + await expect(page.getByRole('alert', { name: 'Managed runtime notice' })).toBeHidden() + await expect(page.getByText('Resource limits and usage', { exact: true })).toBeHidden() + await expect(page.getByText(SOUL_ID, { exact: true })).toBeHidden() + await expect(page.getByRole('button', { name: 'Retry recovery', exact: true })).toBeHidden() + } + }) + + test(`${kind}: blocked retry preserves its soul revision and shows failure inside the pane`, async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + const historyRead = kind === 'fresh-agent' ? page.waitForRequest(`**/api/runtime/souls/${SOUL_ID}/history`) : null + await installPane(page, kind, 'blocked') + if (historyRead) expect((await historyRead).method()).toBe('GET') + const retries: Array<{ requestId: string; expectedIntentRevision: number }> = [] + let inventoryRefreshes = 0 + await page.route(`**/api/runtime/souls/${SOUL_ID}/retry`, async (route) => { + expect(route.request().method()).toBe('POST') + retries.push(route.request().postDataJSON()) + await route.fulfill(retries.length === 1 + ? { status: 409, json: { message: 'The provider is still unavailable. Try again.' } } + : { json: { outcome: 'blocked', view: { soulId: SOUL_ID, intentRevision: INTENT_REVISION, recoveryReason: 'STORE_UNREADABLE' }, + probe: { kind: 'blocked', data: { reason: 'STORE_UNREADABLE', retry_hint: { manualRetry: true, + repair: 'Restore read access to the saved conversation store, then retry recovery.' } } } } }) + }) + await page.route(/\/api\/runtime\/souls(?:\?.*)?$/, async (route) => { + inventoryRefreshes += 1 + await route.fulfill({ json: { revision: 100, readiness, souls: [], viewIntents: [], pendingProjectionCount: 0 } }) + }) + const card = page.getByTestId('managed-runtime-recovery-card') + await expect(card).toBeVisible() + await expect(card).toContainText('This session needs attention before it can continue.') + await expect(card).toContainText('The provider is unavailable. Check its availability, then retry recovery.') + if (kind === 'fresh-agent') await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + await card.getByRole('button', { name: 'Retry recovery', exact: true }).click() + await expect(card.getByRole('status')).toHaveText('The provider is still unavailable. Try again.') + await expect(page.getByRole('alert', { name: 'Managed runtime notice' })).toBeHidden() + expect(inventoryRefreshes).toBe(0) + await card.getByRole('button', { name: 'Retry recovery', exact: true }).click() + await expect.poll(() => inventoryRefreshes).toBe(1) + await expect(card.getByRole('status')).toHaveText('Restore read access to the saved conversation store, then retry recovery.') + expect(retries).toHaveLength(2) + for (const retry of retries) { + expect(retry.expectedIntentRevision).toBe(INTENT_REVISION) + expect(retry.requestId).toEqual(expect.any(String)) + expect(retry.requestId.length).toBeGreaterThan(0) + } + expect(await paneContent(page)).toMatchObject({ + soulId: SOUL_ID, soulIntentRevision: INTENT_REVISION, + createRequestId: CREATE_REQUEST_ID, sessionRef: { provider: 'codex', sessionId: SESSION_ID }, + }) + }) + + for (const deliveryOrder of (kind === 'fresh-agent' ? ['ack-first', 'inventory-first'] : ['ack-first'])) { + test(`${kind}: lost identity remains until explicit start-new cleanup is verified (${deliveryOrder})`, async ({ freshellPage, page, terminal, harness }) => { + await terminal.waitForTerminal() + const historyRead = kind === 'fresh-agent' ? page.waitForRequest(`**/api/runtime/souls/${SOUL_ID}/history`) : null + await installPane(page, kind, 'lost') + if (historyRead) expect((await historyRead).method()).toBe('GET') + const before = await paneContent(page) + const stopRequests: Array<{ expectedIntentRevision: number; requestId: string }> = [] + let releaseVerifiedStop!: () => void + const verifiedStop = new Promise((resolve) => { releaseVerifiedStop = resolve }) + await page.route(`**/api/runtime/souls/${SOUL_ID}/stop`, async (route) => { + stopRequests.push(route.request().postDataJSON()) + if (stopRequests.length === 1) { + await route.fulfill({ json: { outcome: 'termination_unconfirmed', soul: { soulId: SOUL_ID, intentRevision: INTENT_REVISION } } }) + } else { + await verifiedStop + await route.fulfill({ json: { outcome: 'verified_empty', soul: { soulId: SOUL_ID, intentRevision: INTENT_REVISION, freshAgentSessionId: SESSION_ID } } }) + } + }) + const card = page.getByTestId('managed-runtime-recovery-card') + await expect(card).toBeVisible() + await expect(card).toContainText('This session could not be recovered.') + await expect(card.getByRole('button', { name: 'Retry recovery', exact: true })).toBeHidden() + if (kind === 'fresh-agent') { + await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + await harness.receiveWsMessage({ + type: 'freshAgent.event', sessionId: SESSION_ID, sessionType: 'freshcodex', provider: 'codex', + event: { type: 'freshAgent.error', code: 'INVALID_SESSION_ID', message: 'Session is gone' }, + }) + } else { + await harness.receiveWsMessage({ + type: 'error', code: 'INVALID_TERMINAL_ID', + terminalId: before.kind === 'terminal' ? before.terminalId : undefined, + message: 'Terminal is gone', + }) + } + // Give frame handlers and their deferred recovery callbacks a browser turn. + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + expect(await paneContent(page)).toMatchObject({ + createRequestId: CREATE_REQUEST_ID, soulId: SOUL_ID, soulIntentRevision: INTENT_REVISION, + sessionRef: { provider: 'codex', sessionId: SESSION_ID }, resumeSessionId: SESSION_ID, + }) + const beforeChoice = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(beforeChoice.filter((message) => ['terminal.create', 'terminal.attach', 'freshAgent.create', 'freshAgent.attach', 'pane.reconcile.request'].includes(message.type ?? ''))).toEqual([]) + await card.getByRole('button', { name: 'Start new conversation', exact: true }).click() + await expect(card.getByRole('status')).toContainText('Your conversation has been kept') + if (kind === 'fresh-agent') await expect(page.getByText(SAVED_HISTORY_TEXT, { exact: true })).toBeVisible() + expect(await paneContent(page)).toMatchObject({ createRequestId: CREATE_REQUEST_ID, soulId: SOUL_ID, sessionRef: before.sessionRef }) + await card.getByRole('button', { name: 'Start new conversation', exact: true }).click() + try { + await expect(card.getByRole('button', { name: 'Starting…', exact: true })).toBeDisabled() + await expect.poll(() => stopRequests.length).toBe(2) + expect(stopRequests).toEqual([ + { expectedIntentRevision: INTENT_REVISION, requestId: expect.any(String) }, + { expectedIntentRevision: INTENT_REVISION, requestId: expect.any(String) }, + ]) + expect((await paneContent(page)).createRequestId).toBe(CREATE_REQUEST_ID) + const pendingMessages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(pendingMessages.filter((message) => ['freshAgent.kill', 'freshAgent.create', 'terminal.create'].includes(message.type ?? ''))).toEqual([]) + } finally { + releaseVerifiedStop() + } + await expect(card).toBeHidden() + await expect.poll(async () => (await paneContent(page)).createRequestId).not.toBe(CREATE_REQUEST_ID) + const replacement = await paneContent(page) + expect(replacement).toMatchObject({ kind }) + for (const field of ['soulId', 'incarnationId', 'soulIntentRevision', 'viewIntentId', 'recoverySummary', 'resourceSummary', 'sessionRef', 'resumeSessionId']) { + expect(replacement[field as keyof typeof replacement]).toBeUndefined() + } + if (replacement.kind === 'fresh-agent') { + expect(replacement.sessionId).toBeUndefined() + const tabsBeforeInventory = await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.getState().tabs.tabs.map((tab) => tab.id)) + const newSoulId = 'new-contextual-soul' + const runtimeSessionId = 'new-runtime-session' + const inventoryRevision = 101 + await page.route(/\/api\/runtime\/souls(?:\?.*)?$/, (route) => route.fulfill({ json: { + revision: inventoryRevision, readiness: { ...readiness, inventoryRevision }, pendingProjectionCount: 0, + souls: [{ soulId: newSoulId, incarnationId: 'new-incarnation', intentRevision: 1, + executionGeneration: 1, launchState: 'running', cleanupState: 'none', + desiredState: 'running', recoveryState: 'live', durabilityState: 'unknown', allocationState: 'allocated', + provider: 'codex', freshAgentSessionId: runtimeSessionId, freshAgentSessionType: 'freshcodex', + freshAgentCreateRequestId: replacement.createRequestId, + evidenceRevision: 0, successfulRecoveriesInWindow: 0 }], + viewIntents: [{ viewId: 'new-contextual-view', soulId: newSoulId, ownerId: 'fixture-owner', workspaceId: 'fixture-workspace', + kind: 'automatic_primary', preferredTabId: 'new-preferred-tab', preferredPaneId: 'new-preferred-pane', + title: 'New conversation', placementGroup: '', visibility: 'visible', revision: 1, soulIntentRevision: 1, + createdAt: 1, updatedAt: 1 }], + } })) + const acknowledge = async () => { + await harness.receiveWsMessage({ type: 'freshAgent.created', requestId: replacement.createRequestId, + sessionId: runtimeSessionId, sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' }) + await expect.poll(async () => { + const content = await paneContent(page) + return content.kind === 'fresh-agent' ? content.sessionId : undefined + }).toBe(runtimeSessionId) + } + if (deliveryOrder === 'ack-first') await acknowledge() + await harness.receiveWsMessage({ type: 'runtime.inventory.changed', revision: inventoryRevision, + readiness: { ...readiness, inventoryRevision } }) + await expect.poll(async () => (await paneContent(page)).soulId).toBe(newSoulId) + if (deliveryOrder === 'inventory-first') await acknowledge() + expect(await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.getState().tabs.tabs.map((tab) => tab.id))).toEqual(tabsBeforeInventory) + expect(await paneContent(page)).toMatchObject({ kind: 'fresh-agent', sessionId: runtimeSessionId, + createRequestId: replacement.createRequestId, viewIntentId: 'new-contextual-view' }) + const afterInventoryMessages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(afterInventoryMessages.filter((message) => message.type === 'terminal.create')).toEqual([]) + } + }) + } +} + +test('shared Fresh session puts the recovery decision on its originating pane despite layout order', async ({ freshellPage, page, terminal, harness }) => { + await terminal.waitForTerminal() + await installPane(page, 'fresh-agent', 'live') + const identity = await page.evaluate(({ sessionId, model }) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const mirrorPaneId = state.panes.activePane[tabId] + const originPaneId = 'shared-session-origin-pane' + harness.setFreshAgentNetworkEffectsSuppressed(originPaneId, true) + const content = (createRequestId: string) => ({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', + createRequestId, sessionId, sessionRef: { provider: 'codex', sessionId }, status: 'idle', + initialCwd: '/tmp', settingsDismissed: true, model, effort: 'low' }) + harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId: mirrorPaneId, content: content('mirror-create') } }) + harness.dispatch({ type: 'panes/splitPane', payload: { tabId, paneId: mirrorPaneId, direction: 'horizontal', + newPaneId: originPaneId, newContent: content('origin-create'), activate: false } }) + harness.clearSentWsMessages?.() + return { tabId, mirrorPaneId, originPaneId, tabIds: state.tabs.tabs.map((tab) => tab.id) } + }, { sessionId: SESSION_ID, model: FRESHCODEX_DEFAULT_MODEL }) + const inventoryRevision = 171 + await page.route(/\/api\/runtime\/souls(?:\?.*)?$/, (route) => route.fulfill({ json: { + revision: inventoryRevision, readiness: { ...readiness, inventoryRevision }, pendingProjectionCount: 0, + souls: [{ soulId: SOUL_ID, incarnationId: 'shared-incarnation', intentRevision: INTENT_REVISION, + executionGeneration: 1, launchState: 'stopped', cleanupState: 'none', desiredState: 'stopped', + recoveryState: 'lost', recoveryReason: 'provider_state_missing', durabilityState: 'resume_captured', + allocationState: 'verified_durable', provider: 'codex', nativeSessionId: SESSION_ID, + freshAgentSessionId: SESSION_ID, freshAgentSessionType: 'freshcodex', freshAgentCreateRequestId: 'origin-create', + evidenceRevision: 1, successfulRecoveriesInWindow: 0 }], + viewIntents: [{ viewId: 'shared-origin-view', soulId: SOUL_ID, ownerId: 'fixture-owner', workspaceId: 'fixture-workspace', + kind: 'automatic_primary', preferredTabId: identity.tabId, preferredPaneId: identity.originPaneId, + title: 'Retained conversation', placementGroup: '', visibility: 'visible', revision: 1, + soulIntentRevision: INTENT_REVISION, createdAt: 1, updatedAt: 1 }], + } })) + await harness.receiveWsMessage({ type: 'runtime.inventory.changed', revision: inventoryRevision, readiness: { ...readiness, inventoryRevision } }) + const card = page.getByTestId('managed-runtime-recovery-card') + await expect(card).toBeVisible() + expect(await card.evaluate((element) => element.closest('[data-pane-id]')?.getAttribute('data-pane-id'))).toBe(identity.originPaneId) + await expect(card.getByRole('button', { name: 'Start new conversation', exact: true })).toBeVisible() + await expect(card.getByRole('button', { name: 'Retry recovery', exact: true })).toBeHidden() + const panes = await page.evaluate((tabId) => { + const root = window.__FRESHELL_TEST_HARNESS__!.getState().panes.layouts[tabId] + if (root?.type !== 'split') throw new Error('Expected the original two panes') + return root.children.map((node) => { + if (node.type !== 'leaf') throw new Error('Expected a leaf') + return { id: node.id, content: node.content } + }) + }, identity.tabId) + expect(panes[0]).toMatchObject({ id: identity.mirrorPaneId, content: { createRequestId: 'mirror-create' } }) + expect(panes[0].content.recoverySummary).toBeUndefined() + expect(panes[1]).toMatchObject({ id: identity.originPaneId, content: { createRequestId: 'origin-create', + viewIntentId: 'shared-origin-view', recoverySummary: { desiredState: 'stopped', recoveryState: 'lost' } } }) + expect(await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.getState().tabs.tabs.map((tab) => tab.id))).toEqual(identity.tabIds) +}) + +for (const deliveryOrder of ['ack-first', 'inventory-first'] as const) { + test(`concurrent Fresh launches preserve their panes with ${deliveryOrder} inventory delivery`, async ({ freshellPage, page, terminal, harness }) => { + await terminal.waitForTerminal() + await page.route('**/api/fresh-agent/threads/**', (route) => route.fulfill({ status: 404, json: { message: 'Conversation is not yet available' } })) + const identity = await page.evaluate((model) => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + const secondPaneId = 'concurrent-second-pane' + harness.setFreshAgentNetworkEffectsSuppressed(paneId, true) + harness.setFreshAgentNetworkEffectsSuppressed(secondPaneId, true) + const content = (createRequestId: string) => ({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', + createRequestId, status: 'creating', initialCwd: '/tmp', settingsDismissed: true, model, effort: 'low' }) + harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content: content('concurrent-first-create') } }) + harness.dispatch({ type: 'panes/splitPane', payload: { tabId, paneId, direction: 'horizontal', + newPaneId: secondPaneId, newContent: content('concurrent-second-create'), activate: false } }) + harness.clearSentWsMessages?.() + return { tabId, paneIds: [paneId, secondPaneId], tabIds: state.tabs.tabs.map((tab) => tab.id) } + }, FRESHCODEX_DEFAULT_MODEL) + const inventoryRevision = 151 + const keys = ['first', 'second'] + await page.route(/\/api\/runtime\/souls(?:\?.*)?$/, (route) => route.fulfill({ json: { + revision: inventoryRevision, readiness: { ...readiness, inventoryRevision }, pendingProjectionCount: 0, + souls: [...keys].reverse().map((key) => ({ soulId: `concurrent-${key}-soul`, incarnationId: `concurrent-${key}-incarnation`, + intentRevision: 1, executionGeneration: 1, launchState: 'running', cleanupState: 'none', + desiredState: 'running', recoveryState: 'live', durabilityState: 'unknown', allocationState: 'allocated', + provider: 'codex', freshAgentSessionId: `concurrent-${key}-runtime`, freshAgentSessionType: 'freshcodex', + freshAgentCreateRequestId: `concurrent-${key}-create`, evidenceRevision: 0, successfulRecoveriesInWindow: 0 })), + viewIntents: [...keys].reverse().map((key) => ({ viewId: `concurrent-${key}-view`, soulId: `concurrent-${key}-soul`, + ownerId: 'fixture-owner', workspaceId: 'fixture-workspace', kind: 'automatic_primary', + preferredTabId: identity.tabId, preferredPaneId: `concurrent-${key}-preferred-pane`, title: 'Concurrent conversation', + placementGroup: '', visibility: 'visible', revision: 1, soulIntentRevision: 1, createdAt: 1, updatedAt: 1 })), + } })) + const readPanes = () => page.evaluate((tabId) => { + const root = window.__FRESHELL_TEST_HARNESS__!.getState().panes.layouts[tabId] + if (root?.type !== 'split') throw new Error('Expected two original panes') + return root.children.map((node) => { + if (node.type !== 'leaf') throw new Error('Expected original leaf') + return { id: node.id, content: node.content } + }) + }, identity.tabId) + const acknowledge = async () => { + for (const key of keys) await harness.receiveWsMessage({ type: 'freshAgent.created', requestId: `concurrent-${key}-create`, + sessionId: `concurrent-${key}-runtime`, sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' }) + await expect.poll(async () => (await readPanes()).map((pane) => pane.content.kind === 'fresh-agent' ? pane.content.sessionId : undefined)) + .toEqual(keys.map((key) => `concurrent-${key}-runtime`)) + } + if (deliveryOrder === 'ack-first') await acknowledge() + await harness.receiveWsMessage({ type: 'runtime.inventory.changed', revision: inventoryRevision, readiness: { ...readiness, inventoryRevision } }) + await expect.poll(async () => (await readPanes()).map((pane) => pane.content.soulId)).toEqual(keys.map((key) => `concurrent-${key}-soul`)) + if (deliveryOrder === 'inventory-first') await acknowledge() + const panes = await readPanes() + for (const [index, key] of keys.entries()) expect(panes[index]).toMatchObject({ id: identity.paneIds[index], content: { + kind: 'fresh-agent', createRequestId: `concurrent-${key}-create`, sessionId: `concurrent-${key}-runtime`, + soulId: `concurrent-${key}-soul`, viewIntentId: `concurrent-${key}-view`, + } }) + expect(await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.getState().tabs.tabs.map((tab) => tab.id))).toEqual(identity.tabIds) + const messages = await harness.getSentWsMessages() as Array<{ type?: string }> + expect(messages.filter((message) => message.type === 'terminal.create')).toEqual([]) + }) +} + +test('routine notices stay silent while cleanup failure is a yellow actionable popup', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + const notices: ManagedRuntimeNotice[] = [ + { noticeId: 'routine-cleanup', kind: 'cleanup_succeeded', message: 'Found and cleaned up 1 lost agent process.', reference: 'SUCCESS1', incidentIds: [], deliveryState: 'pending', createdAt: '2026-10-02T00:00:00.000Z' }, + { noticeId: 'routine-ended', kind: 'ended_without_process', message: 'The managed agent ended without a running process.', reference: 'ENDED001', incidentIds: [], deliveryState: 'pending', createdAt: '2026-10-02T00:00:01.000Z' }, + ] + const receipts: Array<{ noticeId: string; state: string; profileId: string }> = [] + await page.route('**/api/runtime/notices?**', (route) => route.fulfill({ json: { notices } })) + await page.route('**/api/runtime/notices/*/receipt', async (route) => { + const noticeId = route.request().url().split('/').at(-2)! + const body = route.request().postDataJSON() + receipts.push({ noticeId, state: body.state, profileId: body.profileId }) + const notice = notices.find((item) => item.noticeId === noticeId) + if (notice) notice.deliveryState = body.state + if (body.state === 'dismissed') notices.splice(notices.findIndex((item) => item.noticeId === noticeId), 1) + await route.fulfill({ json: { ok: true } }) + }) + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__!.dispatch({ type: 'managedRuntime/setManagedRuntimeAvailable', payload: true })) + await expect.poll(() => receipts.filter((receipt) => receipt.state === 'acknowledged').map((receipt) => receipt.noticeId).sort()).toEqual(['routine-cleanup', 'routine-ended']) + const popup = page.getByRole('alert', { name: 'Managed runtime notice' }) + await expect(popup).toBeHidden() + notices.push({ noticeId: 'cleanup-failed', kind: 'cleanup_failed', message: 'Cleanup could not be verified. No unrelated process was touched. Reference: FAIL0001.', reference: 'FAIL0001', incidentIds: ['incident-one'], deliveryState: 'pending', createdAt: '2026-10-02T00:00:02.000Z' }) + await page.route('**/api/runtime/incidents/incident-one/summary', (route) => route.fulfill({ json: { + incidentId: 'incident-one', correlationId: 'correlation-one', soulId: SOUL_ID, provider: 'codex', + state: 'cleanup_failed', reasonCode: 'cleanup_unconfirmed', observedCause: 'Provider process ownership could not be confirmed.', + cleanup: { ownedHandleRef: 'registry://contextual-incarnation', ownershipVerified: false, gracefulAttempt: 'not_attempted', forcedAttempt: 'not_attempted', verifiedEmpty: false, foreignObjectsTouched: 0 }, + createdAt: '2026-10-02T00:00:02.000Z', updatedAt: '2026-10-02T00:00:03.000Z', + } })) + await expect(popup).toBeVisible() + await expect(popup).toContainText('Runtime cleanup needs attention') + await expect(popup).toContainText('No unrelated process was touched') + await expect.poll(() => receipts.some((receipt) => receipt.noticeId === 'cleanup-failed' && receipt.state === 'rendered')).toBe(true) + expect(receipts.filter((receipt) => receipt.noticeId === 'cleanup-failed' && receipt.state === 'acknowledged')).toEqual([]) + // The visible warning has the same yellow tone as the contextual pane card. + expect(await popup.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(245, 158, 11, 0.1)') + await popup.getByRole('button', { name: 'Details', exact: true }).click() + await expect(popup).toContainText('Provider process ownership could not be confirmed.') + await popup.getByRole('button', { name: 'Dismiss', exact: true }).click() + await expect(popup).toBeHidden() + expect(receipts.some((receipt) => receipt.noticeId === 'cleanup-failed' && receipt.state === 'dismissed')).toBe(true) + expect(receipts.every((receipt) => receipt.profileId.startsWith('profile:'))).toBe(true) +}) diff --git a/test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts b/test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts index fbafb56e7..f544069bc 100644 --- a/test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-lost-soul-notice-rust.spec.ts @@ -5,7 +5,8 @@ * volume. The test performs a real turn, retains a diagnostic-only marker, * removes every registered resumable copy, terminates only the host-recorded * provider PID, and verifies incident-before-cleanup, ended-pane retention, - * one brief notice, and zero foreign/credential access. + * a pane-local recovery decision for the lost conversation, and zero + * foreign/credential access. */ import fs from 'node:fs' import path from 'node:path' @@ -177,7 +178,7 @@ function writePrivateJson(filePath: string, value: unknown): void { } test.describe.serial('Phase 5 certified provider loss', () => { - test('P5-G02: real OpenCode loss persists incident before exact cleanup and shows one brief notice', async ({ page }) => { + test('P5-G02: real OpenCode loss persists incident before exact cleanup and shows the actionable pane path', async ({ page }) => { test.skip(process.env.FRESHELL_RUNTIME_PHASE5_LIVE !== '1', 'set FRESHELL_RUNTIME_PHASE5_LIVE=1 for the live loss receipt') test.setTimeout(1_200_000) @@ -268,6 +269,26 @@ test.describe.serial('Phase 5 certified provider loss', () => { // Retain only a diagnostic marker; remove OpenCode session DB/artifacts // and all Freshell checkpoint copies without reading or mutating host // credentials. Then terminate exactly the recorded provider PID. + // Observe the entire transition so a popup that flashes and disappears + // cannot be certified as invisible from its final DOM state alone. + await page.evaluate(() => { + const selector = '[aria-label="Managed runtime notice"]' + const observation = { + maximumCount: document.querySelectorAll(selector).length, + observer: new MutationObserver((records) => { + const addedNotice = records.some((record) => Array.from(record.addedNodes) + .some((node) => node instanceof Element + && (node.matches(selector) || node.querySelector(selector) !== null))) + observation.maximumCount = Math.max( + observation.maximumCount, + document.querySelectorAll(selector).length, + addedNotice ? 1 : 0, + ) + }), + } + observation.observer.observe(document.body, { childList: true, subtree: true }) + ;(window as any).__FRESHELL_PHASE5_NOTICE_OBSERVATION__ = observation + }) rig.runtime.killOwnedRuntimePidExact(before.containerId, workerPid) rig.ownedContainerExec(before.containerId, [ 'node', '-e', String.raw` @@ -318,14 +339,42 @@ if (!fs.statSync('/home/freshell/provider/p5-diagnostic-only').isFile()) process expect(ended.content.incidentId).toBe(result.incidentId) expect(ended.content.sessionRef.sessionId).toBe(providerSessionId) - const notice = await page.getByRole('status', { name: 'Managed runtime notice' }).or( - page.getByRole('alert', { name: 'Managed runtime notice' }), - ).first() - await expect(notice).toBeVisible({ timeout: 60_000 }) - await expect(notice).toContainText(/Found and cleaned up 1 lost agent process/) - await expect(notice).toContainText(/Reference:/) - await notice.getByRole('button', { name: 'Details' }).click() - await expect(notice).toContainText(/verified empty/i) + // Verified cleanup is routine and is acknowledged without a popup. The + // lost conversation itself remains in place and owns the actionable + // amber card, including the explicitly labeled start-new action. + await expect.poll(() => page.locator('[aria-label="Managed runtime notice"]').count(), { + timeout: 30_000, + }).toBe(0) + const paneRecoveryCard = page.locator(`[data-pane-id="${paneId}"] [data-testid="managed-runtime-recovery-card"]`) + await expect(paneRecoveryCard).toBeVisible({ timeout: 60_000 }) + await expect(paneRecoveryCard).toContainText(/could not be recovered/i) + const startNewConversation = paneRecoveryCard.getByRole('button', { name: 'Start new conversation' }) + await expect(startNewConversation).toBeVisible() + await expect(startNewConversation).toBeEnabled() + const visibleRecoveryCards = page.locator('[data-testid="managed-runtime-recovery-card"]:visible') + await expect(visibleRecoveryCards).toHaveCount(1) + const recoveryCards = await visibleRecoveryCards.evaluateAll((cards) => cards.map((card) => { + const button = Array.from(card.querySelectorAll('button')) + .find((candidate) => candidate.textContent?.trim() === 'Start new conversation') + return { + paneId: card.closest('[data-pane-id]')?.getAttribute('data-pane-id'), + lossMessageVisible: /could not be recovered/i.test(card.textContent ?? ''), + startNewConversationEnabled: !!button && !button.disabled + && button.getClientRects().length > 0, + } + })) + const routineNoticeCount = await page.evaluate(() => { + const observation = (window as any).__FRESHELL_PHASE5_NOTICE_OBSERVATION__ + if (!observation) throw new Error('loss notice observation is missing') + observation.observer.disconnect() + const maximumCount = Math.max( + observation.maximumCount, + document.querySelectorAll('[aria-label="Managed runtime notice"]').length, + ) + delete (window as any).__FRESHELL_PHASE5_NOTICE_OBSERVATION__ + return maximumCount + }) + expect(routineNoticeCount).toBe(0) const incident = dataOf(await rig.runtime.adminOk( rig.supervisor, @@ -348,7 +397,7 @@ if (!fs.statSync('/home/freshell/provider/p5-diagnostic-only').isFile()) process expect(result.view.intentRevision).toBe(before.intentRevision + 1) writePrivateJson(path.join(rig.runtime.evidenceDir, PHASE5_LOSS_INCIDENT_FILE), incidentArtifact) writePrivateJson(path.join(rig.runtime.evidenceDir, PHASE5_LOSS_ASSERTIONS_FILE), { - schemaVersion: 1, + schemaVersion: 2, candidateSha: rig.runtime.candidateSha, receiptRunId: rig.runtime.runId, caseId: 'P5-G02', @@ -382,7 +431,8 @@ if (!fs.statSync('/home/freshell/provider/p5-diagnostic-only').isFile()) process }], }, browser: { - displayedNoticeIds: [incidentArtifact.noticeId], + routineNoticeCount, + recoveryCards, endedPane: { soulId: ended.content.soulId, incarnationId: before.incarnationId, diff --git a/test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts b/test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts index 4802f411e..7451b25ad 100644 --- a/test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts +++ b/test/e2e-browser/specs/runtime-tabs-rehydrate-rust.spec.ts @@ -10,6 +10,8 @@ */ import { expect, type Page } from '@playwright/test' import WebSocket from 'ws' +import fs from 'node:fs/promises' +import path from 'node:path' import { test } from '../helpers/fixtures.js' import { ManagedRuntimeBrowserRig } from '../helpers/managed-runtime.js' @@ -162,6 +164,460 @@ class RawWsClient { } test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { + test('managed fresh-agent: already-live attach restores stale loss through real hosted HTTP truth', async ({ page }) => { + test.setTimeout(900_000) + const observerIntervalMs = 60_000 + const rig = new ManagedRuntimeBrowserRig(process.cwd(), 3, {}, { + FRESHELL_RUNTIME_HOST_COMMAND_TIMEOUT_MS: '5000', FRESHELL_RUNTIME_FRESH_AGENT_COMMAND_TIMEOUT_MS: '10000', + // Exercise owned history before the supervisor's normal periodic recovery. + FRESHELL_RUNTIME_OBSERVER_INTERVAL_MS: String(observerIntervalMs), + }, 'test', { + enabledProviders: [], freshAgentModes: ['freshcodex'], fixtureFreshAgentModes: ['freshcodex'], + providerSettings: { freshcodex: {} }, + }) + const sent: any[] = [] + const received: any[] = [] + let heldHost: {containerId: string, incarnationId: string, pid: number} | undefined + let cpuSession: Awaited['newCDPSession']>> | undefined + let profilingStarted = false + const measurement: Record = { kind: 'owned-page saved-history diagnostic', errors: [] } + const measurementErrors = measurement.errors as string[] + // Keep only asset names and route classes, never auth queries or native text. + const scriptLabel = (url: string) => { + if (!url) return 'native-or-anonymous' + try { + const parsed = new URL(url) + return ['http:', 'https:'].includes(parsed.protocol) && parsed.pathname.startsWith('/assets/') + ? parsed.pathname.split('/').at(-1) : 'document-or-other-script' + } catch { return 'anonymous-script' } + } + try { + const info = await rig.start() + const settings = await fetch(`${info.baseUrl}/api/settings`, { + method: 'PATCH', headers: { 'content-type': 'application/json', 'x-auth-token': info.token }, + body: JSON.stringify({ codingCli: { enabledProviders: ['codex'] } }), + }) + expect(settings.ok).toBe(true) + await page.routeWebSocket('**/ws', (socket) => { + const upstream = socket.connectToServer() + socket.onMessage((data) => { sent.push(JSON.parse(String(data))); upstream.send(data) }) + upstream.onMessage((data) => { received.push(JSON.parse(String(data))); socket.send(data) }) + }) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1&perfAudit=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.getByRole('button', { name: 'Freshcodex', exact: true }).click() + await page.getByRole('option', { name: rig.repoRoot, exact: true }).click() + const created = await waitForValue('browser-created fresh pane', async () => { + const state = await harness.getState() + const tabId = state.tabs.activeTabId! + const leaf = state.panes.layouts[tabId] + return leaf?.type === 'leaf' && leaf.content.kind === 'fresh-agent' && leaf.content.sessionId + ? { tabId, paneId: leaf.id, sessionId: leaf.content.sessionId } : null + }, 60_000) + const view = await waitForValue('real managed fresh Codex session', async () => ( + (await rig.inventory()).find((row) => row.freshAgentSessionType === 'freshcodex' && row.launchState === 'running') ?? null + ), 90_000) + expect(view.containerId).toBeTruthy() + expect(view.nativeSessionId).toBeTruthy() + const rolloutPath = `/home/freshell/provider/.codex/sessions/2026/03/01/rollout-${view.nativeSessionId}.jsonl` + const makeTranscript = (nativeId: string) => [ + JSON.stringify({ type: 'session_meta', payload: { id: nativeId, cwd: '/workspace', model_provider: 'openai' } }), + ...Array.from({ length: 80 }, (_, index) => [ + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: `retained-${index}` } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: `Managed prompt ${index}` } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete', turn_id: `retained-${index}`, + last_agent_message: `${index === 79 ? 'Managed fixture saved answer' : `Managed retained answer ${index}`}\n${'x'.repeat(20_000)}` } }), + ]).flat(), + ].join('\n') + '\n' + const transcript = makeTranscript(view.nativeSessionId!) + expect(Buffer.byteLength(transcript)).toBeGreaterThan(1024 * 1024) + // Generate in the owned provider process, avoiding a multi-MiB argv value. + rig.ownedProviderExec(view.containerId!, ['node', '--input-type=module', '-e', + `import fs from "node:fs"; import path from "node:path"; const makeTranscript = ${makeTranscript.toString()}; fs.mkdirSync(path.dirname(process.argv[1]), {recursive:true}); fs.writeFileSync(process.argv[1], makeTranscript(process.argv[2]));`, + rolloutPath, view.nativeSessionId!]) + const fixtureState = () => JSON.parse(rig.ownedProviderExec(view.containerId!, [ + 'cat', '/home/freshell/provider/.freshell-fixture/provider-native-state.json', + ])) + const before = fixtureState() + const ownedSnapshot = await rig.runtime.adminOk(rig.supervisor, { + method: 'fresh_agent_read_snapshot', params: { soulId: view.soulId, expectedControlEpoch: await rig.controlEpoch() }, + }) + expect(ownedSnapshot.data.threadId).toBe(view.nativeSessionId) + const expectLargeRetainedHistory = (snapshot: any) => { + expect(Buffer.byteLength(JSON.stringify(snapshot))).toBeGreaterThan(1024 * 1024) + // The native projection preserves each user/assistant row independently. + expect(snapshot.turns).toHaveLength(160) + expect(snapshot.turns[0].turnId).toBe('retained-0:row-0') + expect(snapshot.turns[159].turnId).toBe('retained-79:row-1') + const first = snapshot.turns[1].items.find((item: any) => item.text?.startsWith('Managed retained answer 0')) + const last = snapshot.turns[159].items.find((item: any) => item.text?.startsWith('Managed fixture saved answer')) + expect(first.text).toBe(`Managed retained answer 0\n${'x'.repeat(20_000)}`) + expect(last.text).toBe(`Managed fixture saved answer\n${'x'.repeat(20_000)}`) + expect(snapshot.extensions.codex.nativeHistoryRetention).toBeUndefined() + } + expectLargeRetainedHistory(ownedSnapshot.data) + const initialSnapshot = await fetch(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${view.nativeSessionId}`, { + headers: { 'x-auth-token': info.token }, + }) + expect(initialSnapshot.ok).toBe(true) + expectLargeRetainedHistory(await initialSnapshot.json()) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + const pane = page.locator(`[data-pane-id="${created.paneId}"]`) + await expect(pane.getByText('Managed fixture saved answer', { exact: false })).toBeVisible({ timeout: 60_000 }) + const composer = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(composer).toBeEnabled() + await expect.poll(async () => { + const state = await harness.getState() + return [view.freshAgentSessionId, view.nativeSessionId].includes(state.panes.layouts[created.tabId].content.sessionId) + }).toBe(true) + await composer.fill('Draft stays in this managed conversation') + const original = await page.evaluate(({ tabId, paneId }) => { + const root = window.__FRESHELL_TEST_HARNESS__!.getState().panes.layouts[tabId] + const find = (node: any): any => node.type === 'leaf' ? node.id === paneId ? node.content : undefined + : node.children.map(find).find(Boolean) + return find(root) + }, { tabId: created.tabId, paneId: created.paneId }) + expect(original.soulId).toBe(view.soulId) + expect(original.sessionRef.sessionId).toBe(view.nativeSessionId) + const canonicalIdentity = { sessionRef: original.sessionRef, resumeSessionId: original.resumeSessionId, + createRequestId: original.createRequestId, soulId: original.soulId } + const expectOriginalConversation = async () => { + const state = await harness.getState() + const content = state.panes.layouts[created.tabId].content + expect(content).toMatchObject(canonicalIdentity) + // The managed gateway and native materialization use these two + // existing presentation aliases for the same canonical conversation. + expect([view.freshAgentSessionId, view.nativeSessionId]).toContain(content.sessionId) + const current = (await rig.inventory()).find((row) => row.soulId === view.soulId) + expect(current).toMatchObject({ soulId: view.soulId, containerId: view.containerId, + incarnationId: view.incarnationId, nativeSessionId: view.nativeSessionId }) + } + expect(sent.some((frame) => frame.type === 'freshAgent.attach' && frame.sessionId === original.sessionId)).toBe(true) + let release!: () => void + const held = new Promise((resolve) => { release = resolve }) + let liveResponse: any + await page.route('**/api/fresh-agent/threads/**', async (route) => { + const actual = await route.fetch() + liveResponse = await actual.json() + await held + await route.fulfill({ response: actual }) + }) + const baseline = sent.length + await harness.receiveWsMessage({ type: 'freshAgent.event', provider: 'codex', sessionType: 'freshcodex', + sessionId: original.sessionId, event: { type: 'freshAgent.error', code: 'INVALID_SESSION_ID', message: 'Stale managed lookup' } }) + await expect(composer).toBeDisabled() + await expect(composer).toHaveValue('Draft stays in this managed conversation') + await expect.poll(() => liveResponse?.extensions?.codex?.statusFromLiveState).toBe(true) + expect(liveResponse.threadId).toBe(view.nativeSessionId) + expect(liveResponse.capabilities.send).toBe(true) + expectLargeRetainedHistory(liveResponse) + const afterLoss = sent.slice(baseline) + expect(afterLoss.filter((frame) => frame.type === 'freshAgent.create' || frame.type === 'pane.reconcile.request')).toHaveLength(0) + expect(received.filter((frame) => frame.type === 'freshAgent.event' && frame.sessionId === original.sessionId + && frame.event?.type === 'freshAgent.session.snapshot')).toHaveLength(0) + await expect(pane.getByText('Managed fixture saved answer', { exact: false })).toBeVisible() + await expect(page.getByTestId('managed-runtime-recovery-card')).toHaveCount(0) + await expect(pane.getByRole('button', { name: 'Start new session' })).toHaveCount(0) + await expectOriginalConversation() + expect(fixtureState().dispatchCount).toBe(before.dispatchCount) + release() + await expect(composer).toBeEnabled() + await expect(composer).toHaveValue('Draft stays in this managed conversation') + await composer.press('Enter') + await expect.poll(() => fixtureState().completionCount, { timeout: 30_000 }).toBe(before.completionCount + 1) + expect(fixtureState().dispatchCount).toBe(before.dispatchCount + 1) + expect(fixtureState().nativeSessionId).toBe(view.nativeSessionId) + const current = (await rig.inventory()).find((row) => row.soulId === view.soulId) + expect(current).toMatchObject({ soulId: view.soulId, containerId: view.containerId, + incarnationId: view.incarnationId, nativeSessionId: view.nativeSessionId }) + await expectOriginalConversation() + expect(sent.slice(baseline).filter((frame) => frame.type === 'freshAgent.create' || frame.type === 'pane.reconcile.request')).toHaveLength(0) + expect(rig.ownedProviderExec(view.containerId!, ['cat', rolloutPath])).toBe(transcript) + const stableProviderState = fixtureState() + // A tracked host can stop answering while its managed projection still says live. + // Its saved source is the owned provider volume, never a coincident web-local file. + await page.unroute('**/api/fresh-agent/threads/**') + const wrongPath = `${info.homeDir}/.codex/sessions/rollout-${view.nativeSessionId}.jsonl` + await fs.mkdir(`${info.homeDir}/.codex/sessions`, { recursive: true }) + const wrongTranscript = transcript.replaceAll('Managed fixture saved answer', 'Wrong web-local answer') + await fs.writeFile(wrongPath, wrongTranscript) + const lifecyclePath = path.join(path.dirname(rig.supervisor.runtimeRoot), 'evidence', 'lifecycle.jsonl') + const readLifecycle = async () => (await fs.readFile(lifecyclePath, 'utf8')) + .split('\n').filter(Boolean).map((line) => JSON.parse(line)) + // Finish healthy preparation before starting this owned controller's + // first observation window. Its startup must reattach the original host. + const oldControllerId = rig.supervisor.containerId + const oldControlEpoch = await rig.controlEpoch() + const controllerSentBaseline = sent.length + const controllerRestartStartedAt = Date.now() + await rig.restartSupervisor() + const controllerHealth = (await rig.runtime.adminOk(rig.supervisor, { method: 'health' })).data + const reattached = (await rig.inventory()).find((row) => row.soulId === view.soulId) + expect(reattached).toMatchObject({ soulId: view.soulId, containerId: view.containerId, + incarnationId: view.incarnationId, nativeSessionId: view.nativeSessionId, + desiredState: 'running', launchState: 'running', recoveryState: 'live' }) + expect(fixtureState()).toMatchObject({ nativeSessionId: stableProviderState.nativeSessionId, + dispatchCount: stableProviderState.dispatchCount, completionCount: stableProviderState.completionCount }) + expect(rig.ownedProviderExec(view.containerId!, ['cat', rolloutPath])).toBe(transcript) + expect(await fs.readFile(wrongPath, 'utf8')).toBe(wrongTranscript) + await expectOriginalConversation() + expect(sent.slice(controllerSentBaseline).filter((frame) => frame.type === 'freshAgent.create' + || frame.type === 'pane.reconcile.request')).toHaveLength(0) + const restartedLifecycle = await readLifecycle() + const startupFinished = restartedLifecycle.filter((event) => event.event === 'supervisor.startup_scan.finished').at(-1) + const controllerReady = restartedLifecycle.filter((event) => event.event === 'supervisor.ready').at(-1) + expect(Number.isFinite(startupFinished?.at)).toBe(true) + expect(Number.isFinite(controllerReady?.at)).toBe(true) + expect(startupFinished.at).toBeGreaterThanOrEqual(controllerRestartStartedAt) + expect(controllerReady.at).toBeGreaterThanOrEqual(startupFinished.at) + expect(startupFinished.data).toMatchObject({ scanned: 1, blockedSubsystems: [] }) + expect(controllerReady.data).toMatchObject({ controlEpoch: controllerHealth.control_epoch, + installationId: controllerHealth.installation_id }) + expect(controllerReady.data.controlEpoch).toBeGreaterThan(oldControlEpoch) + expect(rig.supervisor.containerId).not.toBe(oldControllerId) + const controllerReattachment = { oldControllerId, currentControllerId: rig.supervisor.containerId, + oldControlEpoch, currentControlEpoch: controllerReady.data.controlEpoch, controllerRestartStartedAt, + controllerReady, startupFinished, reattached, completedAt: Date.now() } + rig.runtime.recordLifecycle('browser.owned_controller_reattached', controllerReattachment) + // The observer sleeps first, after startup reconciliation finishes. + const firstObservationNotBefore = startupFinished.at + observerIntervalMs + const holdRequestedAt = Date.now() + expect(firstObservationNotBefore - holdRequestedAt, + 'owned history/read/reload must have a measured window before STOP').toBeGreaterThanOrEqual(55_000) + const pid = rig.runtime.ownedContainerHostPidExact(view.containerId!) + heldHost = { containerId: view.containerId!, incarnationId: view.incarnationId, pid } + rig.signalOwnedSessionHostExact(heldHost.containerId, heldHost.incarnationId, 'SIGSTOP', pid) + await expect.poll(async () => /State:\s+T/.test(await fs.readFile(`/proc/${pid}/status`, 'utf8'))).toBe(true) + const heldAt = Date.now() + rig.runtime.recordLifecycle('browser.owned_host_hold', { ...heldHost, state: 'T' }) + const unavailableBaseline = sent.length + const unavailable = await fetch(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${view.nativeSessionId}`, { + headers: { 'x-auth-token': info.token }, + }) + expect(unavailable.status).toBe(200) + const history = await unavailable.json() + expect(history.threadId).toBe(view.nativeSessionId) + expect(history.extensions.codex.nativeHistoryAvailable).toBe(true) + expect(history.extensions.codex.ownerKind).toBe('vacant') + expect(history.extensions.codex.statusFromLiveState).not.toBe(true) + expect(history.capabilities.send).toBe(false) + expectLargeRetainedHistory(history) + expect(JSON.stringify(history)).not.toContain('Wrong web-local answer') + expect(sent.slice(unavailableBaseline).filter((frame) => frame.type === 'freshAgent.create' || frame.type === 'pane.reconcile.request')).toHaveLength(0) + expect(fixtureState()).toMatchObject({ nativeSessionId: stableProviderState.nativeSessionId, + dispatchCount: stableProviderState.dispatchCount, completionCount: stableProviderState.completionCount }) + const reloadSentBaseline = sent.length + const reloadReceivedBaseline = received.length + cpuSession = await page.context().newCDPSession(page) + await cpuSession.send('Profiler.enable') + measurement.browserBeforeReload = await page.evaluate(() => ({ timeOrigin: performance.timeOrigin, now: performance.now() })) + measurement.cpuStartRequestedAt = Date.now() + await cpuSession.send('Profiler.start') + profilingStarted = true + measurement.cpuStartedAt = Date.now() + rig.runtime.recordLifecycle('browser.saved_history_measurement.started', measurement) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + const bootstrap = await waitForValue('canonical reload reconciliation acknowledgement', () => { + const request = sent.slice(reloadSentBaseline).find((frame) => frame.type === 'pane.reconcile.request') + const result = received.slice(reloadReceivedBaseline).find((frame) => frame.type === 'pane.reconcile.result' + && frame.reconcileId === request?.reconcileId) + return request && result ? { request, result } : null + }, 30_000) + expect(bootstrap.request.panes).toEqual([expect.objectContaining({ + paneKey: `${created.tabId}:${created.paneId}`, createRequestId: original.createRequestId, + sessionRef: original.sessionRef, kind: 'fresh-agent', mode: 'codex', + })]) + if (bootstrap.result.verdicts.some((verdict: any) => ['fresh', 'respawn'].includes(verdict.verdict))) { + await waitForValue('same-request bootstrap create acknowledgement', () => received.slice(reloadReceivedBaseline) + .find((frame) => ['freshAgent.created', 'freshAgent.create.failed'].includes(frame.type) + && frame.requestId === original.createRequestId), 30_000) + } + const bootstrapLifecycle = () => sent.slice(reloadSentBaseline) + .filter((frame) => frame.type === 'freshAgent.create' || frame.type === 'pane.reconcile.request') + const expectOnlyCanonicalBootstrap = () => { + const frames = bootstrapLifecycle() + expect(frames.filter((frame) => frame.type === 'pane.reconcile.request')).toEqual([bootstrap.request]) + const creates = frames.filter((frame) => frame.type === 'freshAgent.create') + expect(creates.length).toBeLessThanOrEqual(1) + for (const frame of creates) { + expect(frame).toMatchObject({ requestId: original.createRequestId, tabId: created.tabId, + provider: 'codex', sessionType: 'freshcodex' }) + if (frame.sessionRef) expect(frame.sessionRef).toEqual(original.sessionRef) + } + } + expectOnlyCanonicalBootstrap() + const settledLifecycleCount = bootstrapLifecycle().length + await expect(pane.getByText('Managed fixture saved answer', { exact: false })).toBeVisible({ timeout: 60_000 }) + await expect(composer).toBeDisabled() + await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + await expect(pane.getByText('Wrong web-local answer', { exact: false })).toHaveCount(0) + await expectOriginalConversation() + expect(fixtureState()).toMatchObject({ nativeSessionId: stableProviderState.nativeSessionId, + dispatchCount: stableProviderState.dispatchCount, completionCount: stableProviderState.completionCount }) + expect(rig.ownedProviderExec(view.containerId!, ['cat', rolloutPath])).toBe(transcript) + expect(await fs.readFile(wrongPath, 'utf8')).toBe(wrongTranscript) + const managedReceipts = rig.runtime.broker.receipts().filter((receipt) => receipt.soulId === view.soulId) + expect(managedReceipts).toHaveLength(1) + expect(managedReceipts[0].containerId).toBe(view.containerId) + expect(rig.runtime.broker.eventsSnapshot().filter((event) => event.containerId === view.containerId + && event.method === 'POST' && event.url === `/v1.47/containers/${view.containerId}/start`)).toHaveLength(1) + const lifecycle = await readLifecycle() + const launches = lifecycle.filter((event) => event.event === 'supervisor.launch_running' && event.data.soulId === view.soulId) + expect(launches).toHaveLength(1) + expect(launches[0].data).toMatchObject({ incarnationId: view.incarnationId, containerId: view.containerId, workerLaunchCount: 1 }) + expectOnlyCanonicalBootstrap() + expect(bootstrapLifecycle()).toHaveLength(settledLifecycleCount) + const historyEvidence = { source: 'owned provider-volume native history', + wrongSource: 'coincident web-local native history', heldHost, bootstrap: bootstrap.request, + bootstrapCreates: bootstrapLifecycle().filter((frame) => frame.type === 'freshAgent.create'), + nativeSessionId: view.nativeSessionId, beforeHistory: stableProviderState, afterReload: fixtureState(), + originalIdentity: canonicalIdentity, managedLaunch: launches[0], bothSourcesUnchanged: true } + rig.signalOwnedSessionHostExact(heldHost.containerId, heldHost.incarnationId, 'SIGCONT', pid) + const releasedAt = Date.now() + rig.runtime.recordLifecycle('browser.owned_host_release', heldHost) + heldHost = undefined + await expect.poll(async () => /State:\s+T/.test(await fs.readFile(`/proc/${pid}/status`, 'utf8'))).toBe(false) + const resumedAt = Date.now() + expect(resumedAt, 'exact owned host must resume before the first observer pass').toBeLessThan(firstObservationNotBefore) + await expectOriginalConversation() + expect(fixtureState()).toMatchObject({ nativeSessionId: stableProviderState.nativeSessionId, + dispatchCount: stableProviderState.dispatchCount, completionCount: stableProviderState.completionCount }) + expectOnlyCanonicalBootstrap() + expect(bootstrapLifecycle()).toHaveLength(settledLifecycleCount) + const targetSoulRecoveries = (await readLifecycle()).filter((event) => event.event === 'supervisor.runtime_observer.recovery_scheduled' + && event.data.soulId === view.soulId) + expect(targetSoulRecoveries).toHaveLength(0) + rig.runtime.writeBrowserArtifact('owned-host-history-preservation', { ...historyEvidence, + observerWindow: { intervalMs: observerIntervalMs, controllerReattachment, startupFinished, firstObservationNotBefore, + holdRequestedAt, heldAt, releasedAt, resumedAt, targetSoulRecoveries } }) + } finally { + measurement.finallyEnteredAt = Date.now() + try { + if (heldHost) rig.signalOwnedSessionHostExact(heldHost.containerId, heldHost.incarnationId, 'SIGCONT', heldHost.pid) + } finally { + try { + // Resume the exact owned host before diagnostic extraction on failure. + if (cpuSession) { + try { + if (profilingStarted) { + measurement.cpuStopRequestedAt = Date.now() + const { profile } = await cpuSession.send('Profiler.stop') + measurement.cpuStoppedAt = Date.now() + const nodes = new Map(profile.nodes.map((node: any) => [node.id, node])) + const parents = new Map() + for (const node of profile.nodes) for (const child of node.children ?? []) parents.set(child, node.id) + const describeFrame = (id: number) => { + const frame = nodes.get(id)?.callFrame + return { functionName: String(frame?.functionName ?? '').replace(/[^\w .()<>:$-]/g, '').slice(0, 120), + script: scriptLabel(frame?.url ?? ''), line: frame?.lineNumber, column: frame?.columnNumber } + } + const totals = new Map() + const seconds = new Map() + let elapsedUs = 0 + for (let index = 0; index < (profile.samples?.length ?? 0); index += 1) { + const id = profile.samples[index] + const durationMs = (profile.timeDeltas?.[index] ?? 0) / 1000 + const total = totals.get(id) ?? { samples: 0, durationMs: 0 } + total.samples += 1 + total.durationMs += durationMs + totals.set(id, total) + const second = Math.floor(elapsedUs / 1_000_000) + const bucket = seconds.get(second) ?? { durationMs: 0, samples: 0, idleMs: 0 } + bucket.durationMs += durationMs + bucket.samples += 1 + if (nodes.get(id)?.callFrame.functionName === '(idle)') bucket.idleMs += durationMs + seconds.set(second, bucket) + elapsedUs += durationMs * 1000 + } + measurement.cpu = { startTimeUs: profile.startTime, endTimeUs: profile.endTime, + sampledDurationMs: elapsedUs / 1000, sampleCount: profile.samples?.length ?? 0, + // Sampling attributes each delta to its leaf, not exact function wall time. + topLeaves: [...totals.entries()].sort((a, b) => b[1].durationMs - a[1].durationMs).slice(0, 80) + .map(([id, total]) => { + const stack = [] + let parent = parents.get(id) + while (parent !== undefined && stack.length < 20) { + stack.push(describeFrame(parent)) + parent = parents.get(parent) + } + return { ...total, ...describeFrame(id), stack } + }), + seconds: [...seconds.entries()].map(([second, bucket]) => ({ second, ...bucket })) } + } + } catch { measurementErrors.push('owned CPU profile stop/extraction failed') } + finally { + try { await cpuSession.send('Profiler.disable') } catch { measurementErrors.push('owned CPU profiler disable failed') } + try { await cpuSession.detach() } catch { measurementErrors.push('owned CDP detach failed') } + } + try { + // Sanitize inside the owned page before returning audit data to Node. + measurement.audit = await page.evaluate((cutoffWallMs) => { + const snapshot = window.__FRESHELL_TEST_HARNESS__?.getPerfAuditSnapshot() + const routeClass = (raw: unknown) => { + if (typeof raw !== 'string') return undefined + let pathname: string + try { pathname = new URL(raw, location.origin).pathname } catch { return 'unknown-route' } + if (pathname.startsWith('/api/fresh-agent/threads/')) return 'native-thread-snapshot' + if (/^\/api\/runtime\/souls\/[^/]+\/history$/.test(pathname)) return 'owned-soul-history' + if (pathname.startsWith('/assets/')) return `asset:${pathname.split('/').at(-1)}` + return pathname.startsWith('/api/') ? 'other-api' : 'document-or-resource' + } + const requestRecords = (snapshot?.perfEvents ?? []).filter((entry) => entry.event === 'fresh_agent.snapshot_request') + const stages = ['refresh_requested', 'effect_skipped', 'request_captured', 'request_queued', 'run_started', + 'native_started', 'native_completed', 'native_failed', 'soul_started', 'outcome_received', + 'currentness_checked', 'identity_rejected', 'display_committed'] + const triggers = ['identity', 'event', 'send-accepted', 'materialized', 'manual', 'poll', 'reconnect', 'reveal', 'idle-incomplete'] + const allowed = { stage: stages, trigger: triggers, reason: ['no_thread', 'divergence', 'unmanaged_lost'], + check: ['native-error', 'before-soul', 'outcome', 'error-fold'], source: ['direct', 'fallback'], + fence: ['create', 'soul', 'revision', 'boot', 'owner', 'applied_serial', 'read_generation', 'recovery', + 'provider', 'session_type', 'thread', 'divergence'], errorKind: ['abort', 'error'], + outcome: ['ok', 'error', 'coalesced', 'rate-limited', 'backoff'] } + const numericFields = ['timestamp', 'view', 'requestSerial', 'readGeneration', 'refreshSerial', 'appliedSerial', + 'currentGeneration', 'previousRows', 'rows', 'revision'] + const booleanFields = ['requestReadOnly', 'hasSoul', 'ran', 'stale', 'historyOnly', 'live', 'vacant', 'accepted', + 'providerMatches', 'typeMatches', 'threadMatches', 'initialCapture', 'createChanged', 'soulChanged', + 'revisionChanged', 'bootChanged', 'ownerChanged', 'providerChanged', 'typeChanged', 'threadChanged', + 'paneSessionChanged', 'recoveryChanged', 'lostChanged', 'supervisorChanged', 'refreshChanged'] + return { timeOrigin: performance.timeOrigin, extractedAtMs: performance.now(), available: Boolean(snapshot), + cutoffWallMs, requestRecordCount: requestRecords.length, requestRecordsReturned: Math.min(2000, requestRecords.length), + requestRecordsDropped: Math.max(0, requestRecords.length - 2000), + requestRecords: requestRecords.slice(0, 2000).map((entry, sequence) => ({ sequence, + ...Object.fromEntries(Object.entries(allowed).filter(([key, values]) => values.includes(String(entry[key]))) + .map(([key]) => [key, entry[key]])), + ...Object.fromEntries(numericFields.filter((key) => typeof entry[key] === 'number' && Number.isFinite(entry[key])) + .map((key) => [key, entry[key]])), + ...Object.fromEntries(booleanFields.filter((key) => typeof entry[key] === 'boolean').map((key) => [key, entry[key]])) })), + // The existing sink has no event timestamp: retain sequence and measured durations only. + events: (snapshot?.perfEvents ?? []).flatMap((entry, sequence) => { + if (!['perf.api_slow', 'perf.api_parse_slow', 'perf.longtask', 'perf.resource_slow'].includes(String(entry.event))) return [] + const numeric = Object.fromEntries(['status', 'durationMs', 'ttfbMs', 'bodyMs', 'parseMs', 'payloadChars', + 'startTime', 'transferSize', 'encodedBodySize', 'decodedBodySize'] + .filter((key) => typeof entry[key] === 'number').map((key) => [key, entry[key]])) + return [{ sequence, event: entry.event, route: routeClass(entry.path ?? entry.name), ...numeric }] + }) } + }, Number(measurement.finallyEnteredAt)) + } catch { measurementErrors.push('owned perf audit extraction failed') } + rig.runtime.writeBrowserArtifact('saved-history-render-measurement', measurement) + rig.runtime.recordLifecycle('browser.saved_history_measurement.finished', { + cpuStartRequestedAt: measurement.cpuStartRequestedAt, cpuStartedAt: measurement.cpuStartedAt, + cpuStopRequestedAt: measurement.cpuStopRequestedAt, cpuStoppedAt: measurement.cpuStoppedAt, + errors: measurementErrors, + }) + } + } finally { + const cleanup = await rig.stop() + expect(cleanup.ok, cleanup.errors.join('\n')).toBe(true) + } + } + } + }) + test('P4-G08: controller inventory reconstructs views without duplicating souls', async ({ page }) => { test.setTimeout(900_000) @@ -236,6 +692,7 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { }, browserTabId) await expect.poll(() => harness.getActiveTabId()).toBe(browserTabId) const activePaneBefore = (await browserState(page)).panes.activePane[browserTabId] + expect(activePaneBefore).toEqual(expect.any(String)) const layoutStorageKey = await prunePersistedLayoutToTab(page, browserTabId) expect(layoutStorageKey.length).toBeGreaterThan(0) @@ -252,6 +709,11 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { ) expect(rehydrated.tabs.activeTabId).toBe(browserTabId) expect(rehydrated.panes.activePane[browserTabId]).toBe(activePaneBefore) + const focusObservations = [{ + stage: 'rehydration', + activeTabId: rehydrated.tabs.activeTabId, + activePaneId: rehydrated.panes.activePane[rehydrated.tabs.activeTabId] ?? null, + }] expect(visibleManagedTabs(rehydrated).map((tab: any) => tab.viewIntentId).sort()).toEqual( initialSnapshot.viewIntents.map((view: any) => view.viewId).sort(), ) @@ -278,6 +740,12 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { [browserTabId, ...expectedTabIds].sort(), ) expect(state.tabs.activeTabId).toBe(browserTabId) + expect(state.panes.activePane[browserTabId]).toBe(activePaneBefore) + focusObservations.push({ + stage: `web_restart_${cycle}`, + activeTabId: state.tabs.activeTabId, + activePaneId: state.panes.activePane[state.tabs.activeTabId] ?? null, + }) expect(new Set(visibleManagedTabs(state).map((tab: any) => tab.viewIntentId)).size).toBe(3) expect(visibleManagedTabs(state).map((tab: any) => tab.soulId).sort()).toEqual(initialSoulIds) } @@ -291,17 +759,18 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { expect(after?.terminalId).toBe(before.terminalId) } - const statusPanel = page.getByRole('complementary', { name: 'Managed agent recovery' }) - await expect(statusPanel).toBeVisible({ timeout: 30_000 }) - const panelDetails = statusPanel.locator(':scope > details') - const summary = panelDetails.locator(':scope > summary') - if (!(await panelDetails.evaluate((details: HTMLDetailsElement) => details.open))) { - await summary.click() - } - const closeSoulId = initialSoulIds[0] - const closeSection = statusPanel.getByRole('region').filter({ hasText: closeSoulId }) - await closeSection.getByRole('button', { name: 'Close view' }).click() + const stateBeforeClose = await browserState(page) + const closeTabId = stateBeforeClose.tabs.tabs.find((tab: any) => tab.soulId === closeSoulId)?.id + const closePaneId = expectedTabIds + .map((tabId: string) => stateBeforeClose.panes.layouts[tabId]) + .find((node: any) => node?.type === 'leaf' && node.content?.soulId === closeSoulId)?.id + expect(closeTabId).toEqual(expect.any(String)) + expect(closePaneId).toEqual(expect.any(String)) + // Ordinary pane close is the durable view intent action. It detaches the + // managed view after close evidence succeeds; it never stops the soul. + await page.locator(`[data-context="tab"][data-tab-id="${closeTabId}"]`).click() + await page.locator(`[data-pane-id="${closePaneId}"] button[title="Close pane"]`).click() const closeOutcome = await waitForValue('detached view with live soul', async () => { const snapshot = await rig.inventorySnapshot() const view = snapshot.viewIntents.find((candidate: any) => candidate.soulId === closeSoulId) @@ -315,10 +784,25 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { timeout: 30_000, }).toBe(2) + // Reconnect the browser to force the normal inventory refresh path. A + // detached view must remain absent after reconciliation; the live soul + // is intentionally not recreated as a replacement conversation. + const priorReadyAt = await harness.getLastReadyAt() + await rig.restartWebGracefully() + await harness.waitForConnectionAfter(priorReadyAt, 90_000) + await expect.poll(async () => visibleManagedTabs(await browserState(page)).length, { + timeout: 30_000, + }).toBe(2) + const afterCloseRefresh = await browserState(page) + expect(afterCloseRefresh.tabs.tabs.some((tab: any) => tab.soulId === closeSoulId)).toBe(false) + const stopSoulId = initialSoulIds[1] - const stopSection = statusPanel.getByRole('region').filter({ hasText: stopSoulId }) - page.once('dialog', (dialog) => void dialog.accept()) - await stopSection.getByRole('button', { name: 'Stop agent' }).click() + const stopTabId = afterCloseRefresh.tabs.tabs.find((tab: any) => tab.soulId === stopSoulId)?.id + expect(stopTabId).toEqual(expect.any(String)) + // Retain stop coverage through the existing terminal shift-close path. + await page.locator(`[data-context="tab"][data-tab-id="${stopTabId}"]`) + .getByRole('button', { name: /close/i }) + .click({ modifiers: ['Shift'] }) const stopOutcome = await waitForValue('explicitly stopped soul', async () => { const snapshot = await rig.inventorySnapshot() const soul = latestSoul(snapshot, stopSoulId) @@ -362,8 +846,8 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { candidateSha: rig.runtime.candidateSha, browser: { browserInteraction: true, - coldStartAgents: 3, - restartCycles: 3, + coldStartAgents: initialSoulIds.length, + restartCycles: focusObservations.length - 1, deterministicPlacement: recoveredPaneIds.join('|') === expectedPaneIds.join('|'), singleViewPerIntent: new Set( finalState.tabs.tabs @@ -371,7 +855,13 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { .filter(Boolean), ).size === finalState.tabs.tabs.filter((tab: any) => tab.viewIntentId).length, existingLayoutPreserved: Boolean(finalState.tabs.tabs.find((tab: any) => tab.id === browserTabId)), - focusStable: finalState.tabs.activeTabId === browserTabId, + // Only automatic restore/restart observations belong to this + // guarantee; the close/stop interactions deliberately move focus. + focusStable: focusObservations.every((observed) => ( + observed.activeTabId === browserTabId && observed.activePaneId === activePaneBefore + )), + expectedFocus: { activeTabId: browserTabId, activePaneId: activePaneBefore }, + focusObservations, sameSoulIds: initialSoulIds.every((soulId: string) => ( afterRestarts.souls.some((soul: any) => soul.soulId === soulId) )), @@ -381,6 +871,9 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { return after?.terminalId === before.terminalId }), closeViewKeepsAgent: closeOutcome.soul.launchState === 'running', + closeViewNotRecreatedAfterInventoryRefresh: afterCloseRefresh.tabs.tabs.every( + (tab: any) => tab.soulId !== closeSoulId, + ), stopAgentStopsRuntime: stopOutcome.soul.launchState === 'stopped', oldClientNoDuplicate: createdReply.terminalId === compatibilitySoul.terminalId && rig.runtime.broker.receipts().length === receiptCountBefore, @@ -391,6 +884,8 @@ test.describe.serial('Phase 4 managed-runtime tab rehydration', () => { createdTabIds: created.map((response) => response.tabId), }, } + expect(receipt.browser.focusObservations).toHaveLength(4) + expect(receipt.browser.focusStable).toBe(true) const receiptPath = rig.writePhase4BrowserReceipt(receipt) // eslint-disable-next-line no-console console.log(`[P4-G08] runtime tab rehydration receipt: ${receiptPath}`) diff --git a/test/fixtures/managed-native-history/claude.json b/test/fixtures/managed-native-history/claude.json new file mode 100644 index 000000000..268af3d25 --- /dev/null +++ b/test/fixtures/managed-native-history/claude.json @@ -0,0 +1,105 @@ +{ + "sessionType": "freshclaude", + "provider": "claude", + "threadId": "44444444-4444-4444-8444-444444444444", + "sessionId": "44444444-4444-4444-8444-444444444444", + "revision": 0, + "latestTurnId": "native-tool-result", + "status": "idle", + "capabilities": { + "send": false, + "interrupt": false, + "approvals": false, + "questions": false, + "fork": false, + "undo": false, + "redo": false, + "settingScopes": { + "model": "per-send", + "effort": "per-send", + "permissionMode": "per-send", + "sandbox": "unsupported" + } + }, + "tokenUsage": { + "inputTokens": 0, + "outputTokens": 0, + "totalTokens": 0 + }, + "pendingApprovals": [], + "pendingQuestions": [], + "worktrees": [], + "diffs": [], + "childThreads": [], + "turns": [ + { + "id": "native-user", + "turnId": "native-user", + "ordinal": 0, + "source": "durable", + "role": "user", + "timestamp": "2026-10-03T10:00:00Z", + "summary": "Saved native user prompt", + "summaryKind": "echo", + "items": [ + { + "id": "native-user-i0", + "kind": "text", + "text": "Saved native user prompt" + } + ] + }, + { + "id": "native-answer", + "turnId": "native-answer", + "messageId": "msg_native", + "ordinal": 1, + "source": "durable", + "role": "assistant", + "timestamp": "2026-10-03T10:00:01Z", + "summary": "Saved native Claude answer", + "summaryKind": "echo", + "items": [ + { + "id": "native-answer-i0", + "kind": "text", + "text": "Saved native Claude answer" + }, + { + "id": "native-answer-i1", + "kind": "tool_use", + "toolUseId": "toolu_native", + "name": "Bash", + "input": { + "command": "pwd" + } + } + ] + }, + { + "id": "native-tool-result", + "turnId": "native-tool-result", + "ordinal": 2, + "source": "durable", + "role": "user", + "timestamp": "2026-10-03T10:00:02Z", + "summary": "Tool result", + "summaryKind": "echo", + "items": [ + { + "id": "native-tool-result-i0", + "kind": "tool_result", + "toolUseId": "toolu_native", + "content": "/workspace", + "isError": false + } + ] + } + ], + "extensions": { + "claude": { + "ownerKind": "vacant", + "nativeHistoryAvailable": true + } + } +} diff --git a/test/fixtures/managed-native-history/claude.jsonl b/test/fixtures/managed-native-history/claude.jsonl new file mode 100644 index 000000000..f37f54040 --- /dev/null +++ b/test/fixtures/managed-native-history/claude.jsonl @@ -0,0 +1,3 @@ +{"type": "user", "uuid": "native-user", "sessionId": "44444444-4444-4444-8444-444444444444", "cwd": "/workspace", "timestamp": "2026-10-03T10:00:00Z", "message": {"role": "user", "content": [{"type": "text", "text": "Saved native user prompt"}]}} +{"type": "assistant", "uuid": "native-answer", "parentUuid": "native-user", "sessionId": "44444444-4444-4444-8444-444444444444", "timestamp": "2026-10-03T10:00:01Z", "message": {"id": "msg_native", "role": "assistant", "content": [{"type": "text", "text": "Saved native Claude answer"}, {"type": "tool_use", "id": "toolu_native", "name": "Bash", "input": {"command": "pwd"}}]}} +{"type": "user", "uuid": "native-tool-result", "parentUuid": "native-answer", "sessionId": "44444444-4444-4444-8444-444444444444", "timestamp": "2026-10-03T10:00:02Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_native", "content": "/workspace", "is_error": false}]}} diff --git a/test/fixtures/managed-native-history/codex-tools.json b/test/fixtures/managed-native-history/codex-tools.json new file mode 100644 index 000000000..3cb910f2d --- /dev/null +++ b/test/fixtures/managed-native-history/codex-tools.json @@ -0,0 +1,142 @@ +{ + "sessionType": "freshcodex", + "provider": "codex", + "threadId": "rich-tools", + "revision": 0, + "status": "idle", + "summary": "", + "capabilities": { + "send": false, + "interrupt": false, + "approvals": false, + "questions": false, + "fork": false, + "worktrees": false, + "diffs": false, + "childThreads": false, + "undo": false, + "redo": false, + "settingScopes": { + "model": "per-send", + "effort": "per-send", + "sandbox": "per-send", + "permissionMode": "per-send" + } + }, + "tokenUsage": { + "inputTokens": 0, + "outputTokens": 0, + "cachedTokens": 0, + "totalTokens": 0 + }, + "pendingApprovals": [], + "pendingQuestions": [], + "worktrees": [], + "diffs": [], + "childThreads": [], + "turns": [ + { + "id": "native-turn:row-0", + "turnId": "native-turn:row-0", + "ordinal": 0, + "source": "durable", + "role": "user", + "summary": "Saved coding request", + "summaryKind": "echo", + "items": [ + { + "id": "user-native:part:0", + "kind": "text", + "text": "Saved coding request" + } + ] + }, + { + "id": "native-turn:row-1", + "turnId": "native-turn:row-1", + "ordinal": 1, + "source": "durable", + "role": "tool", + "summary": "apply_patch", + "summaryKind": "echo", + "items": [ + { + "id": "custom-1", + "kind": "dynamic_tool", + "namespace": null, + "tool": "apply_patch", + "status": "completed", + "arguments": "*** Begin Patch\n*** End Patch", + "contentItems": [ + { + "type": "inputText", + "text": "Patch saved" + } + ], + "success": true + }, + { + "id": "search-1", + "kind": "web_search", + "query": "SQLite WAL", + "action": { + "type": "search", + "query": "SQLite WAL" + } + }, + { + "id": "image-1", + "kind": "image_generation", + "status": "completed", + "revisedPrompt": "diagram", + "result": "saved-image" + }, + { + "id": "command-1", + "kind": "command", + "command": "pwd", + "status": "completed", + "output": "/workspace", + "exitCode": 0, + "extensions": { + "codex": { + "type": "commandExecution", + "id": "command-1", + "command": "pwd", + "cwd": "/workspace", + "status": "completed", + "aggregatedOutput": "/workspace", + "exitCode": 0 + } + }, + "cwd": "/workspace" + }, + { + "id": "mcp-1", + "kind": "mcp_tool", + "server": "fixture", + "tool": "lookup", + "status": "completed", + "arguments": { + "query": "saved" + }, + "result": { + "content": [ + { + "type": "text", + "text": "MCP saved result" + } + ] + }, + "error": null + } + ] + } + ], + "extensions": { + "codex": { + "ownerKind": "vacant", + "nativeHistoryAvailable": true + } + } +} diff --git a/test/fixtures/managed-native-history/codex-tools.jsonl b/test/fixtures/managed-native-history/codex-tools.jsonl new file mode 100644 index 000000000..3d69910ae --- /dev/null +++ b/test/fixtures/managed-native-history/codex-tools.jsonl @@ -0,0 +1,10 @@ +{"type": "session_meta", "payload": {"id": "rich-tools", "cwd": "/workspace", "history_mode": "paginated"}} +{"type": "turn_context", "payload": {"turn_id": "native-turn"}} +{"type": "response_item", "payload": {"type": "message", "id": "user-native", "role": "user", "content": [{"type": "input_text", "text": "Saved coding request"}]}} +{"type": "response_item", "payload": {"type": "custom_tool_call", "id": "response-custom", "call_id": "custom-1", "name": "apply_patch", "input": "*** Begin Patch\n*** End Patch"}} +{"type": "response_item", "payload": {"type": "custom_tool_call_output", "call_id": "custom-1", "output": [{"type": "input_text", "text": "Patch saved"}]}} +{"type": "response_item", "payload": {"type": "web_search_call", "id": "search-1", "status": "completed", "action": {"type": "search", "query": "SQLite WAL"}}} +{"type": "response_item", "payload": {"type": "image_generation_call", "id": "image-1", "status": "completed", "result": "saved-image", "revised_prompt": "diagram"}} +{"type": "event_msg", "payload": {"type": "item_completed", "turn_id": "native-turn", "item": {"type": "CommandExecution", "id": "command-1", "command": ["pwd"], "cwd": "/workspace", "status": "completed", "aggregated_output": "/workspace", "exit_code": 0}}} +{"type": "event_msg", "payload": {"type": "item_completed", "turn_id": "native-turn", "item": {"type": "DynamicToolCall", "id": "custom-1", "tool": "apply_patch", "arguments": "*** Begin Patch\n*** End Patch", "status": "completed", "content_items": [{"type": "inputText", "text": "Patch saved"}], "success": true}}} +{"type": "event_msg", "payload": {"type": "item_completed", "turn_id": "native-turn", "item": {"type": "McpToolCall", "id": "mcp-1", "server": "fixture", "tool": "lookup", "arguments": {"query": "saved"}, "status": "completed", "result": {"content": [{"type": "text", "text": "MCP saved result"}]}}}} diff --git a/test/fixtures/managed-native-history/codex.json b/test/fixtures/managed-native-history/codex.json new file mode 100644 index 000000000..c2a74606c --- /dev/null +++ b/test/fixtures/managed-native-history/codex.json @@ -0,0 +1,77 @@ +{ + "sessionType": "freshcodex", + "provider": "codex", + "threadId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "revision": 0, + "status": "idle", + "summary": "", + "capabilities": { + "send": false, + "interrupt": false, + "approvals": false, + "questions": false, + "fork": false, + "worktrees": false, + "diffs": false, + "childThreads": false, + "undo": false, + "redo": false, + "settingScopes": { + "model": "per-send", + "effort": "per-send", + "sandbox": "per-send", + "permissionMode": "per-send" + } + }, + "tokenUsage": { + "inputTokens": 0, + "outputTokens": 0, + "cachedTokens": 0, + "totalTokens": 0 + }, + "pendingApprovals": [], + "pendingQuestions": [], + "worktrees": [], + "diffs": [], + "childThreads": [], + "turns": [ + { + "id": "persisted-turn:row-0", + "turnId": "persisted-turn:row-0", + "ordinal": 0, + "source": "durable", + "role": "user", + "summary": "Saved native user prompt", + "summaryKind": "echo", + "items": [ + { + "id": "native-line-2:part:0", + "kind": "text", + "text": "Saved native user prompt" + } + ] + }, + { + "id": "persisted-turn:row-1", + "turnId": "persisted-turn:row-1", + "ordinal": 1, + "source": "durable", + "role": "assistant", + "summary": "Saved native Codex answer", + "summaryKind": "echo", + "items": [ + { + "id": "native-line-3", + "kind": "text", + "text": "Saved native Codex answer" + } + ] + } + ], + "extensions": { + "codex": { + "ownerKind": "vacant", + "nativeHistoryAvailable": true + } + } +} diff --git a/test/fixtures/managed-native-history/opencode.json b/test/fixtures/managed-native-history/opencode.json new file mode 100644 index 000000000..fa7f70704 --- /dev/null +++ b/test/fixtures/managed-native-history/opencode.json @@ -0,0 +1,80 @@ +{ + "sessionType": "freshopencode", + "provider": "opencode", + "threadId": "ses_saved", + "sessionId": "ses_saved", + "revision": 2, + "latestTurnId": "2", + "status": "idle", + "summary": "Saved", + "capabilities": { + "send": false, + "interrupt": false, + "approvals": false, + "questions": false, + "fork": false, + "worktrees": false, + "diffs": false, + "childThreads": false, + "undo": false, + "redo": false, + "settingScopes": { + "model": "per-send", + "effort": "per-send", + "sandbox": "unsupported", + "permissionMode": "unsupported" + } + }, + "tokenUsage": { + "inputTokens": 0, + "outputTokens": 0, + "totalTokens": 0 + }, + "pendingApprovals": [], + "pendingQuestions": [], + "worktrees": [], + "diffs": [], + "childThreads": [], + "turns": [ + { + "id": "1", + "turnId": "1", + "messageId": "1", + "ordinal": 0, + "source": "durable", + "role": "user", + "summary": "Saved native user prompt", + "summaryKind": "echo", + "items": [ + { + "id": "1", + "kind": "text", + "text": "Saved native user prompt" + } + ] + }, + { + "id": "2", + "turnId": "2", + "messageId": "2", + "ordinal": 1, + "source": "durable", + "role": "assistant", + "summary": "Saved native OpenCode answer", + "summaryKind": "echo", + "items": [ + { + "id": "2", + "kind": "text", + "text": "Saved native OpenCode answer" + } + ] + } + ], + "extensions": { + "opencode": { + "ownerKind": "vacant", + "nativeHistoryAvailable": true + } + } +} diff --git a/test/fixtures/managed-runtime/lost-fresh-agent-inventory.json b/test/fixtures/managed-runtime/lost-fresh-agent-inventory.json new file mode 100644 index 000000000..517b91661 --- /dev/null +++ b/test/fixtures/managed-runtime/lost-fresh-agent-inventory.json @@ -0,0 +1,51 @@ +{ + "revision": 9, + "readiness": { + "inventoryRevision": 9, + "initialScanState": "pending", + "blockedSubsystems": [], + "startupRecoveryConcurrencyLimit": 4, + "startupRecoveryPeak": 0 + }, + "souls": [ + { + "soulId": "soul-3be9a784-7aab-4d00-8007-796d9779c5bc", + "incarnationId": "incarnation-52d15ab6-78cc-4117-9e1a-36afb13a4a4c", + "launchState": "stopping", + "cleanupState": "termination_unconfirmed", + "intentRevision": 2, + "containerId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "hostBootId": "hostboot-252cb8b4-081c-4c44-b6b4-347e8a729897", + "executionGeneration": 1, + "effectiveLimits": { + "cpuMilli": 500, + "memoryBytes": 67108864, + "swapBytes": 0, + "pidsMax": 32 + }, + "configuredLimits": { + "cpuMilli": 500, + "memoryBytes": 67108864, + "swapBytes": 0, + "pidsMax": 32 + }, + "freshAgentSessionId": "fresh-retained-thread", + "freshAgentSessionType": "freshopencode", + "freshAgentRuntimeVariant": "opencode", + "projectKey": "workspace", + "profile": "custom", + "desiredState": "stopped", + "recoveryState": "lost", + "durabilityState": "unknown", + "allocationState": "allocated", + "provider": "opencode", + "nativeSessionId": "retained-thread", + "recoveryReason": "LOSS_CERTIFIED:incident-69063dbc-7e17-4417-9029-d4d6ec93b1c8", + "incidentId": "incident-69063dbc-7e17-4417-9029-d4d6ec93b1c8", + "evidenceRevision": 0, + "successfulRecoveriesInWindow": 0 + } + ], + "viewIntents": [], + "pendingProjectionCount": 0 +} diff --git a/test/runtime/gates/phase-4.test.ts b/test/runtime/gates/phase-4.test.ts index 21bb39724..4f51a8f4d 100644 --- a/test/runtime/gates/phase-4.test.ts +++ b/test/runtime/gates/phase-4.test.ts @@ -275,17 +275,8 @@ async function gate05StoppedHistoryRetained(h: RuntimeHarness): Promise { } async function gate06StatusAndAccessibility(h: RuntimeHarness): Promise { - const caseId = 'P4-G06' - runFocusedNodeTest(h, 'test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx') - const source = fs.readFileSync( - path.join(h.repoRoot, 'src/components/ManagedAgentRecoveryStatus.tsx'), - 'utf8', - ) - for (const label of ['Reconnecting', 'Restarting agent', 'Recovery blocked', 'Ready', 'Stopped']) { - h.assert(caseId, source.includes(`'${label}'`), `UI has distinct ${label} state`, label) - } - h.assert(caseId, source.includes('aria-label="Managed agent recovery"') && source.includes('role="alert"'), 'recovery surface exposes semantic labels and assertive errors') - h.assert(caseId, source.includes('Retry recovery') && source.includes('Close view') && source.includes('Stop agent'), 'recovery actions are keyboard-native buttons with distinct labels') + runFocusedNodeTest(h, 'test/unit/client/components/ManagedRuntimeNotices.test.tsx') + runFocusedNodeTest(h, 'test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx') } async function gate07ResourceLimits(h: RuntimeHarness): Promise { diff --git a/test/runtime/gates/phase-5.test.ts b/test/runtime/gates/phase-5.test.ts index 80952f806..867beb44f 100644 --- a/test/runtime/gates/phase-5.test.ts +++ b/test/runtime/gates/phase-5.test.ts @@ -231,7 +231,8 @@ async function gate02GenuineLossCertificateAndCleanup(h: RuntimeHarness): Promis const real = receipt.lossValidation h.assert(caseId, real.provider === 'opencode', 'real isolated OpenCode loss ran through the browser evidence builder', real) h.assert(caseId, real.exactCleanupVerified === true && real.foreignObjectsTouched === 0, 'hashed incident and broker evidence prove exact isolated cleanup', real) - h.assert(caseId, real.displayedNoticeCount === 1, 'hashed browser evidence proves exactly one truthful notice', real) + h.assert(caseId, real.routineNoticeCount === 0 && real.actionableRecoveryCardCount === 1, + 'hashed browser evidence proves routine popups stay hidden and the exact lost pane offers one recovery decision', real) } async function gate03EveryRecoverableAlternativeWins(h: RuntimeHarness): Promise { diff --git a/test/unit/architecture/rust-only-server-runtime.test.ts b/test/unit/architecture/rust-only-server-runtime.test.ts index 471d57389..95aaf1bdc 100644 --- a/test/unit/architecture/rust-only-server-runtime.test.ts +++ b/test/unit/architecture/rust-only-server-runtime.test.ts @@ -645,15 +645,3 @@ describe('runtime boundary analyzer', () => { expect(result.unexpectedNodeBackend).toEqual(['scripts/misclassified.ts']) }) }) - -describe('runtime boundary inventory for the current checkout', () => { - it('requires executable runtime evidence to be fully Rust-only', async () => { - const result = await analyzeRuntimeBoundary(process.cwd()) - - expect(result).toEqual({ - manifestDrift: [], - legacyDebt: [], - unexpectedNodeBackend: [], - }) - }) -}) diff --git a/test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx b/test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx deleted file mode 100644 index 0f979508d..000000000 --- a/test/unit/client/components/ManagedAgentRecoveryStatus.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { configureStore } from '@reduxjs/toolkit' -import { Provider } from 'react-redux' -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import connectionReducer from '@/store/connectionSlice' -import managedRuntimeReducer from '@/store/managedRuntimeSlice' -import tabsReducer from '@/store/tabsSlice' -import panesReducer from '@/store/panesSlice' -import { - ManagedAgentRecoveryStatus, - managedAgentStatusLabel, -} from '@/components/ManagedAgentRecoveryStatus' -import { AgentResourceLimits } from '@/components/AgentResourceLimits' -import type { ManagedRuntimeSoul } from '@shared/managed-runtime' -import { - getManagedRuntimeSoul, - retryManagedRuntimeSoul, - stopManagedRuntimeSoul, - updateManagedRuntimeLimits, -} from '@/lib/api' - -const apiMocks = vi.hoisted(() => ({ - getManagedRuntimeSoul: vi.fn(), - retryManagedRuntimeSoul: vi.fn(), - stopManagedRuntimeSoul: vi.fn(), - updateManagedRuntimeLimits: vi.fn(), - updateManagedRuntimeViewVisibility: vi.fn(), - getManagedRuntimeIncidentSummary: vi.fn(), -})) - -vi.mock('@/lib/api', async (importOriginal) => { - const original = await importOriginal() - return { ...original, ...apiMocks } -}) - -const refreshMocks = vi.hoisted(() => ({ - queueManagedRuntimeRefresh: vi.fn(async () => undefined), -})) -vi.mock('@/lib/recovery/managed-runtime-recovery', async (importOriginal) => { - const original = await importOriginal() - return { ...original, ...refreshMocks } -}) - -function soul(overrides: Partial = {}): ManagedRuntimeSoul { - return { - soulId: 'soul-one', - incarnationId: 'incarnation-one', - launchState: 'stopped', - cleanupState: 'verified_empty', - intentRevision: 7, - executionGeneration: 1, - effectiveLimits: { cpuMilli: 500, memoryBytes: 256 * 1024 * 1024, swapBytes: 0, pidsMax: 64 }, - configuredLimits: { cpuMilli: 1_000, memoryBytes: 512 * 1024 * 1024, swapBytes: 0, pidsMax: 128 }, - viewIntentRevision: 3, - terminalId: 'terminal-one', - terminalStreamId: 'stream-one', - terminalMode: 'opencode', - terminalCwd: '/workspace', - terminalCreateRequestId: 'create-one', - terminalResumeSessionId: 'ses_one', - projectKey: 'workspace-one', - profile: 'default_agent', - desiredState: 'running', - recoveryState: 'blocked', - recoveryReason: 'CREDENTIALS_EXPIRED', - durabilityState: 'resume_captured', - allocationState: 'verified_durable', - provider: 'opencode', - nativeSessionId: 'ses_one', - recoveryAttemptId: 'recovery-one', - evidenceRevision: 2, - successfulRecoveriesInWindow: 0, - ...overrides, - } -} - -function renderStatus(currentSoul = soul()) { - const store = configureStore({ - reducer: { - connection: connectionReducer, - managedRuntime: managedRuntimeReducer, - tabs: tabsReducer, - panes: panesReducer, - }, - preloadedState: { - connection: { status: 'ready' }, - managedRuntime: { - available: true, - status: 'ready', - revision: 12, - readiness: { - inventoryRevision: 12, - initialScanState: 'complete', - blockedSubsystems: [], - startupRecoveryConcurrencyLimit: 4, - startupRecoveryPeak: 2, - }, - souls: [currentSoul], - viewIntents: [{ - viewId: 'view-one', - soulId: currentSoul.soulId, - ownerId: 'owner-one', - workspaceId: 'workspace-one', - kind: 'automatic_primary', - preferredTabId: 'tab-one', - preferredPaneId: 'pane-one', - title: 'Recovered OpenCode agent', - placementGroup: 'Recovered agents', - visibility: 'visible', - revision: 3, - soulIntentRevision: currentSoul.intentRevision, - createdAt: 1, - updatedAt: 2, - }], - pendingProjectionCount: 0, - reconstructedViewCount: 1, - }, - tabs: { - tabs: [], - activeTabId: null, - renameRequestTabId: null, - tombstones: [], - }, - panes: { - layouts: {}, - activePane: {}, - paneTitles: {}, - paneTitleSetByUser: {}, - renameRequestTabId: null, - renameRequestPaneId: null, - zoomedPane: {}, - closingTabs: {}, - closingPanes: {}, - refreshRequests: {}, - restoreFallbackAttemptsByPane: {}, - deadSessionAdjudication: [], - reconcilePendingPanes: {}, - }, - } as any, - }) - render( - - - , - ) - return store -} - -describe('managed agent recovery status', () => { - beforeEach(() => { - vi.clearAllMocks() - apiMocks.getManagedRuntimeSoul.mockResolvedValue({ - revision: 12, - readiness: { - inventoryRevision: 12, - initialScanState: 'complete', - blockedSubsystems: [], - startupRecoveryConcurrencyLimit: 4, - startupRecoveryPeak: 2, - }, - soul: soul(), - viewIntents: [], - actualUsage: { - cpuUsageUsec: 2_000_000, - cpuThrottledUsec: 0, - cpuNrThrottled: 0, - memoryCurrentBytes: 128 * 1024 * 1024, - memoryPeakBytes: 160 * 1024 * 1024, - memoryOom: 0, - memoryOomKill: 0, - pidsCurrent: 9, - pidsMax: 64, - }, - }) - apiMocks.retryManagedRuntimeSoul.mockResolvedValue({}) - apiMocks.stopManagedRuntimeSoul.mockResolvedValue({}) - apiMocks.getManagedRuntimeIncidentSummary.mockResolvedValue({ - incidentId: 'incident-one', - correlationId: 'correlation-one', - soulId: 'soul-one', - provider: 'opencode', - state: 'closed', - reasonCode: 'all_applicable_recovery_paths_definitively_unavailable', - observedCause: 'provider store missing', - cleanup: { - ownedHandleRef: 'registry://incarnation-one', - ownershipVerified: true, - gracefulAttempt: 'not_required', - forcedAttempt: 'not_required', - verifiedEmpty: true, - verifiedAt: '2026-09-08T00:00:00.000Z', - foreignObjectsTouched: 0, - }, - createdAt: '2026-09-08T00:00:00.000Z', - updatedAt: '2026-09-08T00:00:01.000Z', - }) - apiMocks.updateManagedRuntimeLimits.mockResolvedValue({ - view: soul({ intentRevision: 8 }), - application: 'next_incarnation', - configuredLimits: { cpuMilli: 1_500, memoryBytes: 768 * 1024 * 1024, swapBytes: 0, pidsMax: 160 }, - effectiveLimits: soul().effectiveLimits, - }) - }) - - afterEach(cleanup) - - it('uses explicit lifecycle labels including certified loss', () => { - expect(managedAgentStatusLabel('connecting', soul())).toBe('Reconnecting') - expect(managedAgentStatusLabel('ready', soul({ recoveryState: 'recovering' }))).toBe('Restarting agent') - expect(managedAgentStatusLabel('ready', soul())).toBe('Recovery blocked') - expect(managedAgentStatusLabel('ready', soul({ - recoveryState: 'lost', - desiredState: 'stopped', - incidentId: 'incident-one', - }))).toBe('Lost') - expect(managedAgentStatusLabel('ready', soul({ - launchState: 'running', - recoveryState: 'live', - }))).toBe('Ready') - expect(managedAgentStatusLabel('ready', soul({ - desiredState: 'stopped', - recoveryState: 'stopped', - }))).toBe('Stopped') - }) - - it('shows blocked identity and keeps retry, close-view, and stop-agent actions distinct', async () => { - renderStatus() - expect(screen.getByRole('complementary', { name: 'Managed agent recovery' })).toBeVisible() - expect(screen.getByText('Recovery blocked')).toBeVisible() - expect(screen.getByText(/CREDENTIALS_EXPIRED/)).toBeVisible() - expect(screen.getByRole('button', { name: 'Retry recovery' })).toBeVisible() - expect(screen.getByRole('button', { name: 'Close view' })).toBeVisible() - expect(screen.getByRole('button', { name: 'Stop agent' })).toBeVisible() - - await userEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) - await waitFor(() => { - expect(apiMocks.retryManagedRuntimeSoul).toHaveBeenCalledWith('soul-one', 7) - }) - expect(apiMocks.stopManagedRuntimeSoul).not.toHaveBeenCalled() - }) - - it('shows certified loss without offering retry and opens its incident summary', async () => { - renderStatus(soul({ - recoveryState: 'lost', - desiredState: 'stopped', - recoveryReason: 'LOSS_CERTIFIED:incident-one', - incidentId: 'incident-one', - })) - expect(screen.getByText('Lost')).toBeVisible() - expect(screen.queryByRole('button', { name: 'Retry recovery' })).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'View incident details' })) - await waitFor(() => { - expect(apiMocks.getManagedRuntimeIncidentSummary).toHaveBeenCalledWith('incident-one') - }) - expect(await screen.findByText(/provider store missing/)).toBeVisible() - }) - - it('exposes configured, effective, and actual resources separately', async () => { - renderStatus() - await userEvent.click(screen.getByText('Resource limits and usage')) - expect(screen.getByText(/Configured:/)).toBeVisible() - expect(screen.getByText(/Effective:/)).toBeVisible() - await waitFor(() => expect(screen.getByText(/Actual:/)).toBeVisible()) - await waitFor(() => expect(screen.getByText(/128 MiB memory/)).toBeVisible()) - }) -}) - -describe('AgentResourceLimits', () => { - afterEach(cleanup) - - it('submits validated limits and announces next-incarnation policy', async () => { - apiMocks.getManagedRuntimeSoul.mockResolvedValue({ - revision: 12, - readiness: { - inventoryRevision: 12, - initialScanState: 'complete', - blockedSubsystems: [], - startupRecoveryConcurrencyLimit: 4, - startupRecoveryPeak: 2, - }, - soul: soul(), - viewIntents: [], - actualUsage: null, - }) - apiMocks.updateManagedRuntimeLimits.mockResolvedValue({ - view: soul({ intentRevision: 8 }), - application: 'next_incarnation', - configuredLimits: { - cpuMilli: 1_500, - memoryBytes: 768 * 1024 * 1024, - swapBytes: 0, - pidsMax: 160, - }, - effectiveLimits: soul().effectiveLimits, - }) - render() - await userEvent.click(screen.getByText('Resource limits and usage')) - const cpu = screen.getByLabelText('CPU (millicores)') - await userEvent.clear(cpu) - await userEvent.type(cpu, '1500') - await userEvent.click(screen.getByRole('button', { name: 'Save limits' })) - await waitFor(() => { - expect(apiMocks.updateManagedRuntimeLimits).toHaveBeenCalledWith( - 'soul-one', - 7, - expect.objectContaining({ cpuMilli: 1500 }), - ) - }) - expect(await screen.findByRole('status')).toHaveTextContent( - 'They will apply to the next agent incarnation', - ) - }) -}) diff --git a/test/unit/client/components/ManagedRuntimeNotices.test.tsx b/test/unit/client/components/ManagedRuntimeNotices.test.tsx index 35a8ef907..14e3be2ef 100644 --- a/test/unit/client/components/ManagedRuntimeNotices.test.tsx +++ b/test/unit/client/components/ManagedRuntimeNotices.test.tsx @@ -1,6 +1,7 @@ import { configureStore } from '@reduxjs/toolkit' +import { Profiler, type ProfilerOnRenderCallback } from 'react' import { Provider } from 'react-redux' -import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,11 +9,12 @@ import connectionReducer from '@/store/connectionSlice' import managedRuntimeReducer from '@/store/managedRuntimeSlice' import tabRegistryReducer from '@/store/tabRegistrySlice' import { - MANAGED_RUNTIME_NOTICE_AUTO_ACK_MS, MANAGED_RUNTIME_NOTICE_POLL_MS, ManagedRuntimeNotices, noticeProfileId, } from '@/components/ManagedRuntimeNotices' +import type { ManagedRuntimeIncidentSummary, ManagedRuntimeNotice } from '@shared/managed-runtime' +import { ApiError } from '@/lib/api' const apiMocks = vi.hoisted(() => ({ getManagedRuntimeNotices: vi.fn(), @@ -25,6 +27,27 @@ vi.mock('@/lib/api', async (importOriginal) => { return { ...original, ...apiMocks } }) +const incidentSummary: ManagedRuntimeIncidentSummary = { + incidentId: 'incident-one', + correlationId: 'correlation-one', + soulId: 'soul-one', + provider: 'opencode', + state: 'closed', + reasonCode: 'all_applicable_recovery_paths_definitively_unavailable', + observedCause: 'provider state was missing', + cleanup: { + ownedHandleRef: 'registry://incarnation-one', + ownershipVerified: true, + gracefulAttempt: 'not_required', + forcedAttempt: 'not_required', + verifiedEmpty: true, + verifiedAt: '2026-09-08T00:00:00.000Z', + foreignObjectsTouched: 0, + }, + createdAt: '2026-09-08T00:00:00.000Z', + updatedAt: '2026-09-08T00:00:01.000Z', +} + function runtimeState() { return { available: true, @@ -37,7 +60,7 @@ function runtimeState() { } } -function renderNotices() { +function renderNotices(onRender?: ProfilerOnRenderCallback) { const store = configureStore({ reducer: { connection: connectionReducer, @@ -61,108 +84,146 @@ function renderNotices() { }) render( - + {onRender ? ( + + + + ) : } , ) return store } +function cleanupFailure(noticeId: string): ManagedRuntimeNotice { + return { + noticeId, + kind: 'cleanup_failed', + message: `Cleanup needs attention: ${noticeId}`, + reference: noticeId, + incidentIds: ['incident-one'], + deliveryState: 'pending', + createdAt: '2026-09-08T00:00:00.000Z', + } +} + +async function renderPollingNotices(notices: ManagedRuntimeNotice[], onRender?: ProfilerOnRenderCallback) { + vi.useFakeTimers() + apiMocks.getManagedRuntimeNotices.mockResolvedValue(notices) + await act(async () => { renderNotices(onRender) }) +} + +async function pollNotices() { + await act(async () => { await vi.advanceTimersByTimeAsync(MANAGED_RUNTIME_NOTICE_POLL_MS) }) +} + +async function clickNoticeButton(name: 'Details' | 'Dismiss') { + await act(async () => { fireEvent.click(screen.getByRole('button', { name })) }) +} + +function deferredDetails() { + let resolve!: (value: ManagedRuntimeIncidentSummary) => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + return { promise, resolve, reject } +} + describe('ManagedRuntimeNotices', () => { beforeEach(() => { vi.useRealTimers() vi.clearAllMocks() apiMocks.recordManagedRuntimeNoticeReceipt.mockResolvedValue(undefined) - apiMocks.getManagedRuntimeIncidentSummary.mockResolvedValue({ - incidentId: 'incident-one', - correlationId: 'correlation-one', - soulId: 'soul-one', - provider: 'opencode', - state: 'closed', - reasonCode: 'all_applicable_recovery_paths_definitively_unavailable', - observedCause: 'provider state was missing', - cleanup: { - ownedHandleRef: 'registry://incarnation-one', - ownershipVerified: true, - gracefulAttempt: 'not_required', - forcedAttempt: 'not_required', - verifiedEmpty: true, - verifiedAt: '2026-09-08T00:00:00.000Z', - foreignObjectsTouched: 0, - }, - createdAt: '2026-09-08T00:00:00.000Z', - updatedAt: '2026-09-08T00:00:01.000Z', - }) + apiMocks.getManagedRuntimeIncidentSummary.mockResolvedValue(incidentSummary) }) afterEach(() => { cleanup() vi.useRealTimers() + vi.restoreAllMocks() }) - it('marks a stable per-profile notice rendered and records explicit dismissal', async () => { + it('logs a background fetch failure without showing a decisionless popup', async () => { + const cause = new Error('Notices service refused the request') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + apiMocks.getManagedRuntimeNotices.mockRejectedValue(cause) + await act(async () => { renderNotices() }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByText(cause.message)).not.toBeInTheDocument() + expect(warn).toHaveBeenCalledWith('[ManagedRuntimeNotices]', expect.objectContaining({ + event: 'managed_runtime_notices_fetch_failed', profileId: noticeProfileId('device-notice-test'), err: cause, + })) + }) + + it('keeps actionable cleanup context and action errors when background polling fails', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await renderPollingNotices([cleanupFailure('notice-one')]) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + apiMocks.getManagedRuntimeIncidentSummary.mockRejectedValue(new Error('Details request refused')) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('Details request refused') + apiMocks.getManagedRuntimeNotices.mockRejectedValue(new Error('Background notices request refused')) + await pollNotices() + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('Cleanup needs attention: notice-one') + expect(alert).toHaveTextContent('provider state was missing') + expect(alert).toHaveTextContent('Details request refused') + expect(alert).not.toHaveTextContent('Background notices request refused') + expect(screen.getByRole('button', { name: 'Details' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible() + expect(warn).toHaveBeenCalledWith('[ManagedRuntimeNotices]', expect.objectContaining({ event: 'managed_runtime_notices_fetch_failed' })) + }) + + it('keeps expected background unavailability quiet during server recovery', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + apiMocks.getManagedRuntimeNotices.mockRejectedValue(new ApiError(503, 'Server is restarting')) + await act(async () => { renderNotices() }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(warn).not.toHaveBeenCalled() + }) + + it('silently acknowledges routine notices and leaves no popup behind', async () => { apiMocks.getManagedRuntimeNotices.mockResolvedValue([{ noticeId: 'notice-one', kind: 'cleanup_succeeded', - message: 'Found and cleaned up 1 lost agent process. Details are in the server logs. Reference: ABCD1234.', - reference: 'ABCD1234', + message: 'Found and cleaned up 1 lost agent process.', + reference: 'SUCCESS01', incidentIds: ['incident-one'], deliveryState: 'pending', createdAt: '2026-09-08T00:00:00.000Z', + }, { + noticeId: 'notice-two', + kind: 'ended_without_process', + message: 'The managed agent ended without a running process.', + reference: 'ENDED001', + incidentIds: [], + deliveryState: 'pending', + createdAt: '2026-09-08T00:00:01.000Z', }]) renderNotices() - expect(await screen.findByRole('status')).toHaveTextContent('Found and cleaned up 1 lost agent process') await waitFor(() => { expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( 'notice-one', noticeProfileId('device-notice-test'), - 'rendered', + 'acknowledged', ) - }) - await userEvent.click(screen.getByRole('button', { name: 'Details' })) - expect(await screen.findByText(/provider state was missing/)).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) - await waitFor(() => { expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( - 'notice-one', + 'notice-two', noticeProfileId('device-notice-test'), - 'dismissed', + 'acknowledged', ) }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText('Managed agent recovery')).not.toBeInTheDocument() + expect(screen.queryByText(/Resource limits and usage/i)).not.toBeInTheDocument() + expect(screen.queryByText('soul-one')).not.toBeInTheDocument() }) - it('poll refreshes do not postpone the stable notice auto-ack deadline', async () => { - vi.useFakeTimers() - apiMocks.getManagedRuntimeNotices.mockImplementation(async () => [{ - noticeId: 'notice-stable', - kind: 'cleanup_succeeded', - message: 'Found and cleaned up 1 lost agent process. Details are in the server logs. Reference: STABLE01.', - reference: 'STABLE01', - incidentIds: ['incident-one'], - deliveryState: 'rendered', - createdAt: '2026-09-08T00:00:00.000Z', - }]) - renderNotices() - await act(async () => { - await Promise.resolve() - await Promise.resolve() - }) - for (let elapsed = 0; elapsed < MANAGED_RUNTIME_NOTICE_AUTO_ACK_MS; elapsed += MANAGED_RUNTIME_NOTICE_POLL_MS) { - await act(async () => { - await vi.advanceTimersByTimeAsync(MANAGED_RUNTIME_NOTICE_POLL_MS) - }) - } - expect(apiMocks.getManagedRuntimeNotices.mock.calls.length).toBeGreaterThan(2) - expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( - 'notice-stable', - noticeProfileId('device-notice-test'), - 'acknowledged', - ) - }) - - it('auto-acknowledges only after the notice remained visible long enough', async () => { - vi.useFakeTimers() + it('renders cleanup failures as an actionable amber alert without auto-acknowledging', async () => { apiMocks.getManagedRuntimeNotices.mockResolvedValue([{ noticeId: 'notice-failed', kind: 'cleanup_failed', @@ -173,23 +234,123 @@ describe('ManagedRuntimeNotices', () => { createdAt: '2026-09-08T00:00:00.000Z', }]) renderNotices() - await act(async () => { - await Promise.resolve() - await Promise.resolve() + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('No unrelated process was touched') + expect(alert).toHaveClass('border-amber-500/50', 'bg-amber-500/10') + expect(screen.getByRole('button', { name: 'Details' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible() + await waitFor(() => { + expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( + 'notice-failed', + noticeProfileId('device-notice-test'), + 'rendered', + ) }) - expect(screen.getByRole('alert')).toHaveTextContent('No unrelated process was touched') expect(apiMocks.recordManagedRuntimeNoticeReceipt).not.toHaveBeenCalledWith( 'notice-failed', expect.any(String), 'acknowledged', ) - await act(async () => { - await vi.advanceTimersByTimeAsync(MANAGED_RUNTIME_NOTICE_AUTO_ACK_MS) + await userEvent.click(screen.getByRole('button', { name: 'Details' })) + expect(await screen.findByText(/provider state was missing/)).toBeVisible() + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + await waitFor(() => { + expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( + 'notice-failed', + noticeProfileId('device-notice-test'), + 'dismissed', + ) }) - expect(apiMocks.recordManagedRuntimeNoticeReceipt).toHaveBeenCalledWith( - 'notice-failed', - noticeProfileId('device-notice-test'), - 'acknowledged', - ) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('keeps opened incident details visible across repeated polls of the same warning', async () => { + await renderPollingNotices([cleanupFailure('notice-one')]) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + + for (let poll = 0; poll < 2; poll += 1) { + await pollNotices() + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + } + expect(apiMocks.getManagedRuntimeNotices).toHaveBeenCalledTimes(3) + expect(apiMocks.getManagedRuntimeIncidentSummary).toHaveBeenCalledTimes(1) + }) + + it('clears opened details when polling replaces the visible warning', async () => { + await renderPollingNotices([cleanupFailure('notice-one')]) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + + apiMocks.getManagedRuntimeNotices.mockResolvedValue([cleanupFailure('notice-two')]) + await pollNotices() + expect(screen.getByRole('alert')).toHaveTextContent('Cleanup needs attention: notice-two') + expect(screen.getByRole('alert')).not.toHaveTextContent('provider state was missing') }) + + it('never commits the old incident cause under a replacement warning before effects run', async () => { + const committedAlerts: string[] = [] + await renderPollingNotices([cleanupFailure('notice-one')], () => { + // Profiler observes the actual committed DOM before passive effects. + committedAlerts.push(screen.queryByRole('alert')?.textContent ?? '') + }) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + + apiMocks.getManagedRuntimeNotices.mockResolvedValue([cleanupFailure('notice-two')]) + await pollNotices() + const replacementAlerts = committedAlerts.filter((text) => text.includes('Cleanup needs attention: notice-two')) + expect(replacementAlerts.length).toBeGreaterThan(0) + for (const text of replacementAlerts) { + expect(text).not.toContain('provider state was missing') + } + }) + + it('clears opened details when dismissing advances to the next queued warning', async () => { + await renderPollingNotices([cleanupFailure('notice-one'), cleanupFailure('notice-two')]) + await clickNoticeButton('Details') + expect(screen.getByRole('alert')).toHaveTextContent('provider state was missing') + + await clickNoticeButton('Dismiss') + expect(screen.getByRole('alert')).toHaveTextContent('Cleanup needs attention: notice-two') + expect(screen.getByRole('alert')).not.toHaveTextContent('provider state was missing') + }) + + it.each(['success', 'failure'] as const)( + 'ignores a late Details %s after its warning is dismissed', + async (result) => { + await renderPollingNotices([cleanupFailure('notice-one')]) + const pending = deferredDetails() + apiMocks.getManagedRuntimeIncidentSummary.mockReturnValue(pending.promise) + await clickNoticeButton('Details') + await clickNoticeButton('Dismiss') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await act(async () => { + if (result === 'success') pending.resolve(incidentSummary) + else pending.reject(new Error('Old details request failed')) + }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }, + ) + + it.each(['success', 'failure'] as const)( + 'ignores a late Details %s after polling replaces its warning', + async (result) => { + await renderPollingNotices([cleanupFailure('notice-one')]) + const pending = deferredDetails() + apiMocks.getManagedRuntimeIncidentSummary.mockReturnValue(pending.promise) + await clickNoticeButton('Details') + apiMocks.getManagedRuntimeNotices.mockResolvedValue([cleanupFailure('notice-two')]) + await pollNotices() + + await act(async () => { + if (result === 'success') pending.resolve(incidentSummary) + else pending.reject(new Error('Old details request failed')) + }) + expect(screen.getByRole('alert')).toHaveTextContent('Cleanup needs attention: notice-two') + expect(screen.getByRole('alert')).not.toHaveTextContent('provider state was missing') + expect(screen.getByRole('alert')).not.toHaveTextContent('Old details request failed') + }, + ) }) diff --git a/test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx b/test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx new file mode 100644 index 000000000..6f8674695 --- /dev/null +++ b/test/unit/client/components/ManagedRuntimeRecoveryCard.test.tsx @@ -0,0 +1,106 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ManagedRuntimeRecoverySummary } from '@shared/managed-runtime' +import { ManagedRuntimeRecoveryCard } from '@/components/ManagedRuntimeRecoveryCard' + +function summary(recoveryState: ManagedRuntimeRecoverySummary['recoveryState']): ManagedRuntimeRecoverySummary { + return { + desiredState: recoveryState === 'stopped' ? 'stopped' : 'running', + recoveryState, + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + } +} + +describe('ManagedRuntimeRecoveryCard', () => { + afterEach(() => cleanup()) + + it.each(['live', 'recovering', 'stopped'] as const)('renders nothing for %s recovery', (recoveryState) => { + render( + , + ) + + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + }) + + it('renders one amber blocked alert and retries the same recovery', async () => { + const onRetry = vi.fn().mockResolvedValue(undefined) + render( + , + ) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('Check that the saved conversation store is readable, then retry recovery.') + expect(alert).toHaveClass('border-amber-500/50', 'bg-amber-500/10') + expect(screen.getByRole('button', { name: 'Retry recovery' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)) + }) + + it('explains exhausted automatic attempts and allows explicit recovery of the retained conversation', async () => { + const onRetry = vi.fn().mockResolvedValue(undefined) + const onStartFresh = vi.fn() + render() + expect(screen.getByRole('alert')).toHaveTextContent('Automatic recovery attempts have been exhausted.') + expect(screen.getByRole('alert')).not.toHaveTextContent('BLOCKED_RETRY_BUDGET') + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1)) + expect(onStartFresh).not.toHaveBeenCalled() + }) + + it('keeps the blocked alert and reports retry failures in the same card', async () => { + const onRetry = vi.fn().mockRejectedValue(new Error('Provider is unavailable')) + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + expect(await screen.findByRole('status')).toHaveTextContent('Provider is unavailable') + expect(screen.getByRole('alert')).toBeInTheDocument() + }) + + it('renders neutral lost copy and waits for an explicit start-new click', () => { + const onStartFresh = vi.fn() + render( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent(/could not be recovered/i) + expect(screen.getByRole('button', { name: 'Start new conversation' })).toBeInTheDocument() + expect(onStartFresh).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + expect(onStartFresh).toHaveBeenCalledTimes(1) + }) + + it('waits for start-new cleanup and reports failures in the lost card', async () => { + let reject!: (error: Error) => void + const onStartFresh = vi.fn(() => new Promise((_resolve, rejectPromise) => { reject = rejectPromise })) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + expect(screen.getByRole('button', { name: 'Starting…' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Starting…' })) + expect(onStartFresh).toHaveBeenCalledTimes(1) + + reject(new Error('Cleanup could not be confirmed. Your conversation has been kept. Try again.')) + expect(await screen.findByRole('status')).toHaveTextContent('Your conversation has been kept') + expect(screen.getByRole('button', { name: 'Start new conversation' })).toBeEnabled() + }) +}) diff --git a/test/unit/client/components/TabBar.test.tsx b/test/unit/client/components/TabBar.test.tsx index f27a705ce..60436997b 100644 --- a/test/unit/client/components/TabBar.test.tsx +++ b/test/unit/client/components/TabBar.test.tsx @@ -23,8 +23,9 @@ import { } from '@shared/settings' // Mock the ws-client module -const { mockSend, wsMessageHandlers } = vi.hoisted(() => ({ +const { mockSend, wsMessageHandlers, mockManagedVisibility } = vi.hoisted(() => ({ mockSend: vi.fn(), + mockManagedVisibility: vi.fn(), wsMessageHandlers: new Set<(msg: unknown) => void>(), })) vi.mock('@/lib/ws-client', () => ({ @@ -84,6 +85,7 @@ function ackAllPaneCloses() { // Mock the api module so the repo-icon meta probe thunk never hits the network vi.mock('@/lib/api', () => ({ + updateManagedRuntimeViewVisibility: mockManagedVisibility, api: { get: vi.fn().mockRejectedValue(new Error('no server in tests')), post: vi.fn(), @@ -271,6 +273,7 @@ function renderWithStore( describe('TabBar', () => { beforeEach(() => { mockSend.mockClear() + mockManagedVisibility.mockReset() }) afterEach(() => { @@ -794,6 +797,39 @@ describe('TabBar', () => { }) }) + it('Shift-closes a managed terminal when kill hides its view before the inventory update', async () => { + const node: PaneNode = { + type: 'leaf', id: 'managed-terminal-pane', + content: { + kind: 'terminal', mode: 'codex', status: 'running', + createRequestId: 'managed-terminal-create', terminalId: 'managed-terminal', + soulId: 'managed-terminal-soul', viewIntentId: 'managed-terminal-view', + viewIntentRevision: 2, soulIntentRevision: 7, + }, + } + const store = createStore( + { tabs: [createTab({ id: 'tab-1' })], activeTabId: 'tab-1' }, {}, + { layouts: { 'tab-1': node }, activePane: { 'tab-1': node.id } }, + ) + let stopped = false + mockManagedVisibility.mockImplementation(async (viewId, visibility, revision, soulRevision) => { + expect(stopped).toBe(true) + expect([viewId, visibility, revision, soulRevision]).toEqual(['managed-terminal-view', 'detached', 2, 7]) + return { viewId, soulId: 'managed-terminal-soul', visibility: 'hidden', revision: 3, soulIntentRevision: 8 } + }) + renderWithStore(, store) + fireEvent.click(screen.getByTitle('Close (Shift+Click to kill)'), { shiftKey: true }) + expect(mockManagedVisibility).not.toHaveBeenCalled() + stopped = true + ackAllTerminalKills() + await waitFor(() => expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ type: 'panes.closed' }))) + expect(store.getState().panes.layouts['tab-1']).toEqual(node) + ackAllPaneCloses() + await waitFor(() => expect(store.getState().tabs.tabs).toEqual([])) + expect(store.getState().panes.layouts['tab-1']).toBeUndefined() + expect(mockManagedVisibility).toHaveBeenCalledTimes(1) + }) + // b8ke ext r20 F2: the shift-close kill of a session-backed pane // carries the session's observed (epoch, generation) pair on the // wire — a reconnect-queued stale kill is typed-refused by the diff --git a/test/unit/client/components/TerminalView.exitBanner.test.tsx b/test/unit/client/components/TerminalView.exitBanner.test.tsx index 4c0db7f0f..4dd242151 100644 --- a/test/unit/client/components/TerminalView.exitBanner.test.tsx +++ b/test/unit/client/components/TerminalView.exitBanner.test.tsx @@ -121,6 +121,8 @@ interface StoreOptions { status?: TerminalPaneContent['status'] withSessionRef?: boolean crashTrace?: CrashTrace + managed?: boolean + recoveryState?: NonNullable['recoveryState'] lifecycle?: { lastTerminalId?: string exit?: { exitCode: number; at: number } @@ -136,6 +138,8 @@ function makeStore(opts: StoreOptions = {}) { status: opts.status ?? 'exited', mode: mode as TerminalPaneContent['mode'], shell: 'system', + ...(opts.managed ? { soulId: 'managed-soul' } : {}), + ...(opts.recoveryState ? { soulId: 'managed-soul', recoverySummary: { desiredState: 'running' as const, recoveryState: opts.recoveryState, reason: 'provider_unavailable', durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } : {}), ...(opts.crashTrace ? { crashTrace: opts.crashTrace } : {}), ...(opts.withSessionRef === false ? {} @@ -462,6 +466,41 @@ describe('TerminalView exited-pane error banner', () => { expect(screen.getByRole('alert')).toHaveTextContent('process exited (code 1)') }) + it.each(['claude', 'codex', 'opencode'])('keeps %s managed automatic terminal events invisible and retains a final failure action', async (mode) => { + const { store, paneContent } = makeStore({ mode, status: 'running', recoveryState: 'recovering', + lifecycle: { lastTerminalId: 'term-crashed', exit: { exitCode: 137, at: Date.now() } } }) + const { rerender } = render() + await act(async () => { await Promise.resolve(); await Promise.resolve() }) + act(() => messageHandler!({ type: 'terminal.status', terminalId: 'term-crashed', status: 'recovering', + attempt: 1, maxAttempts: 3, exitCode: 137 })) + expect(store.getState().terminalLifecycle.byPaneId[PANE].notice?.kind).toBe('recovering') + expect(screen.queryByText(/auto-resuming/)).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + act(() => messageHandler!({ type: 'terminal.replaced', oldTerminalId: 'term-crashed', newTerminalId: 'term-new', + exitCode: 137, attempt: 1, maxAttempts: 3 })) + rerender() + expect(paneState(store)).toMatchObject({ terminalId: 'term-new', sessionRef: { provider: mode, sessionId: SESSION_ID }, + crashTrace: { exitCode: 137 } }) + expect(screen.queryByTestId('crash-trace')).toBeNull() + expect(screen.queryByText(/auto-resumed/)).toBeNull() + act(() => messageHandler!({ type: 'terminal.status', terminalId: 'term-new', status: 'exited', exitCode: 137, + resumeCycles: 3, reason: 'retries_exhausted' })) + expect(screen.getByRole('alert')).toHaveTextContent('process exited (code 137)') + expect(screen.getByRole('button', { name: `Relaunch ${mode} session` })).toBeInTheDocument() + expect(screen.queryByText(/crashed 3 times/)).toBeNull() + }) + + it('keeps managed recovery invisible while the first summary is pending', async () => { + const { store, paneContent } = makeStore({ mode: 'codex', managed: true, + lifecycle: { lastTerminalId: 'term-crashed', exit: { exitCode: 137, at: Date.now() } } }) + await renderPane(store, paneContent) + act(() => messageHandler!({ type: 'terminal.status', terminalId: 'term-crashed', status: 'recovering', + attempt: 1, maxAttempts: 3, exitCode: 137 })) + expect(store.getState().terminalLifecycle.byPaneId[PANE].notice?.kind).toBe('recovering') + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByText(/auto-resuming/)).toBeNull() + }) + it('terminal.replaced writes a persistent crash trace onto pane content and shows the trace strip', async () => { const at = Date.now() const { store, paneContent } = makeStore({ diff --git a/test/unit/client/components/TerminalView.launchRetry.test.tsx b/test/unit/client/components/TerminalView.launchRetry.test.tsx index 6dea0df86..ec042f9be 100644 --- a/test/unit/client/components/TerminalView.launchRetry.test.tsx +++ b/test/unit/client/components/TerminalView.launchRetry.test.tsx @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { act, render, cleanup } from '@testing-library/react' +import { act, render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react' import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import tabsReducer from '@/store/tabsSlice' -import panesReducer from '@/store/panesSlice' +import panesReducer, { updatePaneContent } from '@/store/panesSlice' import settingsReducer, { defaultSettings } from '@/store/settingsSlice' import connectionReducer from '@/store/connectionSlice' import { resetPersistedLayoutCacheForTests, resetPersistFlushListenersForTests } from '@/store/persistMiddleware' @@ -44,6 +44,16 @@ const runtimeMocks = vi.hoisted(() => ({ instances: [] as Array<{ fit: ReturnType }>, })) +const retryManagedRuntimeSoul = vi.hoisted(() => vi.fn()) +const queueManagedRuntimeRefresh = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +vi.mock('@/lib/recovery/managed-runtime-recovery', () => ({ queueManagedRuntimeRefresh })) +const stopManagedRuntimeSoul = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/api', async (importOriginal) => ({ + ...await importOriginal(), + stopManagedRuntimeSoul, + retryManagedRuntimeSoul, +})) + vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: wsMocks.send, @@ -224,6 +234,7 @@ function withCurrentAttachRequestId void) | null = null +let lastMessageCallback: ((msg: any) => void) | null = null let reconnectHandler: (() => void) | null = null let requestAnimationFrameSpy: ReturnType | null = null let cancelAnimationFrameSpy: ReturnType | null = null @@ -346,6 +357,7 @@ describe('launch-time INVALID_TERMINAL_ID bounded retry', () => { terminalInstances.length = 0 runtimeMocks.instances.length = 0 wsMocks.onMessage.mockImplementation((callback: (msg: any) => void) => { + lastMessageCallback = callback messageHandler = (msg: any) => callback(withCurrentAttachRequestId(msg)) return () => { messageHandler = null } }) @@ -378,6 +390,7 @@ describe('launch-time INVALID_TERMINAL_ID bounded retry', () => { requestAnimationFrameSpy = null cancelAnimationFrameSpy = null reconnectHandler = null + lastMessageCallback = null installPerfAuditBridge(null) }) @@ -479,4 +492,289 @@ describe('launch-time INVALID_TERMINAL_ID bounded retry', () => { await act(async () => { vi.advanceTimersByTime(60_000) }) expect(sentCreates().length).toBe(total) }) + + it.each(['verified_empty', 'termination_unconfirmed', 'blocked_ownership', 'backend_unavailable', 'http_failure', 'missing_revision', 'missing_soul'])( + 'waits for exact-soul cleanup before replacing a lost terminal: %s', async (outcome) => { + stopManagedRuntimeSoul.mockReset() + let resolveStop!: (value: unknown) => void + let rejectStop!: (error: Error) => void + stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve, reject) => { resolveStop = resolve; rejectStop = reject })) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { + ...paneContent, status: 'error', mode: 'codex', terminalId: 'lost-terminal', + sessionRef: { provider: 'codex', sessionId: 'retained-thread' }, resumeSessionId: 'retained-thread', + soulId: outcome === 'missing_soul' ? undefined : 'persisted-lost-terminal-soul', + soulIntentRevision: outcome === 'missing_revision' ? undefined : 21, + recoverySummary: { desiredState: 'stopped', recoveryState: 'lost', + durabilityState: 'resume_captured', allocationState: 'verified_durable' }, + } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + const retained = (store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content + wsMocks.send.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + if (outcome.startsWith('missing_')) { + expect(await within(screen.getByTestId('managed-runtime-recovery-card')).findByRole('status')).toHaveTextContent('Your conversation has been kept') + expect(stopManagedRuntimeSoul).not.toHaveBeenCalled() + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content).toMatchObject(retained) + expect(sentCreates()).toHaveLength(0) + return + } + await waitFor(() => expect(stopManagedRuntimeSoul).toHaveBeenCalledWith(content.soulId, 21)) + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content).toMatchObject(content) + expect(sentCreates()).toHaveLength(0) + expect(screen.getByRole('button', { name: 'Starting…' })).toBeDisabled() + + await act(async () => { + if (outcome === 'http_failure') rejectStop(new Error('Server is unavailable')) + else resolveStop({ outcome, soul: { soulId: content.soulId, intentRevision: 21 } }) + }) + const after = (store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content + if (outcome === 'verified_empty') { + expect(after.createRequestId).not.toBe(content.createRequestId) + expect(after.soulId).toBeUndefined() + expect(after.sessionRef).toBeUndefined() + } else { + expect(after).toMatchObject(content) + expect(await within(screen.getByTestId('managed-runtime-recovery-card')).findByRole('status')).toHaveTextContent(outcome === 'http_failure' ? 'Server is unavailable' : 'Your conversation has been kept') + expect(sentCreates()).toHaveLength(0) + } + }, + ) + + it.each([ + ['recovery', 'verified_empty'], ['recovery', 'termination_unconfirmed'], ['recovery', 'http_failure'], + ['launch', 'verified_empty'], ['launch', 'termination_unconfirmed'], ['launch', 'http_failure'], + ] as const)('does not alter a different terminal pane after a late %s card %s stop result', async (surface, outcome) => { + stopManagedRuntimeSoul.mockReset() + let resolveStop!: (value: unknown) => void + let rejectStop!: (error: Error) => void + stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve, reject) => { resolveStop = resolve; rejectStop = reject })) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', soulId: 'old-soul', soulIntentRevision: 21, + ...(surface === 'recovery' + ? { recoverySummary: { desiredState: 'stopped', recoveryState: 'lost', durabilityState: 'resume_captured', allocationState: 'verified_durable' } as const } + : { launchFailure: { code: 'SESSION_MISSING', message: 'The durable session is gone.', retryable: false } as const }), + } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + const card = screen.getByTestId(surface === 'recovery' ? 'managed-runtime-recovery-card' : 'terminal-launch-failure-card') + fireEvent.click(surface === 'recovery' + ? within(card).getByRole('button', { name: 'Start new conversation' }) + : within(card).getByTestId('terminal-launch-failure-start-fresh')) + await waitFor(() => expect(stopManagedRuntimeSoul).toHaveBeenCalledWith('old-soul', 21)) + const replacement = { ...content, createRequestId: 'different-create', soulId: 'different-soul' } + act(() => store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content: replacement }))) + // The mounted prop/ref is intentionally stale; the store already owns a different pane. + await act(async () => { + if (outcome === 'http_failure') rejectStop(new Error('Stale request failed')) + else resolveStop({ outcome, soul: { soulId: 'old-soul', intentRevision: 22 } }) + }) + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content).toMatchObject(replacement) + expect(within(card).queryByRole('status')).toBeNull() + }) + + it.each(['newer_pane', 'older_result', 'wrong_soul'])('does not replace terminal authority after a %s stop response', async (scenario) => { + stopManagedRuntimeSoul.mockReset() + let resolveStop!: (value: unknown) => void + stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve) => { resolveStop = resolve })) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', soulId: 'same-soul', soulIntentRevision: 21, + recoverySummary: { desiredState: 'stopped', recoveryState: 'lost', durabilityState: 'resume_captured', allocationState: 'verified_durable' } } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + if (scenario === 'newer_pane') act(() => store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content: { ...content, soulIntentRevision: 24 } }))) + await act(async () => resolveStop({ outcome: 'verified_empty', soul: { + soulId: scenario === 'wrong_soul' ? 'different-soul' : 'same-soul', + intentRevision: scenario === 'older_result' ? 20 : 22, + } })) + const after = (store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content + expect(after).toMatchObject({ ...content, soulIntentRevision: scenario === 'newer_pane' ? 24 : 21 }) + const card = screen.getByTestId('managed-runtime-recovery-card') + if (scenario === 'newer_pane') expect(within(card).queryByRole('status')).toBeNull() + else expect(await within(card).findByRole('status')).toHaveTextContent('Your conversation has been kept') + }) + + it('immediately retries a running SESSION_MISSING terminal with the committed stop revision', async () => { + stopManagedRuntimeSoul.mockReset() + let serverRevision = 21 + let running = true + let resolveVerified!: (value: unknown) => void + stopManagedRuntimeSoul.mockImplementation((_soulId: string, revision: number) => { + if (revision !== serverRevision) return Promise.reject(new Error('Stale intent revision')) + if (running) { + running = false + serverRevision += 1 + return Promise.resolve({ outcome: 'termination_unconfirmed', soul: { soulId: 'running-soul', intentRevision: serverRevision } }) + } + return new Promise((resolve) => { resolveVerified = resolve }) + }) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', terminalId: 'old-terminal', + soulId: 'running-soul', soulIntentRevision: 21, + sessionRef: { provider: 'codex', sessionId: 'retained-thread' }, + launchFailure: { code: 'SESSION_MISSING', message: 'The durable session is gone.', retryable: false }, + } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + const card = screen.getByTestId('terminal-launch-failure-card') + wsMocks.send.mockClear() + fireEvent.click(within(card).getByTestId('terminal-launch-failure-start-fresh')) + expect(await within(card).findByRole('status')).toHaveTextContent('Your conversation has been kept') + const retained = (store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content + expect(retained).toMatchObject({ createRequestId: content.createRequestId, soulId: 'running-soul', soulIntentRevision: 22, sessionRef: content.sessionRef }) + fireEvent.click(within(card).getByTestId('terminal-launch-failure-start-fresh')) + await waitFor(() => expect(stopManagedRuntimeSoul).toHaveBeenNthCalledWith(2, 'running-soul', 22)) + expect(within(card).getByTestId('terminal-launch-failure-start-fresh')).toBeDisabled() + expect(sentCreates()).toHaveLength(0) + await act(async () => resolveVerified({ outcome: 'verified_empty', soul: { soulId: 'running-soul', intentRevision: 22 } })) + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content.createRequestId).not.toBe(content.createRequestId) + }) + + it('reports a rejected SESSION_MISSING start-fresh request inline and retains identity while pending', async () => { + stopManagedRuntimeSoul.mockReset() + let rejectStop!: (error: Error) => void + stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectStop = reject })) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', terminalId: 'retained-terminal', + soulId: 'missing-session-soul', soulIntentRevision: 6, sessionRef: { provider: 'codex', sessionId: 'retained-thread' }, + launchFailure: { code: 'SESSION_MISSING', message: 'The durable session is gone.', retryable: false }, + } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + const card = screen.getByTestId('terminal-launch-failure-card') + const button = within(card).getByTestId('terminal-launch-failure-start-fresh') + wsMocks.send.mockClear() + fireEvent.click(button) + expect(button).toBeDisabled() + expect(button).toHaveTextContent('Starting…') + fireEvent.click(button) + expect(stopManagedRuntimeSoul).toHaveBeenCalledTimes(1) + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content).toMatchObject(content) + expect(sentCreates()).toHaveLength(0) + await act(async () => rejectStop(new Error('Server is unavailable'))) + expect(await within(card).findByRole('status')).toHaveTextContent('Server is unavailable') + expect(button).toBeEnabled() + expect((store.getState().panes.layouts[TAB] as { content: TerminalPaneContent }).content).toMatchObject(content) + }) + + it.each(['blocked', 'lost'] as const)('shows only the managed %s decision when a prior launch failure exists', async (recoveryState) => { + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', soulId: 'same-soul', soulIntentRevision: 19, + launchFailure: { code: 'LAUNCH_FAILED', message: 'Old launch failed', retryable: true }, + recoverySummary: { desiredState: 'running', recoveryState, reason: 'STORE_UNREADABLE', + durabilityState: 'resume_captured', allocationState: 'verified_durable' } } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + expect(screen.getAllByRole('alert')).toHaveLength(1) + expect(screen.queryByTestId('terminal-launch-failure-card')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Retry', exact: true })).not.toBeInTheDocument() + }) + + it('clears prior managed retry feedback when a different terminal conversation occupies the pane', async () => { + retryManagedRuntimeSoul.mockRejectedValueOnce(new Error('Old conversation repair failed')) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', soulId: 'old-retry-soul', soulIntentRevision: 19, + recoverySummary: { desiredState: 'running', recoveryState: 'blocked', reason: 'STORE_UNREADABLE', + durabilityState: 'resume_captured', allocationState: 'verified_durable' } } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + expect(await within(screen.getByTestId('managed-runtime-recovery-card')).findByRole('status')).toHaveTextContent('Old conversation repair failed') + const replacement = { ...content, createRequestId: 'new-retry-create', soulId: 'new-retry-soul' } + act(() => store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content: replacement }))) + view.rerender() + expect(within(screen.getByTestId('managed-runtime-recovery-card')).queryByRole('status')).toBeNull() + }) + + it.each(['repair', 'reason', 'stale_result', 'stale_error', 'different_create', 'different_soul'] as const)('handles a managed terminal retry: %s', async (scenario) => { + let resolve!: (value: unknown) => void + let reject!: (error: Error) => void + retryManagedRuntimeSoul.mockReset() + queueManagedRuntimeRefresh.mockClear() + retryManagedRuntimeSoul.mockReturnValueOnce(new Promise((res, rej) => { resolve = res; reject = rej })) + const { store, paneContent } = makeStore() + const content: TerminalPaneContent = { ...paneContent, status: 'error', soulId: 'retry-soul', soulIntentRevision: 19, + recoverySummary: { desiredState: 'running', recoveryState: 'blocked', reason: 'STORE_UNREADABLE', + durabilityState: 'resume_captured', allocationState: 'verified_durable' } } + store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content })) + render() + const card = screen.getByTestId('managed-runtime-recovery-card') + fireEvent.click(within(card).getByRole('button', { name: 'Retry recovery' })) + expect(retryManagedRuntimeSoul).toHaveBeenCalledWith('retry-soul', 19) + const stale = scenario.startsWith('stale') || scenario.startsWith('different') + if (stale) act(() => store.dispatch(updatePaneContent({ tabId: TAB, paneId: PANE, content: { ...content, + ...(scenario === 'different_create' ? { createRequestId: 'another-create' } + : scenario === 'different_soul' ? { soulId: 'another-soul' } : { soulIntentRevision: 20 }), + } }))) + await act(async () => { + if (scenario === 'stale_error') reject(new Error('Obsolete retry failure')) + else resolve({ outcome: 'blocked', view: { soulId: 'retry-soul', intentRevision: 19, recoveryReason: 'OLD_RUNTIME_NOT_EMPTY' }, + probe: { kind: 'blocked', data: { reason: 'OLD_RUNTIME_NOT_EMPTY', retry_hint: { manualRetry: true, + ...(scenario === 'reason' ? {} : { repair: 'Confirm the old process has stopped, then retry.' }) } } } }) + }) + if (stale) expect(within(card).queryByRole('status')).toBeNull() + else expect(await within(card).findByRole('status')).toHaveTextContent(scenario === 'repair' + ? 'Confirm the old process has stopped, then retry.' : 'The previous agent process could not be confirmed stopped. Check it before retrying recovery.') + }) + + it('keeps a blocked managed pane from re-creating after a rejected-terminal callback', async () => { + const { store, paneContent } = makeStore() + const rendered = render( + + + , + ) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + const createsBeforeManagedDecision = sentCreates().length + expect(createsBeforeManagedDecision).toBeGreaterThan(0) + + store.dispatch(updatePaneContent({ + tabId: TAB, + paneId: PANE, + content: { + ...paneContent, + terminalId: 'term-managed-lost', + status: 'error', + mode: 'opencode', + soulId: 'soul-managed', + soulIntentRevision: 4, + recoverySummary: { + desiredState: 'stopped', + recoveryState: 'blocked', + reason: 'provider_unavailable', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + }, + }, + })) + const managedPane = store.getState().panes.layouts[TAB] + if (managedPane.type !== 'leaf') throw new Error('expected managed leaf') + await act(async () => { + rendered.rerender( + + + , + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + expect(wsMocks.send.mock.calls.some(([message]) => message?.type === 'terminal.attach')).toBe(false) + const createsBeforeRejectedTerminal = sentCreates().length + expect(lastMessageCallback).not.toBeNull() + await act(async () => { + lastMessageCallback?.({ + type: 'error', + code: 'INVALID_TERMINAL_ID', + terminalId: 'term-managed-lost', + }) + }) + expect(sentCreates()).toHaveLength(createsBeforeRejectedTerminal) + }) }) diff --git a/test/unit/client/components/TerminalView.lifecycle.test.tsx b/test/unit/client/components/TerminalView.lifecycle.test.tsx index fd68c70c8..1aaf5ab8c 100644 --- a/test/unit/client/components/TerminalView.lifecycle.test.tsx +++ b/test/unit/client/components/TerminalView.lifecycle.test.tsx @@ -5,6 +5,7 @@ import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' import tabsReducer, { setActiveTab } from '@/store/tabsSlice' import panesReducer, { + updatePaneContent, removeLayout, requestPaneRefresh, setPaneCloseError, @@ -4293,9 +4294,9 @@ describe('TerminalView lifecycle updates', () => { await waitFor(() => { expect(createCalls()).toHaveLength(2) }) - expect(createCalls()[1]).toMatchObject({ - requestId: 'req-b8ke', - }) + const retiredRequestId = createCalls()[0].requestId + expect(createCalls()[1].requestId).toEqual(expect.any(String)) + expect(createCalls()[1].requestId).not.toBe(retiredRequestId) expect(createCalls()[1].sessionRef).toBeUndefined() const leaf = store.getState().panes.layouts['tab-b8ke'] expect( @@ -5932,6 +5933,21 @@ describe('TerminalView lifecycle updates', () => { expect(queryByText('Recovering terminal output...')).not.toBeNull() }) + it('keeps managed output attachment invisible while actually requesting retained output', async () => { + const { tabId, paneId, paneContent, store } = setupNonBlockingTerminal('ready') + const managedContent: TerminalPaneContent = { ...paneContent, soulId: 'managed-soul', recoverySummary: { + desiredState: 'running', recoveryState: 'recovering', reason: 'provider_unavailable', + durabilityState: 'resume_captured', allocationState: 'verified_durable', + } } + store.dispatch(updatePaneContent({ tabId, paneId, content: managedContent })) + render() + await waitFor(() => expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.attach', terminalId: 'term-non-blocking', sinceSeq: 0, + }))) + expect(screen.queryByText('Recovering terminal output...')).toBeNull() + expect(screen.queryByTestId('loader')).toBeNull() + }) + it('does not show recovering banner on fresh terminal creation', async () => { const tabId = 'tab-fresh' const paneId = 'pane-fresh' @@ -6040,6 +6056,8 @@ describe('TerminalView lifecycle updates', () => { status?: 'creating' | 'running' terminalId?: string mode?: TerminalPaneContent['mode'] + recoverySummary?: TerminalPaneContent['recoverySummary'] + soulId?: string hidden?: boolean clearSends?: boolean requestId?: string @@ -6078,6 +6096,8 @@ describe('TerminalView lifecycle updates', () => { ...(terminalId ? { terminalId } : {}), ...(opts?.sessionRef ? { sessionRef: opts.sessionRef } : {}), ...(opts?.streamId ? { streamId: opts.streamId } : {}), + ...(opts?.recoverySummary ? { recoverySummary: opts.recoverySummary } : {}), + ...(opts?.soulId ? { soulId: opts.soulId } : {}), ...(opts?.contentServerInstanceId ? { serverInstanceId: opts.contentServerInstanceId } : {}), } @@ -6182,6 +6202,70 @@ describe('TerminalView lifecycle updates', () => { } } + it.each([false, true].flatMap((savedIdentity) => (['running', 'creating'] as const).map((status) => ({ savedIdentity, status }))))('preserves a recovering managed terminal on a rejected attach (saved identity $savedIdentity, stale $status)', async ({ savedIdentity, status }) => { + const recoverySummary = { desiredState: 'running' as const, recoveryState: 'recovering' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } + const { store, term, tabId, paneId, terminalId } = await renderTerminalHarness({ + mode: 'codex', status, terminalId: 'automatic-invalid-terminal', clearSends: false, ackInitialAttach: false, fromStore: true, + soulId: 'automatic-terminal-soul', recoverySummary, + ...(savedIdentity ? { sessionRef: { provider: 'codex', sessionId: 'saved-automatic-thread' } } : {}), + }) + const root = store.getState().panes.layouts[tabId] + if (root.type !== 'leaf') throw new Error('Expected one pane') + const before = root.content + const attach = sentMessages().find((frame) => frame.type === 'terminal.attach' && frame.terminalId === terminalId) + expect(attach).toMatchObject({ attachRequestId: expect.any(String) }) + act(() => messageHandler!({ type: 'error', code: 'INVALID_TERMINAL_ID', terminalId, + requestId: attach.attachRequestId, message: 'Terminal not running' })) + await act(async () => {}) + expect(store.getState().panes.layouts[tabId].content).toEqual(before) + expect(sentMessages().filter((frame) => frame.type === 'terminal.create')).toEqual([]) + expect(term.write.mock.calls.map(([data]: [string]) => data).join('')).not.toMatch(/Reconnecting|Starting a new terminal/) + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.queryByText('Starting terminal...')).not.toBeInTheDocument() + // The managed replacement still folds and its real mounted attachment renders output. + act(() => messageHandler!({ type: 'terminal.replaced', oldTerminalId: terminalId, + newTerminalId: 'automatic-replacement-terminal', exitCode: 137, attempt: 1, maxAttempts: 3 })) + const replacementAttach = sentMessages().filter((frame) => frame.type === 'terminal.attach' + && frame.terminalId === 'automatic-replacement-terminal').at(-1) + expect(replacementAttach).toMatchObject({ attachRequestId: expect.any(String) }) + act(() => { + messageHandler!({ type: 'terminal.attach.ready', terminalId: 'automatic-replacement-terminal', + attachRequestId: replacementAttach.attachRequestId, headSeq: 1, replayFromSeq: 1, replayToSeq: 1 }) + messageHandler!({ type: 'terminal.output', terminalId: 'automatic-replacement-terminal', + attachRequestId: replacementAttach.attachRequestId, seqStart: 1, seqEnd: 1, data: 'Recovered conversation output' }) + }) + expect(term.write).toHaveBeenCalledWith('Recovered conversation output', expect.any(Function)) + expect(store.getState().panes.layouts[tabId].content).toMatchObject({ + terminalId: 'automatic-replacement-terminal', createRequestId: before.createRequestId, soulId: 'automatic-terminal-soul', + }) + expect(store.getState().panes.layouts[tabId].content.sessionRef).toEqual(before.sessionRef) + }) + + it('retains an attach target rejected after live to recovering and reattaches after live authority returns', async () => { + const { store, term, tabId, paneId, terminalId } = await renderTerminalHarness({ + mode: 'codex', terminalId: 'automatic-transition-terminal', clearSends: false, ackInitialAttach: false, fromStore: true, + soulId: 'automatic-transition-soul', sessionRef: { provider: 'codex', sessionId: 'transition-saved-thread' }, + }) + const attach = sentMessages().filter((frame) => frame.type === 'terminal.attach').at(-1) + const root = store.getState().panes.layouts[tabId] + if (root.type !== 'leaf') throw new Error('Expected one pane') + const recovering = { ...root.content, recoverySummary: { desiredState: 'running' as const, recoveryState: 'recovering' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + act(() => store.dispatch(updatePaneContent({ tabId, paneId, content: recovering }))) + const currentAttach = sentMessages().filter((frame) => frame.type === 'terminal.attach').at(-1) ?? attach + act(() => messageHandler!({ type: 'error', code: 'INVALID_TERMINAL_ID', terminalId, + requestId: currentAttach.attachRequestId, message: 'Terminal not running' })) + expect(store.getState().panes.layouts[tabId].content).toEqual(recovering) + expect(sentMessages().filter((frame) => frame.type === 'terminal.create')).toEqual([]) + expect(term.write.mock.calls.map(([data]: [string]) => data).join('')).not.toMatch(/Reconnecting|Starting a new terminal/) + const beforeLive = sentMessages().filter((frame) => frame.type === 'terminal.attach').length + act(() => store.dispatch(updatePaneContent({ tabId, paneId, content: { ...recovering, + recoverySummary: { ...recovering.recoverySummary, recoveryState: 'live' } } }))) + expect(sentMessages().filter((frame) => frame.type === 'terminal.attach')).toHaveLength(beforeLive + 1) + expect(sentMessages().filter((frame) => frame.type === 'terminal.attach').at(-1).terminalId).toBe(terminalId) + }) + function replayReconstructedSurface(terminalId: string, probeAttachId: string, headSeq: number, data: string) { const attach = sentMessages().filter(msg => msg?.type === 'terminal.attach' && msg.terminalId === terminalId).at(-1)! expect(attach.attachRequestId).not.toBe(probeAttachId) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index a7b1c5ea8..88c57c1b9 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -1,3 +1,7 @@ +import savedCodexTools from '../../../../fixtures/managed-native-history/codex-tools.json' +import savedClaudeNativeHistory from '../../../../fixtures/managed-native-history/claude.json' +import savedCodexNativeHistory from '../../../../fixtures/managed-native-history/codex.json' +import savedOpenCodeNativeHistory from '../../../../fixtures/managed-native-history/opencode.json' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor, fireEvent, createEvent, cleanup, act, within } from '@testing-library/react' import { Provider } from 'react-redux' @@ -5,17 +9,20 @@ import { configureStore, type Middleware } from '@reduxjs/toolkit' import panesReducer from '@/store/panesSlice' import settingsReducer, { previewServerSettingsPatch, updateSettingsLocal } from '@/store/settingsSlice' import sessionsReducer, { applySessionsPatch, applyContextUsageExtras } from '@/store/sessionsSlice' -import freshAgentReducer, { applyRuntimeOwner, sessionError, sessionExited, sessionInit, sessionMetadataReceived, setSessionStatus, markSessionLost } from '@/store/freshAgentSlice' +import freshAgentReducer, { applyRuntimeOwner, historyPageReceived, sessionError, sessionExited, sessionInit, sessionMetadataReceived, sessionSnapshotReceived, setSessionStatus, markSessionLost } from '@/store/freshAgentSlice' import { selectPaneOwnerFence } from '@/store/selectors/runtimeOwner' -import tabsReducer from '@/store/tabsSlice' -import connectionReducer from '@/store/connectionSlice' +import tabsReducer, { closeTab, closePaneWithCleanup } from '@/store/tabsSlice' +import connectionReducer, { setBootId } from '@/store/connectionSlice' +import managedRuntimeReducer from '@/store/managedRuntimeSlice' import { FreshAgentView, IDLE_INCOMPLETE_MAX_RETRIES, locatorMatchesPane } from '@/components/fresh-agent/FreshAgentView' import { FreshAgentSettingsButton } from '@/components/fresh-agent/FreshAgentSettingsButton' import { initLayout, + splitPane, applyFreshAgentReconcileAttach, requestPaneRefresh, resetFreshAgentPaneForReconcileCreate, + startNewManagedRuntimeConversation, setActivePane, setPaneHandoffError, updatePaneContent, @@ -24,6 +31,10 @@ import { import { useAppSelector } from '@/store/hooks' import { updateTab } from '@/store/tabsSlice' import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import { buildReconcileRequest, foldVerdicts, setFreshAgentReconcileActive } from '@/lib/pane-reconcile' +import type { AppDispatch, RootState } from '@/store/store' +import { getFreshAgentSnapshotThreadId, getManagedBootstrapHistoryThreadId } from '@/lib/fresh-agent-snapshot-thread' +import type { FreshAgentPaneContent } from '@/store/paneTypes' import { ApiError } from '@/lib/api' import { resetSnapshotSchedulerForTests, SNAPSHOT_DEBOUNCE_MS } from '@/lib/fresh-agent-snapshot-scheduler' import { SESSION_HANDOFF_RETRY_BACKOFF_MS } from '@/lib/session-handoff' @@ -36,9 +47,223 @@ import { } from '@/lib/fresh-agent-rollback' import { getFreshAgentPaneActions } from '@/lib/pane-action-registry' import type { PaneNode } from '@/store/paneTypes' +import { findPaneContent } from '@/lib/pane-utils' +import { resetManagedRuntimeRefreshForTest } from '@/lib/recovery/managed-runtime-recovery' +import { FreshAgentSnapshotSchema } from '@shared/fresh-agent-contract' +import { createPerfAuditBridge, installPerfAuditBridge } from '@/lib/perf-audit-bridge' +import { isClientPerfLoggingEnabled, setClientPerfEnabled } from '@/lib/perf-logger' const CLAUDE_THREAD_ID = '550e8400-e29b-41d4-a716-446655440000' +describe('snapshot request audit', () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator = { sessionType: 'freshcodex' as const, provider: 'codex' as const, sessionId: native.threadId } + const content = { kind: 'fresh-agent' as const, ...locator, status: 'running' as const, + createRequestId: 'private-audit-create', soulId: 'private-audit-soul', soulIntentRevision: 1, + initialCwd: '/private-audit-path', recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + const owner = { type: 'session.runtimeOwner' as const, ...locator, epoch: 1, generation: 1, + ownerKind: 'fresh-agent' as const, operationId: 'private-audit-owner', transition: 'handoff-committed' as const } + const history = { ...native, extensions: { codex: { nativeHistoryAvailable: true, ownerKind: 'vacant' } } } + const records = (bridge: ReturnType) => + bridge.snapshot().perfEvents.filter((entry) => entry.event === 'fresh_agent.snapshot_request') + function mount() { + const store = createStore() + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + return store + } + afterEach(() => { + cleanup() + resetSnapshotSchedulerForTests() + installPerfAuditBridge(null) + }) + + it.each(['native-only', 'native-error'] as const)('records the actual %s to owned-history closure with logging disabled', async (source) => { + const enabled = isClientPerfLoggingEnabled() + setClientPerfEnabled(false) + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + const interactive = createDeferred() + const owned = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _id, options) => options?.soulId ? owned.promise : interactive.promise) + try { + const store = mount() + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'private-audit-draft' } }) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + await act(async () => source === 'native-only' ? interactive.resolve(history) : interactive.reject(new Error('private-audit-error'))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls[1][3]).toMatchObject({ soulId: content.soulId }) + await act(async () => owned.resolve(history)) + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + expect(composer).toHaveValue('private-audit-draft') + expect(composer).toBeDisabled() + expect(getFreshAgentPaneContent(store)).toMatchObject(content) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + const events = records(bridge) + const stages = events.map((entry) => entry.stage) + for (const stage of ['request_captured', 'request_queued', 'run_started', 'native_started', + source === 'native-only' ? 'native_completed' : 'native_failed', 'soul_started', 'outcome_received', 'display_committed']) { + expect(stages).toContain(stage) + } + expect(stages.indexOf('native_started')).toBeLessThan(stages.indexOf('soul_started')) + expect(stages.indexOf('soul_started')).toBeLessThan(stages.indexOf('display_committed')) + expect(events.every((entry, index) => typeof entry.timestamp === 'number' + && (index === 0 || Number(entry.timestamp) >= Number(events[index - 1].timestamp)))).toBe(true) + expect(events.find((entry) => entry.stage === 'outcome_received')).toMatchObject({ ran: true, outcome: 'ok' }) + const serialized = JSON.stringify(events) + for (const secret of [content.soulId, content.createRequestId, content.initialCwd, native.threadId, + 'private-audit-draft', 'private-audit-error', 'Saved native Codex answer']) expect(serialized).not.toContain(secret) + } finally { + await act(async () => { interactive.resolve(history); owned.resolve(history) }) + setClientPerfEnabled(enabled) + } + }) + + it.each(['owner', 'boot', 'soul', 'revision'] as const)('records only the first actual %s rejection and preserves the current pane', async (change) => { + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + const held = createDeferred() + const currentRead = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _id, options) => options?.soulId + ? held.promise : Promise.reject(new Error('host unavailable'))) + const store = mount() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(currentRead.promise) + act(() => { + if (change === 'owner') store.dispatch(applyRuntimeOwner({ ...owner, generation: 2, operationId: 'next-owner' })) + if (change === 'boot') store.dispatch(setBootId('private-audit-next-boot')) + if (change === 'soul' || change === 'revision') store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', + content: { ...content, soulId: change === 'soul' ? 'next-soul' : content.soulId, soulIntentRevision: change === 'revision' ? 2 : 1 } })) + if (change === 'revision') { + store.dispatch(setBootId('later-private-boot-fence')) + store.dispatch(applyRuntimeOwner({ ...owner, generation: 2, operationId: 'later-private-owner-fence' })) + } + }) + const current = getFreshAgentPaneContent(store) + await act(async () => held.resolve(history)) + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(current) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + const rejected = records(bridge).find((entry) => entry.stage === 'currentness_checked' && entry.stale === true) + expect(rejected).toMatchObject({ check: 'outcome', fence: change, stale: true, requestSerial: 1 }) + expect(records(bridge).some((entry) => entry.stage === 'display_committed' && entry.requestSerial === 1)).toBe(false) + } finally { installPerfAuditBridge(null); await act(async () => currentRead.resolve(history)) } + }) + + it.each([false, true])('preserves inactive behavior and stops recording after bridge removal (installed: %s)', async (installed) => { + const bridge = createPerfAuditBridge() + installPerfAuditBridge(installed ? bridge : null) + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + mount() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + const started = records(bridge).map((entry) => entry.stage) + installPerfAuditBridge(null) + const before = bridge.snapshot() + await act(async () => held.resolve({ ...native, capabilities: { ...native.capabilities, send: true }, + extensions: { codex: { statusFromLiveState: true } } })) + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + expect(bridge.snapshot()).toEqual(before) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + if (installed) expect(started).toContain('native_started') + }) + + it.each([false, true])('preserves the compound read-generation exception (initial read-only: %s)', async (readOnly) => { + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + const held = createDeferred() + const liveRead = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(held.promise).mockReturnValue(liveRead.promise) + const store = createStore() + store.dispatch(sessionInit(locator)) + const initial = { ...content, recoverySummary: { ...content.recoverySummary, + recoveryState: readOnly ? 'blocked' as const : 'live' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: initial })) + render() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...initial, + recoverySummary: { ...initial.recoverySummary, recoveryState: readOnly ? 'live' : 'recovering' } } }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + await act(async () => held.resolve(history)) + if (readOnly) { + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(records(bridge).find((entry) => entry.stage === 'display_committed' && entry.requestSerial === 1)) + .toMatchObject({ requestReadOnly: true }) + } else { + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(records(bridge).find((entry) => entry.stage === 'currentness_checked' && entry.stale === true)) + .toMatchObject({ fence: 'read_generation', requestSerial: 1 }) + } + } finally { installPerfAuditBridge(null); await act(async () => liveRead.resolve(history)) } + }) + + it('distinguishes shared scheduler consumers from the actual trailing run', async () => { + vi.useFakeTimers() + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + try { + const store = createStore() + const handlers = new Set<(message: unknown) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + const broadcast = (message: unknown) => { for (const handler of handlers) handler(message) } + const pane = { kind: 'fresh-agent' as const, sessionType: 'freshopencode' as const, provider: 'opencode' as const, + sessionId: 'ses_late_change', sessionRef: { provider: 'opencode', sessionId: 'ses_late_change' }, + resumeSessionId: 'ses_late_change', createRequestId: 'shared-audit-a', status: 'idle' as const } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(freshopencodeSnapshot('audit shared answer', 10)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: pane })) + store.dispatch(initLayout({ tabId: 'tab-2', paneId: 'pane-2', content: { ...pane, createRequestId: 'shared-audit-b' } })) + render( + ) + await act(async () => { await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) }) + expect(screen.getAllByText('audit shared answer').length).toBeGreaterThan(0) + const baseline = records(bridge).length + apiMock.getFreshAgentThreadSnapshot.mockClear() + for (let index = 0; index < 10; index += 1) act(() => broadcast({ type: 'freshAgent.event', sessionType: 'freshopencode', + provider: 'opencode', sessionId: 'ses_late_change', event: { type: 'freshAgent.session.changed', + sessionId: 'ses_late_change', reason: 'opencode-message' } })) + await act(async () => { await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) }) + const events = records(bridge).slice(baseline) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + expect(events.filter((entry) => entry.stage === 'run_started')).toHaveLength(1) + expect(events.filter((entry) => entry.stage === 'outcome_received' && entry.ran === false).length).toBeGreaterThan(0) + expect(events.filter((entry) => entry.stage === 'request_queued').length).toBeGreaterThan(1) + act(() => broadcast({ type: 'freshAgent.event', sessionType: 'freshopencode', provider: 'opencode', + sessionId: 'ses_late_change', event: { type: 'freshAgent.session.changed', sessionId: 'ses_late_change', reason: 'opencode-message' } })) + await act(async () => resetSnapshotSchedulerForTests()) + expect(records(bridge).find((entry) => entry.stage === 'outcome_received' && entry.outcome === 'coalesced')) + .toMatchObject({ ran: false }) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + } finally { cleanup(); resetSnapshotSchedulerForTests(); installPerfAuditBridge(null); vi.useRealTimers() } + }) + + it('records owned-history error completion without certifying live state or replacing identity', async () => { + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new Error('private-source-unavailable')) + const store = mount() + await waitFor(() => expect(screen.getByText('private-source-unavailable')).toBeInTheDocument()) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + expect(getFreshAgentPaneContent(store)).toMatchObject(content) + expect(records(bridge).find((entry) => entry.stage === 'outcome_received')).toMatchObject({ outcome: 'error', ran: true }) + expect(records(bridge).find((entry) => entry.stage === 'currentness_checked' && entry.check === 'error-fold')) + .toMatchObject({ stale: false }) + expect(records(bridge).some((entry) => entry.stage === 'display_committed')).toBe(false) + expect(JSON.stringify(records(bridge))).not.toContain('private-source-unavailable') + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }) +}) + // STATUS-STRIP meter seeding helper: usage lands in the unified store map // (sessions.contextUsageByKey) exactly as a committed refresh would stamp it // — fresh-page rows and extras share the map, and the strip reads nothing else. @@ -75,6 +300,11 @@ const apiMock = vi.hoisted(() => ({ post: vi.fn(), requestSessionHandoff: vi.fn(), setSessionMetadata: vi.fn().mockResolvedValue(undefined), + getManagedRuntimeInventory: vi.fn(), + retryManagedRuntimeSoul: vi.fn(), + stopManagedRuntimeSoul: vi.fn(), + updateManagedRuntimeViewVisibility: vi.fn(), + getManagedRuntimeSoul: vi.fn(), })) const saveServerSettingsPatchSpy = vi.hoisted(() => vi.fn((patch: unknown) => ({ @@ -95,6 +325,11 @@ vi.mock('@/lib/api', async () => { getFreshAgentModelCapabilities: apiMock.getFreshAgentModelCapabilities, requestSessionHandoff: apiMock.requestSessionHandoff, setSessionMetadata: apiMock.setSessionMetadata, + getManagedRuntimeInventory: apiMock.getManagedRuntimeInventory, + retryManagedRuntimeSoul: apiMock.retryManagedRuntimeSoul, + stopManagedRuntimeSoul: apiMock.stopManagedRuntimeSoul, + updateManagedRuntimeViewVisibility: apiMock.updateManagedRuntimeViewVisibility, + getManagedRuntimeSoul: apiMock.getManagedRuntimeSoul, } }) @@ -115,6 +350,7 @@ function createStore(tabTitleSetByUser = false, extraMiddleware: Middleware[] = // The status-strip context meter reads the session indexer's tokenUsage // from this slice (wsSnapshotReceived un-gates applySessionsPatch). sessions: sessionsReducer, + managedRuntime: managedRuntimeReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -264,6 +500,7 @@ function freshopencodeSnapshot(text: string, revision: number) { } beforeEach(() => { + setFreshAgentReconcileActive(false) resetSnapshotSchedulerForTests() wsMock.send.mockReset() wsMock.onMessage.mockReset() @@ -278,6 +515,11 @@ beforeEach(() => { apiMock.post.mockReset() apiMock.requestSessionHandoff.mockReset() apiMock.setSessionMetadata.mockReset() + apiMock.getManagedRuntimeInventory.mockReset() + apiMock.retryManagedRuntimeSoul.mockReset() + apiMock.stopManagedRuntimeSoul.mockReset() + apiMock.updateManagedRuntimeViewVisibility.mockReset() + apiMock.getManagedRuntimeSoul.mockReset() apiMock.post.mockResolvedValue({ title: null, source: 'none' }) apiMock.requestSessionHandoff.mockResolvedValue({ ok: true, @@ -286,6 +528,21 @@ beforeEach(() => { owner: { kind: 'terminal', terminalId: 't-default', mode: 'codex' }, }) apiMock.setSessionMetadata.mockResolvedValue(undefined) + apiMock.retryManagedRuntimeSoul.mockResolvedValue(undefined) + apiMock.getManagedRuntimeInventory.mockResolvedValue({ + revision: 1, + readiness: { + inventoryRevision: 1, + initialScanState: 'complete', + blockedSubsystems: [], + startupRecoveryConcurrencyLimit: 1, + startupRecoveryPeak: 0, + }, + souls: [], + viewIntents: [], + pendingProjectionCount: 0, + }) + resetManagedRuntimeRefreshForTest() saveServerSettingsPatchSpy.mockClear() window.localStorage.removeItem('freshopencode.modelMru.v2') window.localStorage.removeItem('freshopencode.modelLevelMru.v1') @@ -350,7 +607,683 @@ afterEach(() => { cleanup() }) +describe('managed bootstrap history', () => { + const cases = [ + { sessionType: 'freshclaude', provider: 'claude', history: savedClaudeNativeHistory, text: 'Saved native Claude answer' }, + { sessionType: 'kilroy', provider: 'claude', history: { ...savedClaudeNativeHistory, sessionType: 'kilroy' }, text: 'Saved native Claude answer' }, + { sessionType: 'freshcodex', provider: 'codex', history: savedCodexNativeHistory, text: 'Saved native Codex answer' }, + { sessionType: 'freshopencode', provider: 'opencode', history: savedOpenCodeNativeHistory, text: 'Saved native OpenCode answer' }, + ] as const + + function foldFresh(store: ReturnType) { + const request = buildReconcileRequest(store.getState() as RootState, { includeFreshAgent: true })! + return foldVerdicts(store.dispatch as AppDispatch, request, { type: 'pane.reconcile.result', + reconcileId: request.reconcileId, bootId: 'bootstrap-new-boot', serverInstanceId: 'bootstrap-server', + verdicts: [{ paneKey: request.panes[0].paneKey, verdict: 'fresh', reason: 'identity_never_observed' }] }) + } + + function mountPending(historyRead: ReturnType>, nativeRead = historyRead) { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator = { sessionType: 'freshcodex' as const, provider: 'codex' as const, sessionId: native.threadId } + const store = createStore() + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _thread, options) => options?.soulId ? historyRead.promise : nativeRead.promise) + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, + sessionRef: { provider: 'codex', sessionId: native.threadId }, resumeSessionId: native.threadId, + createRequestId: 'pending-original-request', status: 'connected', soulId: 'pending-owned-soul', soulIntentRevision: 7 } })) + foldFresh(store) + const mount = () => render() + const emit = (message: unknown) => handlers.forEach((handler) => handler(message)) + return { store, native, locator, mount, emit } + } + + afterEach(() => { cleanup(); resetSnapshotSchedulerForTests(); installPerfAuditBridge(null); vi.useRealTimers() }) + + it.each(cases.flatMap((entry) => [false, true].map((hydrated) => ({ ...entry, hydrated }))))( + 'reads current owned $sessionType history before recovery projection (hydrated=$hydrated)', async ({ sessionType, provider, history, text, hydrated }) => { + vi.useFakeTimers() + const oldRead = createDeferred() + const ownedRead = createDeferred() + const native = FreshAgentSnapshotSchema.parse(history) + const live = { ...native, capabilities: { ...native.capabilities, send: true }, + extensions: { [provider]: { statusFromLiveState: true, ownerKind: 'fresh-agent', nativeHistoryAvailable: true } } } + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + const bridge = createPerfAuditBridge() + installPerfAuditBridge(bridge) + let initialLive = hydrated + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _thread, options) => { + if (options?.soulId) return ownedRead.promise + if (initialLive) { initialLive = false; return Promise.resolve(live) } + return oldRead.promise + }) + const store = createStore() + const sessionRef = { provider, sessionId: native.threadId } + const locator = { sessionId: native.threadId, provider, sessionType } + store.dispatch(setBootId('bootstrap-old-boot')) + store.dispatch(sessionInit(locator)) + store.dispatch(applyRuntimeOwner({ type: 'session.runtimeOwner', ...locator, epoch: 2, generation: 3, + ownerKind: 'fresh-agent', transition: 'handoff-committed', operationId: 'bootstrap-owner' })) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, + sessionRef, resumeSessionId: native.threadId, createRequestId: 'bootstrap-original-request', status: 'connected', + soulId: 'bootstrap-owned-soul', soulIntentRevision: 7, + recoverySummary: { desiredState: 'running', recoveryState: 'live', durabilityState: 'resume_captured', allocationState: 'verified_durable' } } })) + try { + await act(async () => { + render() + await vi.advanceTimersByTimeAsync(0) + }) + if (hydrated) { + expect(screen.getByText(text)).toBeInTheDocument() + await act(async () => { store.dispatch(requestPaneRefresh({ tabId: 'tab-1', paneId: 'pane-1' })); await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) }) + } + const beforeResetCalls = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + expect(beforeResetCalls).toBe(hydrated ? 2 : 1) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'Draft survives original bootstrap' } }) + wsMock.send.mockClear() + await act(async () => { + store.dispatch(setBootId('bootstrap-new-boot')) + store.dispatch(markSessionLost(locator)) + expect(foldFresh(store).fresh).toBe(1) + await vi.advanceTimersByTimeAsync(0) + }) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.slice(beforeResetCalls)).toHaveLength(1) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.at(-1)).toEqual([ + sessionType, provider, native.threadId, expect.objectContaining({ soulId: 'bootstrap-owned-soul' }), + ]) + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionRef, resumeSessionId: native.threadId, + createRequestId: 'bootstrap-original-request', soulId: 'bootstrap-owned-soul', pendingReconcile: 'fresh', reconcileEpoch: 1 }) + expect(getFreshAgentPaneContent(store).sessionId).toBeUndefined() + expect(sentFreshAgentMessages('freshAgent.create')).toEqual([expect.objectContaining({ + requestId: 'bootstrap-original-request', sessionRef, tabId: 'tab-1', observedEpoch: 2, observedGeneration: 3, + })]) + await act(async () => { + for (const handler of handlers) handler({ type: 'freshAgent.create.failed', requestId: 'bootstrap-original-request', + code: 'FRESH_AGENT_CREATE_FAILED', message: 'Owned host is temporarily unavailable', retryable: true }) + }) + expect(getFreshAgentPaneContent(store).status).toBe('create-failed') + await act(async () => { await vi.advanceTimersByTimeAsync(5_001) }) + expect(getFreshAgentPaneContent(store).reconcileNotice).toBeUndefined() + await act(async () => { oldRead.resolve(live); ownedRead.resolve(native); await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText(text)).toBeInTheDocument() + expect(composer).toHaveValue('Draft survives original bootstrap') + expect(composer).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionRef, resumeSessionId: native.threadId, + soulId: 'bootstrap-owned-soul', soulIntentRevision: 7, status: 'create-failed', pendingReconcile: 'fresh', + recoverySummary: { recoveryState: 'live' } }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + const audit = bridge.snapshot().perfEvents.filter((entry) => entry.event === 'fresh_agent.snapshot_request') + expect(audit.some((entry) => entry.stage === 'currentness_checked' && entry.stale === true && entry.fence === 'boot')).toBe(true) + expect(audit.some((entry) => entry.stage === 'soul_started' && entry.source === 'direct' && entry.requestReadOnly === true)).toBe(true) + expect(audit.some((entry) => entry.stage === 'display_committed' && entry.historyOnly === true && entry.requestReadOnly === true)).toBe(true) + expect(apiMock.getManagedRuntimeInventory).not.toHaveBeenCalled() + expect(store.getState().freshAgent.sessions[`${sessionType}:${provider}:${native.threadId}`].lost).toBe(true) + } finally { + cleanup() + await act(async () => { oldRead.resolve(live); ownedRead.resolve(native); await vi.advanceTimersByTimeAsync(0) }) + resetSnapshotSchedulerForTests() + installPerfAuditBridge(null) + vi.useRealTimers() + } + }) + + it('reads a failed in-memory bootstrap after notice dismissal and remount with the same identity', async () => { + vi.useFakeTimers() + const firstRead = createDeferred() + const secondRead = createDeferred() + const fixture = mountPending(firstRead) + try { + let mounted = fixture.mount() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + await act(async () => fixture.emit({ type: 'freshAgent.create.failed', requestId: 'pending-original-request', + code: 'FRESH_AGENT_CREATE_FAILED', message: 'Host unavailable', retryable: true })) + await act(async () => { await vi.advanceTimersByTimeAsync(5_001); firstRead.resolve(fixture.native) }) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(getFreshAgentPaneContent(fixture.store).reconcileNotice).toBeUndefined() + expect(getFreshAgentPaneContent(fixture.store).status).toBe('create-failed') + const current = getFreshAgentPaneContent(fixture.store) + mounted.unmount() + resetSnapshotSchedulerForTests() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(secondRead.promise) + mounted = fixture.mount() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.at(-1)).toEqual([ + 'freshcodex', 'codex', fixture.native.threadId, expect.objectContaining({ soulId: 'pending-owned-soul' }), + ]) + await act(async () => secondRead.resolve(fixture.native)) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(getFreshAgentPaneContent(fixture.store)).toEqual(current) + // Existing create-on-mount semantics may retry once on each mount; both + // bootstraps must name the same original request and saved conversation. + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(2) + expect(sentFreshAgentMessages('freshAgent.create').every((message) => message.requestId === 'pending-original-request' + && (message.sessionRef as { sessionId: string }).sessionId === fixture.native.threadId)).toBe(true) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + expect(apiMock.getManagedRuntimeInventory).not.toHaveBeenCalled() + } finally { cleanup(); await act(async () => { firstRead.resolve(fixture.native); secondRead.resolve(fixture.native); await vi.advanceTimersByTimeAsync(0) }) } + }) + + it.each(['success', 'failure'] as const)('retains loaded transcript and draft when pending owned history returns %s', async (outcome) => { + vi.useFakeTimers() + const firstRead = createDeferred() + const nextRead = createDeferred() + const fixture = mountPending(firstRead) + try { + fixture.mount() + await act(async () => { firstRead.resolve(fixture.native); await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'Retained failed-read draft' } }) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(nextRead.promise) + await act(async () => { fixture.store.dispatch(setBootId('failed-history-next-boot')); await vi.advanceTimersByTimeAsync(0) }) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + const current = getFreshAgentPaneContent(fixture.store) + await act(async () => outcome === 'success' ? nextRead.resolve(fixture.native) : nextRead.reject(new ApiError(404, 'Owned history unavailable'))) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(composer).toHaveValue('Retained failed-read draft') + expect(composer).toBeDisabled() + expect(getFreshAgentPaneContent(fixture.store)).toEqual(current) + if (outcome === 'failure') expect(screen.getByText('Owned history unavailable')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + } finally { cleanup(); await act(async () => { firstRead.resolve(fixture.native); nextRead.resolve(fixture.native); await vi.advanceTimersByTimeAsync(0) }) } + }) + + it.each([false, true])('keeps late bootstrap history read-only and newer live truth authoritative (live first=%s)', async (liveFirst) => { + vi.useFakeTimers() + const historyRead = createDeferred() + const liveRead = createDeferred() + const fixture = mountPending(historyRead, liveRead) + try { + fixture.mount() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'Same native Send after bootstrap' } }) + await act(async () => fixture.emit({ type: 'freshAgent.created', requestId: 'pending-original-request', + ...fixture.locator, sessionRef: { provider: 'codex', sessionId: fixture.native.threadId } })) + expect(getFreshAgentPaneContent(fixture.store).pendingReconcile).toBeUndefined() + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.at(-1)?.[3]?.soulId).toBeUndefined() + const live = { ...fixture.native, capabilities: { ...fixture.native.capabilities, send: true }, + extensions: { codex: { statusFromLiveState: true, nativeHistoryAvailable: true, ownerKind: 'fresh-agent' } } } + if (!liveFirst) { + await act(async () => historyRead.resolve(fixture.native)) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(composer).toBeDisabled() + expect(composer).toHaveValue('Same native Send after bootstrap') + } + await act(async () => liveRead.resolve(live)) + if (liveFirst) await act(async () => historyRead.resolve(fixture.native)) + expect(composer).not.toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([expect.objectContaining({ sessionId: fixture.native.threadId })]) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + expect(getFreshAgentPaneContent(fixture.store)).toMatchObject({ createRequestId: 'pending-original-request', + sessionId: fixture.native.threadId, sessionRef: { provider: 'codex', sessionId: fixture.native.threadId }, soulId: 'pending-owned-soul' }) + } finally { cleanup(); await act(async () => { historyRead.resolve(fixture.native); liveRead.resolve(fixture.native); await vi.advanceTimersByTimeAsync(0) }) } + }) + + it.each(['provider', 'type', 'thread'] as const)('rejects wrong %s in a bootstrap owned-history response', async (change) => { + const held = createDeferred() + const fixture = mountPending(held) + fixture.mount() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + const current = getFreshAgentPaneContent(fixture.store) + await act(async () => held.resolve({ ...fixture.native, + ...(change === 'provider' ? { provider: 'opencode', extensions: { opencode: { ownerKind: 'vacant', nativeHistoryAvailable: true } } } : {}), + ...(change === 'type' ? { sessionType: 'kilroy' } : {}), + ...(change === 'thread' ? { threadId: 'wrong-native-source' } : {}), + })) + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(fixture.store)).toEqual(current) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + } finally { cleanup(); await act(async () => held.resolve(fixture.native)) } + }) + + it.each(['boot', 'owner', 'soul', 'revision', 'request', 'thread', 'provider', 'type'] as const)( + 'rejects a held bootstrap history response after current %s changes', async (change) => { + const held = createDeferred() + const currentRead = createDeferred() + const fixture = mountPending(held) + fixture.mount() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(currentRead.promise) + act(() => { + if (change === 'boot') fixture.store.dispatch(setBootId('next-bootstrap-boot')) + else if (change === 'owner') fixture.store.dispatch(applyRuntimeOwner({ type: 'session.runtimeOwner', ...fixture.locator, + epoch: 3, generation: 4, ownerKind: 'fresh-agent', transition: 'handoff-committed', operationId: 'next-bootstrap-owner' })) + else fixture.store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(fixture.store), + ...(change === 'soul' ? { soulId: 'next-owned-soul' } : {}), + ...(change === 'revision' ? { soulIntentRevision: 8 } : {}), + ...(change === 'request' ? { createRequestId: 'explicit-next-request' } : {}), + ...(change === 'thread' ? { sessionRef: { provider: 'codex', sessionId: 'next-native-thread' }, resumeSessionId: 'next-native-thread' } : {}), + ...(change === 'provider' ? { provider: 'opencode' } : {}), + ...(change === 'type' ? { sessionType: 'kilroy' } : {}), + } })) + }) + const current = getFreshAgentPaneContent(fixture.store) + await act(async () => held.resolve(fixture.native)) + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(fixture.store)).toEqual(current) + } finally { cleanup(); await act(async () => { held.resolve(fixture.native); currentRead.resolve(fixture.native) }) } + }) + + it('allows a legitimate current historical ref restored after a fresh reset initially cleared it', async () => { + const held = createDeferred() + const fixture = mountPending(held) + fixture.store.dispatch(resetFreshAgentPaneForReconcileCreate({ tabId: 'tab-1', paneId: 'pane-1', intent: 'fresh', reason: 'duplicate_session_claim' })) + expect(getFreshAgentPaneContent(fixture.store).sessionRef).toBeUndefined() + fixture.store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...getFreshAgentPaneContent(fixture.store), + sessionRef: { provider: 'codex', sessionId: fixture.native.threadId }, resumeSessionId: fixture.native.threadId } })) + fixture.mount() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls[0][3]).toMatchObject({ soulId: 'pending-owned-soul' }) + await act(async () => held.resolve(fixture.native)) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ requestId: 'pending-original-request', + sessionRef: { provider: 'codex', sessionId: fixture.native.threadId } }) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + } finally { cleanup(); await act(async () => held.resolve(fixture.native)) } + }) + + it.each(['fresh', 'respawn'] as const)('resolves only existing managed canonical history during pending %s', (pendingReconcile) => { + const pane: FreshAgentPaneContent = { kind: 'fresh-agent', sessionType: 'freshopencode', provider: 'opencode', + status: 'creating', createRequestId: 'original-request', soulId: 'owned-soul', pendingReconcile, + sessionRef: { provider: 'opencode', sessionId: 'ses_original' } } + expect(getManagedBootstrapHistoryThreadId(pane)).toBe('ses_original') + expect(getManagedBootstrapHistoryThreadId({ ...pane, status: 'create-failed', reconcileNotice: undefined })).toBe('ses_original') + expect(getFreshAgentSnapshotThreadId(pane, undefined)).toBeUndefined() + for (const updates of [{ soulId: undefined }, { createRequestId: '' }, { pendingReconcile: undefined }, + { sessionId: 'live-handle' }, { sessionRef: undefined }, + { sessionRef: { provider: 'claude' as const, sessionId: CLAUDE_THREAD_ID } }, + { sessionRef: { provider: 'opencode' as const, sessionId: 'freshopencode-placeholder' } }]) { + expect(getManagedBootstrapHistoryThreadId({ ...pane, ...updates })).toBeUndefined() + } + }) + + it('uses only the authoritative server-named canonical ref after a valid managed respawn fold', async () => { + const held = createDeferred() + const fixture = mountPending(held) + fixture.store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(fixture.store), sessionRef: { provider: 'codex', sessionId: 'superseded-native' }, resumeSessionId: 'superseded-native', + } })) + const request = buildReconcileRequest(fixture.store.getState() as RootState, { includeFreshAgent: true })! + const sessionRef = { provider: 'codex', sessionId: fixture.native.threadId } + const result = foldVerdicts(fixture.store.dispatch as AppDispatch, request, { type: 'pane.reconcile.result', + reconcileId: request.reconcileId, bootId: 'bootstrap-boot', serverInstanceId: 'bootstrap-server', + verdicts: [{ paneKey: request.panes[0].paneKey, verdict: 'respawn', sessionRef }] }) + expect(result.respawned).toBe(1) + fixture.mount() + try { + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls[0]).toEqual(['freshcodex', 'codex', fixture.native.threadId, + expect.objectContaining({ soulId: 'pending-owned-soul' })]) + await act(async () => held.resolve(fixture.native)) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(getFreshAgentPaneContent(fixture.store)).toMatchObject({ sessionRef, resumeSessionId: fixture.native.threadId, + pendingReconcile: 'respawn', createRequestId: 'pending-original-request' }) + expect(sentFreshAgentMessages('freshAgent.create')).toEqual([expect.objectContaining({ sessionRef, requestId: 'pending-original-request' })]) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + } finally { cleanup(); await act(async () => held.resolve(fixture.native)) } + }) + + it('clears the bootstrap historical source at the actual explicit new-conversation boundary', () => { + const held = createDeferred() + const fixture = mountPending(held) + expect(getManagedBootstrapHistoryThreadId(getFreshAgentPaneContent(fixture.store))).toBe(fixture.native.threadId) + fixture.store.dispatch(startNewManagedRuntimeConversation({ tabId: 'tab-1', paneId: 'pane-1' })) + const fresh = getFreshAgentPaneContent(fixture.store) + expect(getManagedBootstrapHistoryThreadId(fresh)).toBeUndefined() + expect(fresh.sessionRef).toBeUndefined() + expect(fresh.soulId).toBeUndefined() + expect(fresh.pendingReconcile).toBeUndefined() + expect(fresh.createRequestId).not.toBe('pending-original-request') + held.resolve(fixture.native) + }) +}) + +describe('new conversation close acceptance', () => { + const surfaces = [ + ['freshclaude', 'claude', savedClaudeNativeHistory], + ['kilroy', 'claude', { ...savedClaudeNativeHistory, sessionType: 'kilroy' }], + ['freshcodex', 'codex', savedCodexNativeHistory], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory], + ] as const + const historyText = 'Retained history during the close race' + const draftText = 'Retained unsent close-race draft' + const stopped = { outcome: 'verified_empty', soul: { soulId: 'close-race-soul', intentRevision: 17 } } + + function CurrentPane() { + const content = useAppSelector((state) => findPaneContent(state.panes.layouts['tab-1'], 'pane-1')) + if (content?.kind !== 'fresh-agent') throw new Error('Missing close-race fresh pane') + return + } + + function prepare(surface: typeof surfaces[number], scope: 'pane' | 'tab', mode: 'lost' | 'busy' | 'unmanaged' = 'lost') { + const [sessionType, provider, saved] = surface + const native = FreshAgentSnapshotSchema.parse(saved) + const snapshot = { ...native, ...(mode === 'busy' ? { status: 'running', + capabilities: { send: true, interrupt: true, fork: false }, + extensions: { [provider]: { ownerKind: 'fresh-agent', statusFromLiveState: true } } } + : mode === 'unmanaged' ? { status: 'exited' } : {}), turns: [{ id: 'close-race-turn', role: 'assistant' as const, + items: [{ id: 'close-race-text', kind: 'text' as const, text: historyText }] }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(snapshot) + const stop = createDeferred() + apiMock.stopManagedRuntimeSoul.mockReturnValue(stop.promise) + const store = createStore() + const content: FreshAgentPaneContent = { + kind: 'fresh-agent', sessionType, provider, createRequestId: 'close-race-create', + sessionRef: { provider, sessionId: native.threadId }, resumeSessionId: native.threadId, + status: mode === 'unmanaged' ? 'exited' : 'error', settingsDismissed: true, + ...(mode === 'unmanaged' ? {} : { soulId: 'close-race-soul', soulIntentRevision: 17, + recoverySummary: { desiredState: mode === 'busy' ? 'running' : 'stopped', recoveryState: mode === 'busy' ? 'live' : 'lost', + durabilityState: 'resume_captured', allocationState: 'verified_durable' } }), + ...(mode === 'unmanaged' ? { sessionId: native.threadId } : {}), + ...(mode === 'busy' ? { sessionId: native.threadId, status: 'running', + pendingLocalEcho: { requestId: 'prior-close-race-send', text: 'Retained submitted optimistic message' } } : {}), + } + if (mode === 'busy') { + store.dispatch(sessionInit({ sessionType, provider, sessionId: native.threadId })) + store.dispatch(setSessionStatus({ sessionType, provider, sessionId: native.threadId, status: 'running' })) + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + if (scope === 'pane') store.dispatch(splitPane({ tabId: 'tab-1', paneId: 'pane-1', direction: 'horizontal', + newPaneId: 'pane-2', newContent: { kind: 'picker' }, activate: false })) + const draftKey = `fresh-agent-draft:${sessionType}:${content.sessionId ?? content.createRequestId}` + sessionStorage.setItem(draftKey, draftText) + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + let closeMessage: Record | undefined + wsMock.send.mockImplementation((message) => { + if (message.type === 'pane.closed' || message.type === 'panes.closed') closeMessage = message + }) + const getContent = () => { + const current = findPaneContent(store.getState().panes.layouts['tab-1'], 'pane-1') + if (current?.kind !== 'fresh-agent') throw new Error('Missing close-race pane') + return current + } + const failClose = () => { + if (!closeMessage) return + const message = closeMessage + closeMessage = undefined + for (const handler of [...handlers]) handler({ type: `${message.type}.result`, requestId: message.requestId, + createRequestId: message.createRequestId, success: false }) + } + const startClose = () => store.dispatch(scope === 'tab' + ? closeTab('tab-1') : closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-1' })) + const isClosing = () => scope === 'tab' ? store.getState().panes.closingTabs?.['tab-1'] + : store.getState().panes.closingPanes?.['tab-1:pane-1'] + const rendered = render() + const emit = (message: unknown) => { for (const handler of [...handlers]) handler(message) } + return { store, content, stop, getContent, failClose, startClose, isClosing, rendered, draftKey, emit, snapshot } + } + + const cases = surfaces.flatMap((surface) => (['pane', 'tab'] as const).flatMap((scope) => + (['already pending', 'starts during cleanup'] as const).map((timing) => ({ surface, scope, timing })))) + + it.each(cases)('preserves $surface.0 when $scope close $timing', async ({ surface, scope, timing }) => { + const fixture = prepare(surface, scope) + let closing: ReturnType | undefined + try { + expect(await screen.findByText(historyText)).toBeInTheDocument() + const reads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + const startNew = () => fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + if (timing === 'already pending') act(() => { closing = fixture.startClose() }) + startNew() + await waitFor(() => expect(apiMock.stopManagedRuntimeSoul).toHaveBeenCalledWith('close-race-soul', 17)) + if (timing === 'starts during cleanup') act(() => { closing = fixture.startClose() }) + expect(fixture.isClosing()).toBe(true) + await act(async () => fixture.stop.resolve(stopped)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Start new conversation' })).toBeEnabled()) + const assertRetained = () => { + expect(screen.getByText(historyText)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(draftText) + expect(sessionStorage.getItem(fixture.draftKey)).toBe(draftText) + expect(fixture.getContent()).toMatchObject(fixture.content) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(reads) + for (const type of ['freshAgent.create', 'freshAgent.attach', 'freshAgent.send', 'pane.reconcile.request']) { + expect(sentFreshAgentMessages(type)).toHaveLength(0) + } + } + assertRetained() + await act(async () => { fixture.failClose(); await closing }) + expect(fixture.isClosing()).toBeFalsy() + expect(await screen.findByText(/Close failed:/)).toBeInTheDocument() + assertRetained() + } finally { + await act(async () => { fixture.stop.resolve(stopped); fixture.failClose(); await closing }) + fixture.rendered.unmount() + } + }) + + it('accepts one new identity only after a failed close is lifted and the user retries', async () => { + const fixture = prepare(surfaces[2], 'tab') + let closing: ReturnType | undefined + try { + expect(await screen.findByText(historyText)).toBeInTheDocument() + act(() => { closing = fixture.startClose() }) + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await act(async () => fixture.stop.resolve(stopped)) + expect(fixture.getContent().createRequestId).toBe(fixture.content.createRequestId) + await act(async () => { fixture.failClose(); await closing }) + expect(screen.getByText(historyText)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await waitFor(() => expect(fixture.getContent().createRequestId).not.toBe(fixture.content.createRequestId)) + const newId = fixture.getContent().createRequestId + expect(fixture.getContent().soulId).toBeUndefined() + expect(fixture.getContent().sessionRef).toBeUndefined() + expect(fixture.getContent().resumeSessionId).toBeUndefined() + expect(screen.queryByText(historyText)).toBeNull() + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ requestId: newId }) + await act(async () => {}) + expect(fixture.getContent().createRequestId).toBe(newId) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + } finally { + await act(async () => { fixture.stop.resolve(stopped); fixture.failClose(); await closing }) + fixture.rendered.unmount() + } + }) + + it.each(['refused', 'accepted'] as const)('%s new keeps or clears real queued work and persisted optimistic echo at the acceptance boundary', async (outcome) => { + const fixture = prepare(surfaces[2], 'tab', 'busy') + let closing: ReturnType | undefined + try { + await waitFor(() => expect(screen.getByText(historyText)).toBeInTheDocument()) + expect(screen.getByText('Retained submitted optimistic message')).toBeInTheDocument() + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + expect(composer).toBeEnabled() + fireEvent.change(composer, { target: { value: 'Retained queued work' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(0) + fireEvent.change(composer, { target: { value: draftText } }) + await act(async () => fixture.store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...fixture.getContent(), status: 'error', recoverySummary: { desiredState: 'stopped', recoveryState: 'lost', + durabilityState: 'resume_captured', allocationState: 'verified_durable' }, + } }))) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + const reads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + if (outcome === 'refused') act(() => { closing = fixture.startClose() }) + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await act(async () => fixture.stop.resolve(stopped)) + if (outcome === 'refused') { + expect(screen.getByText(historyText)).toBeInTheDocument() + expect(screen.getByText('Retained submitted optimistic message')).toBeInTheDocument() + expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') + expect(fixture.getContent().pendingLocalEcho).toEqual(fixture.content.pendingLocalEcho) + expect(composer).toHaveValue(draftText) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(reads) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + await act(async () => { fixture.failClose(); await closing }) + expect(screen.getByText(historyText)).toBeInTheDocument() + expect(screen.getByText('Retained submitted optimistic message')).toBeInTheDocument() + expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') + } else { + await waitFor(() => expect(fixture.getContent().createRequestId).not.toBe(fixture.content.createRequestId)) + expect(screen.queryByText(historyText)).toBeNull() + expect(screen.queryByText('Retained submitted optimistic message')).toBeNull() + expect(screen.queryByRole('status', { name: 'Queued messages' })).toBeNull() + expect(fixture.getContent().pendingLocalEcho).toBeUndefined() + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + } + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(0) + } finally { + await act(async () => { fixture.stop.resolve(stopped); fixture.failClose(); await closing }) + fixture.rendered.unmount() + } + }) + + it.each(['refused', 'accepted'] as const)('preserves unmanaged history on a late refused close and permits ordinary %s new after awaited kill', async (outcome) => { + const fixture = prepare(surfaces[2], 'tab', 'unmanaged') + let closing: ReturnType | undefined + const acknowledgeKill = () => fixture.emit({ type: 'freshAgent.killed', sessionId: fixture.content.sessionRef!.sessionId, + sessionType: 'freshcodex', provider: 'codex', success: true }) + try { + expect(await screen.findByText(historyText)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Start new session', exact: true })) + expect(sentFreshAgentMessages('freshAgent.kill')).toHaveLength(1) + expect(fixture.getContent().createRequestId).toBe(fixture.content.createRequestId) + if (outcome === 'refused') act(() => { closing = fixture.startClose() }) + await act(async () => acknowledgeKill()) + if (outcome === 'refused') { + expect(fixture.isClosing()).toBe(true) + expect(screen.getByText(historyText)).toBeInTheDocument() + expect(fixture.getContent()).toMatchObject(fixture.content) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(draftText) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + await act(async () => { fixture.failClose(); await closing }) + expect(screen.getByText(historyText)).toBeInTheDocument() + } else { + await waitFor(() => expect(fixture.getContent().createRequestId).not.toBe(fixture.content.createRequestId)) + expect(screen.queryByText(historyText)).toBeNull() + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + } + expect(apiMock.stopManagedRuntimeSoul).not.toHaveBeenCalled() + } finally { + await act(async () => { acknowledgeKill(); fixture.failClose(); await closing }) + fixture.rendered.unmount() + } + }) + + it('refuses a late managed cleanup after the displayed conversation source changes', async () => { + const fixture = prepare(surfaces[2], 'tab') + try { + expect(await screen.findByText(historyText)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + const replacement = { ...fixture.content, createRequestId: 'replacement-close-race-create', + soulId: 'replacement-close-race-soul', soulIntentRevision: 18 } + await act(async () => fixture.store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: replacement }))) + expect(await screen.findByText(historyText)).toBeInTheDocument() + await act(async () => fixture.stop.resolve(stopped)) + expect(fixture.getContent()).toMatchObject(replacement) + expect(screen.getByText(historyText)).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + } finally { + await act(async () => fixture.stop.resolve(stopped)) + fixture.rendered.unmount() + } + }) + + it('refuses an old deferred history result after an accepted new conversation', async () => { + const fixture = prepare(surfaces[2], 'tab', 'unmanaged') + const history = createDeferred() + try { + expect(await screen.findByText(historyText)).toBeInTheDocument() + const reads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(history.promise) + act(() => fixture.store.dispatch(requestPaneRefresh({ tabId: 'tab-1', paneId: 'pane-1' }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(reads)) + fireEvent.click(screen.getByRole('button', { name: 'Start new session', exact: true })) + await act(async () => fixture.emit({ type: 'freshAgent.killed', sessionId: fixture.content.sessionId, + sessionType: 'freshcodex', provider: 'codex', success: true })) + const replacement = fixture.getContent().createRequestId + expect(replacement).not.toBe(fixture.content.createRequestId) + expect(screen.queryByText(historyText)).toBeNull() + await act(async () => history.resolve(fixture.snapshot)) + expect(screen.queryByText(historyText)).toBeNull() + expect(fixture.getContent().createRequestId).toBe(replacement) + expect(fixture.getContent().sessionRef).toBeUndefined() + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ requestId: replacement }) + } finally { + await act(async () => history.resolve(fixture.snapshot)) + fixture.rendered.unmount() + } + }) +}) + describe('FreshAgentView', () => { + it('renders and dismisses a failed close while keeping the stopped managed conversation', async () => { + const store = createStore() + const handlers = new Set<(message: unknown) => void>() + wsMock.onMessage.mockImplementation((handler) => { + handlers.add(handler) + return () => { handlers.delete(handler) } + }) + wsMock.send.mockImplementation((message) => { + if (message.type === 'panes.closed') { + for (const handler of [...handlers]) handler({ + type: 'panes.closed.result', requestId: message.requestId, success: true, + }) + } + }) + apiMock.updateManagedRuntimeViewVisibility + .mockRejectedValueOnce(new Error('View update refused')) + .mockResolvedValue({ visibility: 'visible', revision: 4, soulIntentRevision: 8 }) + apiMock.getManagedRuntimeSoul.mockResolvedValue({ + soul: { soulId: 'close-retained-soul', intentRevision: 8 }, + viewIntents: [{ viewId: 'close-retained-view', visibility: 'visible', revision: 3, soulIntentRevision: 8 }], + }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ status: 'exited', turns: [ + { id: 'saved-turn', role: 'assistant', items: [{ id: 'saved-text', kind: 'text', text: 'Saved conversation remains here' }] }, + ] }) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'close-retained-create', sessionId: 'close-retained-thread', + sessionRef: { provider: 'codex' as const, sessionId: 'close-retained-thread' }, + soulId: 'close-retained-soul', viewIntentId: 'close-retained-view', + viewIntentRevision: 2, soulIntentRevision: 7, status: 'exited' as const, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + store.dispatch(sessionInit({ + sessionType: 'freshcodex', provider: 'codex', sessionId: 'close-retained-thread', + })) + store.dispatch(sessionExited({ + sessionType: 'freshcodex', provider: 'codex', sessionId: 'close-retained-thread', + })) + render() + expect(await screen.findByText('Saved conversation remains here')).toBeInTheDocument() + expect(screen.queryByText(/Close failed/)).toBeNull() + await act(async () => { await store.dispatch(closeTab('tab-1')) }) + const notice = await screen.findByText('Close failed: The pane could not be closed, so it was left open. Try again.') + expect(notice.closest('[role="alert"]')).toBeInTheDocument() + expect(screen.queryByText(/Agent error:/)).toBeNull() + expect(apiMock.updateManagedRuntimeViewVisibility).toHaveBeenCalled() + expect(screen.getByText('Saved conversation remains here')).toBeInTheDocument() + fireEvent.click(within(notice.closest('[role="alert"]') as HTMLElement).getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByText(/Close failed/)).toBeNull() + expect(getFreshAgentPaneContent(store)).toMatchObject(content) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.kill')).toHaveLength(0) + }) + describe('outgoing message queue', () => { async function setup(status = 'running', canSend = true, provider: 'codex' | 'claude' = 'codex') { const store = createStore() @@ -1617,6 +2550,45 @@ describe('FreshAgentView', () => { } }) + it('enables the managed pre-native OpenCode composer and sends without replacing its soul', async () => { + const store = createStore() + let onMessage: ((message: Record) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { onMessage = handler; return () => {} }) + const sessionId = 'managed-opencode-zero-turn' + const locator = { sessionType: 'freshopencode' as const, provider: 'opencode' as const, sessionId } + const content = {kind: 'fresh-agent' as const, ...locator, createRequestId: 'owned-zero-turn-create', soulId: 'owned-zero-turn-soul', soulIntentRevision: 1, + status: 'idle' as const, recoverySummary: {desiredState: 'running' as const, recoveryState: 'live' as const, durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const}} + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content})) + const pending = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(pending.promise) + render() + const composer = screen.getByRole('textbox', {name:'Chat message input'}) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => pending.resolve({...FreshAgentSnapshotSchema.parse(savedOpenCodeNativeHistory), threadId:sessionId, sessionId, + turns:[], latestTurnId: null, status:'idle', capabilities:{...savedOpenCodeNativeHistory.capabilities,send:true}, + extensions:{opencode:{statusFromLiveState:true,ownerKind:'fresh-agent'}}})) + await waitFor(() => expect(composer).toBeEnabled()) + wsMock.send.mockClear() + fireEvent.change(composer,{target:{value:'First prompt in the owned conversation'}}) + fireEvent.click(screen.getByRole('button',{name:'Send'})) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.send')[0]).toMatchObject(locator) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + expect(getFreshAgentPaneContent(store)).toMatchObject({soulId:content.soulId,createRequestId:content.createRequestId,sessionId}) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + await waitFor(() => expect(onMessage).toBeTypeOf('function')) + act(() => onMessage?.({type:'freshAgent.session.materialized', previousSessionId:sessionId, + sessionId:'ses_owned_first_materialized',sessionType:'freshopencode',provider:'opencode', + sessionRef:{provider:'opencode',sessionId:'ses_owned_first_materialized'}})) + await waitFor(() => expect(getFreshAgentPaneContent(store)).toMatchObject({soulId:content.soulId, + createRequestId:content.createRequestId,sessionId:'ses_owned_first_materialized', + sessionRef:{provider:'opencode',sessionId:'ses_owned_first_materialized'},resumeSessionId:'ses_owned_first_materialized'})) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }) + it('promotes Freshopencode panes when freshAgent.session.materialized arrives', async () => { const store = createStore() let onMessage: ((message: Record) => void) | undefined @@ -6021,6 +6993,1585 @@ describe('FreshAgentView', () => { expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.kill' })) }) + it.each(([ + ['freshclaude', 'claude'], ['freshcodex', 'codex'], ['freshopencode', 'opencode'], + ] as const).flatMap(([sessionType, provider]) => ( + (['blocked', 'lost', 'recovering'] as const).flatMap((recoveryState) => ( + (['running', 'starting'] as const).flatMap((status) => ( + [false, true].map((missingSoul) => ({ sessionType, provider, recoveryState, status, missingSoul })) + )) + )) + )))('loads saved history once for $provider $recoveryState with stale $status (missing soul $missingSoul)', async ({ sessionType, provider, recoveryState, status, missingSoul }) => { + vi.useFakeTimers() + try { + const store = createStore() + const sessionId = provider === 'claude' ? CLAUDE_THREAD_ID : 'quiet-history-thread' + const locator = { sessionType, provider, sessionId } + store.dispatch(sessionInit(locator)) + store.dispatch(setSessionStatus({ ...locator, status })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', capabilities: { send: true }, + turns: [{ id: 'quiet-turn', role: 'assistant', items: [{ id: 'quiet-text', kind: 'text', text: 'Retained history while awaiting a decision' }] }], + }) + const content = { + kind: 'fresh-agent' as const, ...locator, createRequestId: 'quiet-history-request', status, + soulId: missingSoul ? undefined : 'quiet-history-soul', soulIntentRevision: 12, + recoverySummary: { desiredState: 'running' as const, recoveryState, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + await act(async () => { + render() + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Retained history while awaiting a decision')).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls[0][3].soulId).toBe(content.soulId) + for (let poll = 0; poll < 5; poll += 1) { + await act(async () => { await vi.advanceTimersByTimeAsync(3_000 + SNAPSHOT_DEBOUNCE_MS) }) + } + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + expect(getFreshAgentPaneContent(store)).toEqual(content) + if (recoveryState === 'recovering') { + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' }).getAttribute('placeholder')).not.toMatch(/Starting session|Session ended|Agent is working/) + } else expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it.each([ + ['freshcodex', 'codex', savedCodexNativeHistory, 'no rollout found for thread id'], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory, 'Saved provider temporarily unavailable'], + ] as const)('retains %s identity and rendered history through automatic recovery source loss', async (sessionType, provider, captured, message) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(captured) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + const content = { kind: 'fresh-agent' as const, sessionType, provider, sessionId: native.threadId, + sessionRef: { provider, sessionId: native.threadId }, resumeSessionId: native.threadId, + createRequestId: 'automatic-source-loss', status: 'idle' as const, soulId: 'automatic-soul', soulIntentRevision: 5, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + const answer = `Saved native ${provider === 'codex' ? 'Codex' : 'OpenCode'} answer` + expect(await screen.findByText(answer)).toBeInTheDocument() + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new ApiError(404, message, { code: 'FRESH_AGENT_LOST_SESSION' })) + const recovering = { ...content, recoverySummary: { ...content.recoverySummary, recoveryState: 'recovering' as const } } + wsMock.send.mockClear() + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: recovering }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(getFreshAgentPaneContent(store)).toEqual(recovering) + expect(screen.getByText(answer)).toBeInTheDocument() + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.queryByText(message)).not.toBeInTheDocument() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it('suppresses a scheduled SESSION_RESERVED create redrive when automatic recovery takes ownership', async () => { + vi.useFakeTimers() + try { + const listeners: Array<(message: any) => void> = [] + wsMock.onMessage.mockImplementation((listener) => { listeners.push(listener); return () => {} }) + const store = createStore() + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'automatic-reserve-redrive', status: 'creating' as const, + sessionRef: { provider: 'codex' as const, sessionId: 'saved-reserve-thread' } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + await act(async () => { + render() + await vi.advanceTimersByTimeAsync(0) + }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1) + act(() => listeners.forEach((listener) => listener({ type: 'freshAgent.create.failed', + requestId: content.createRequestId, code: 'SESSION_RESERVED', retryable: true }))) + const recovering = { ...getFreshAgentPaneContent(store), soulId: 'automatic-reserve-soul', + recoverySummary: { desiredState: 'running' as const, recoveryState: 'recovering' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: recovering }))) + wsMock.send.mockClear() + await act(async () => { await vi.advanceTimersByTimeAsync(2_000) }) + expect(getFreshAgentPaneContent(store)).toEqual(recovering) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + } finally { cleanup(); vi.useRealTimers() } + }) + + it.each(([ + ['freshclaude', 'claude', savedClaudeNativeHistory], + ['freshcodex', 'codex', savedCodexNativeHistory], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory], + ] as const).flatMap(([sessionType, provider, history]) => [false, true].flatMap((reconcile) => ( + [false, true].map((hydrated) => ({ sessionType, provider, history, reconcile, hydrated })) + ))))('preserves managed $provider lost recovery until attachment truth (reconcile=$reconcile, hydrated=$hydrated)', async ({ sessionType, provider, history, reconcile, hydrated }) => { + vi.useFakeTimers() + setFreshAgentReconcileActive(reconcile) + try { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(history) + const liveSnapshot = { ...native, capabilities: { ...native.capabilities, send: true }, extensions: {} } + const locator = { sessionId: native.threadId, sessionType, provider } + store.dispatch(sessionInit(locator)) + if (hydrated) { + store.dispatch(sessionSnapshotReceived({ ...locator, latestTurnId: 'retained-turn', status: 'idle' })) + store.dispatch(historyPageReceived({ ...locator, turns: [] })) + } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(liveSnapshot) + const content = { kind: 'fresh-agent' as const, ...locator, sessionRef: { provider, sessionId: native.threadId }, + resumeSessionId: native.threadId, createRequestId: 'managed-live-lost-request', status: 'idle' as const, + soulId: 'managed-live-lost-soul', soulIntentRevision: 7, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + await act(async () => { + render() + await vi.advanceTimersByTimeAsync(0) + }) + const retainedText = provider === 'claude' ? 'Saved native Claude answer' + : provider === 'codex' ? 'Saved native Codex answer' : 'Saved native OpenCode answer' + expect(screen.getByText(retainedText)).toBeInTheDocument() + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { target: { value: 'Draft retained across recovery' } }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + await act(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(store), recoverySummary: { ...content.recoverySummary, recoveryState: 'recovering' }, + } })) + store.dispatch(setSessionStatus({ ...locator, status: hydrated ? 'stuck' : 'exited' })) + store.dispatch(markSessionLost(locator)) + await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) + }) + const recoveringContent = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + // No response is delivered until after the pending-frame assertions. + await act(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...recoveringContent, recoverySummary: { ...content.recoverySummary, recoveryState: 'live' }, + } })) + await vi.advanceTimersByTimeAsync(0) + }) + expect(getFreshAgentPaneContent(store)).toEqual({ ...recoveringContent, recoverySummary: content.recoverySummary }) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(true) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.attach')[0]).toMatchObject(locator) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + expect(screen.getByText(retainedText)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Draft retained across recovery') + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.queryByText('Restoring session...')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Resume session' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Start new session' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Restart sidecar and resume session' })).not.toBeInTheDocument() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(liveSnapshot) + await act(async () => { + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.session.snapshot', latestTurnId: 'retained-turn', status: 'idle', timelineSessionId: native.threadId, revision: 1 } }) + await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) + }) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(false) + expect(getFreshAgentPaneContent(store)).toMatchObject({ ...locator, createRequestId: content.createRequestId, + sessionRef: content.sessionRef, resumeSessionId: content.resumeSessionId, soulId: content.soulId }) + expect(screen.getByText(retainedText)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Draft retained across recovery') + expect(screen.getByRole('textbox', { name: 'Chat message input' })).not.toBeDisabled() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + } finally { cleanup(); setFreshAgentReconcileActive(false); vi.useRealTimers() } + }) + + it.each([true, false])('preserves loaded OpenCode conversation across unavailable native reads (owned history: %s)', async (historyAvailable) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(savedOpenCodeNativeHistory) + const locator = { sessionType: 'freshopencode' as const, provider: 'opencode' as const, sessionId: native.threadId } + const content = { kind: 'fresh-agent' as const, ...locator, soulId: 'read-failure-owned-soul', soulIntentRevision: 1, + createRequestId: 'read-failure-original-create', sessionRef: { provider: 'opencode', sessionId: native.threadId }, + resumeSessionId: native.threadId, status: 'idle' as const, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + const live = { ...native, revision: 1, capabilities: { ...native.capabilities, send: true }, + extensions: { opencode: { statusFromLiveState: true } } } + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(live) + render() + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + await waitFor(() => expect(composer).not.toBeDisabled()) + const retained = native.turns.flatMap((turn) => turn.items).find((item) => item.kind === 'text') as { text: string } + expect(screen.getByText(retained.text)).toBeInTheDocument() + fireEvent.change(composer, { target: { value: 'Draft in original OpenCode conversation' } }) + const identity = getFreshAgentPaneContent(store) + const interactive = createDeferred() + const owned = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _id, options) => + options?.soulId ? owned.promise : interactive.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + wsMock.send.mockClear() + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.error', code: 'INVALID_SESSION_ID', message: 'Unavailable native read' } })) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + expect(composer).toBeDisabled() + expect(screen.getByText(retained.text)).toBeInTheDocument() + expect(composer).toHaveValue('Draft in original OpenCode conversation') + expect(getFreshAgentPaneContent(store)).toEqual(identity) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + await act(async () => interactive.reject(new Error('opencode snapshot unavailable'))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshopencode', 'opencode', + native.threadId, expect.objectContaining({ soulId: content.soulId }))) + expect(screen.getByText(retained.text)).toBeInTheDocument() + expect(composer).toBeDisabled() + await act(async () => { + if (historyAvailable) owned.resolve({ ...native, status: 'idle', capabilities: { ...native.capabilities, send: false, interrupt: false }, + extensions: { opencode: { ownerKind: 'vacant', nativeHistoryAvailable: true, statusFromLiveState: false } } }) + else owned.reject(new Error('Owned provider history unavailable')) + }) + expect(screen.getByText(retained.text)).toBeInTheDocument() + expect(composer).toBeDisabled() + expect(composer).toHaveValue('Draft in original OpenCode conversation') + expect(getFreshAgentPaneContent(store)).toEqual(identity) + expect(Object.values(store.getState().freshAgent.sessions).find((row) => row.sessionId === native.threadId)?.lost).toBe(true) + expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Start new session' })).not.toBeInTheDocument() + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.queryByText('Restoring session...')).not.toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...live, revision: 2, + extensions: { opencode: { ownerKind: 'fresh-agent', statusFromLiveState: true, nativeHistoryAvailable: true } } }) + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.session.snapshot', status: 'idle', revision: 2, + latestTurnId: native.latestTurnId, timelineSessionId: native.threadId } })) + await waitFor(() => expect(composer).not.toBeDisabled()) + expect(composer).toHaveValue('Draft in original OpenCode conversation') + expect(screen.getByText(retained.text)).toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(identity) + expect(Object.values(store.getState().freshAgent.sessions).find((row) => row.sessionId === native.threadId)?.lost).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.send')[0]).toMatchObject(locator) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }) + + it.each([ + ['freshclaude', 'claude', savedClaudeNativeHistory], + ['kilroy', 'claude', { ...savedClaudeNativeHistory, sessionType: 'kilroy' }], + ['freshcodex', 'codex', savedCodexNativeHistory], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory], + ] as const)('reads managed $1 unavailable/native-only results from the captured soul source', async (sessionType, provider, captured) => { + for (const failure of [false, true]) { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(captured) + const locator = { sessionType, provider, sessionId: native.threadId } + const content = { kind: 'fresh-agent' as const, ...locator, soulId: 'owned-history-soul', soulIntentRevision: 1, + createRequestId: 'owned-history-source', sessionRef: { provider, sessionId: native.threadId }, + resumeSessionId: native.threadId, status: 'running' as const, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(sessionInit(locator)) + store.dispatch(setSessionStatus({ ...locator, status: 'running' })) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + const interactive = createDeferred() + const owned = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type, _provider, _id, options) => + options?.soulId ? owned.promise : interactive.promise) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'Owned outage draft' } }) + const before = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + await act(async () => { if (failure) interactive.reject(new Error('Owned host unavailable')) + else interactive.resolve({ ...native, extensions: { [provider]: { nativeHistoryAvailable: true, ownerKind: 'vacant' } } }) }) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith(sessionType, provider, + native.threadId, expect.objectContaining({ soulId: 'owned-history-soul' }))) + const text = native.turns.flatMap((turn) => turn.items).find((item) => item.kind === 'text') as {text: string} + expect(screen.queryByText(text.text)).not.toBeInTheDocument() + await act(async () => owned.resolve({ ...native, extensions: { [provider]: { nativeHistoryAvailable: true, ownerKind: 'vacant' } } })) + expect(await screen.findByText(text.text)).toBeInTheDocument() + expect(composer).toBeDisabled() + expect(composer).toHaveValue('Owned outage draft') + expect(getFreshAgentPaneContent(store)).toEqual(before) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + cleanup(); apiMock.getFreshAgentThreadSnapshot.mockClear() + } + }) + + it.each([ + ['freshclaude', 'claude', savedClaudeNativeHistory], + ['kilroy', 'claude', { ...savedClaudeNativeHistory, sessionType: 'kilroy' }], + ['freshcodex', 'codex', savedCodexNativeHistory], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory], + ] as const)('restores managed $1 usability from current HTTP truth without an attach snapshot', async (sessionType, provider, captured) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(captured) + const live = { ...native, extensions: { [provider]: { statusFromLiveState: true } }, + capabilities: { ...native.capabilities, send: true } } + const locator = { sessionType, provider, sessionId: native.threadId } + store.dispatch(sessionInit(locator)) + const content = { kind: 'fresh-agent' as const, ...locator, + sessionRef: { provider, sessionId: native.threadId }, resumeSessionId: native.threadId, + createRequestId: 'http-live-truth', status: 'idle' as const, soulId: 'http-live-soul', soulIntentRevision: 7, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...live, extensions: {} }) + render() + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + await waitFor(() => expect(composer).not.toBeDisabled()) + fireEvent.change(composer, { target: { value: 'Same conversation draft' } }) + const pending = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(pending.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + wsMock.send.mockClear() + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.error', code: 'INVALID_SESSION_ID', message: 'Stale session lookup' } })) + expect(composer).toBeDisabled() + expect(composer).toHaveValue('Same conversation draft') + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => pending.resolve(live)) + await waitFor(() => expect(composer).not.toBeDisabled()) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(false) + expect(getFreshAgentPaneContent(store)).toMatchObject(content) + expect(composer).toHaveValue('Same conversation draft') + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'Start new session' })).not.toBeInTheDocument() + }) + + it.each([ + ['freshclaude', 'claude', savedClaudeNativeHistory], + ['kilroy', 'claude', { ...savedClaudeNativeHistory, sessionType: 'kilroy' }], + ['freshcodex', 'codex', savedCodexNativeHistory], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory], + ] as const)('keeps $1 saved-only HTTP history display-only for ordinary and managed panes', async (sessionType, provider, captured) => { + for (const managed of [false, true]) { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(captured) + const locator = { sessionType, provider, sessionId: native.threadId } + const content = { kind: 'fresh-agent' as const, ...locator, + sessionRef: { provider, sessionId: native.threadId }, resumeSessionId: native.threadId, + createRequestId: 'saved-only-http', status: 'running' as const, + ...(managed ? { soulId: 'saved-only-soul', soulIntentRevision: 1, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } : {}) } + store.dispatch(sessionInit(locator)) + store.dispatch(setSessionStatus({ ...locator, status: 'running' })) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + const pending = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(pending.promise) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + const before = getFreshAgentPaneContent(store) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(composer, { target: { value: 'Retained outage draft' } }) + wsMock.send.mockClear() + await act(async () => pending.resolve({ ...native, revision: 5000, status: 'idle', + capabilities: { ...native.capabilities, send: false, interrupt: false }, + extensions: { [provider]: { nativeHistoryAvailable: true, ownerKind: 'vacant' } } })) + await waitFor(() => expect(composer).toBeDisabled()) + expect(getFreshAgentPaneContent(store)).toEqual(before) + expect(composer).toHaveValue('Retained outage draft') + expect(screen.queryByRole('button', { name: 'Stop' })).not.toBeInTheDocument() + const savedText = native.turns.flatMap((turn) => turn.items).find((item) => item.kind === 'text')! + expect(screen.getByText((savedText as { text: string }).text)).toBeInTheDocument() + expect(Object.values(store.getState().freshAgent.sessions).find((row) => row.sessionId === locator.sessionId)?.status).toBe('running') + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + // A current live result may retain the history-availability metadata. + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...native, revision: 1, + capabilities: { ...native.capabilities, send: true }, + extensions: { [provider]: { nativeHistoryAvailable: true, statusFromLiveState: true, ownerKind: 'fresh-agent' } } }) + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.session.snapshot', status: 'idle', latestTurnId: native.latestTurnId, revision: 1, timelineSessionId: native.threadId } })) + await waitFor(() => expect(composer).not.toBeDisabled()) + expect(getFreshAgentPaneContent(store)).toMatchObject({ ...content, status: 'idle' }) + expect(composer).toHaveValue('Retained outage draft') + cleanup() + apiMock.getFreshAgentThreadSnapshot.mockClear() + } + }) + + it.each(['owner', 'boot', 'soul', 'revision'] as const)('discards owned history held across a %s change', async (change) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator = {sessionType:'freshcodex' as const,provider:'codex' as const,sessionId:native.threadId} + const owner = {type:'session.runtimeOwner' as const,...locator,epoch:1,generation:1,ownerKind:'fresh-agent' as const,operationId:'owned-history',transition:'handoff-committed' as const} + store.dispatch(applyRuntimeOwner(owner)); store.dispatch(sessionInit(locator)) + const content = {kind:'fresh-agent' as const,...locator,status:'running' as const,createRequestId:'held-history-create',soulId:'held-history-soul',soulIntentRevision:1, + recoverySummary:{desiredState:'running' as const,recoveryState:'live' as const,durabilityState:'resume_captured' as const,allocationState:'verified_durable' as const}} + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content})) + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type,_provider,_id,options) => options?.soulId ? held.promise : Promise.reject(new Error('host unavailable'))) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex','codex',native.threadId,expect.objectContaining({soulId:content.soulId}))) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + act(() => { + if (change === 'owner') store.dispatch(applyRuntimeOwner({...owner,generation:2,operationId:'next-owner'})) + if (change === 'boot') store.dispatch(setBootId('new-owned-history-boot')) + if (change === 'soul' || change === 'revision') store.dispatch(updatePaneContent({tabId:'tab-1',paneId:'pane-1',content:{...content, + soulId:change === 'soul' ? 'another-soul' : content.soulId,soulIntentRevision:change === 'revision' ? 2 : 1}})) + }) + const current = getFreshAgentPaneContent(store) + await act(async () => held.resolve({...native,extensions:{codex:{nativeHistoryAvailable:true,ownerKind:'vacant'}}})) + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(current) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }) + + it.each(['native', 'vacant', 'wrong-thread', 'wrong-provider', 'wrong-type', 'owner-change', 'boot-change', 'recovering', 'revision-change'] as const)( + 'retains managed loss when HTTP truth is %s', async (scenario) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator = { sessionType: 'freshcodex' as const, provider: 'codex' as const, sessionId: native.threadId } + const owner = { type: 'session.runtimeOwner' as const, provider: 'codex' as const, sessionId: native.threadId, + epoch: 1, generation: 1, ownerKind: 'fresh-agent' as const, operationId: 'http-owner', transition: 'handoff-committed' as const } + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(sessionInit(locator)) + const content = { kind: 'fresh-agent' as const, ...locator, createRequestId: 'negative-http-truth', status: 'idle' as const, + sessionRef: { provider: 'codex' as const, sessionId: native.threadId }, resumeSessionId: native.threadId, + soulId: 'negative-http-soul', soulIntentRevision: 1, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...native, capabilities: { ...native.capabilities, send: true }, extensions: {} }) + render() + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + const pending = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(pending.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + act(() => store.dispatch(markSessionLost(locator))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + if (scenario === 'owner-change') { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + act(() => store.dispatch(applyRuntimeOwner({ ...owner, generation: 2, operationId: 'new-owner' }))) + } + if (scenario === 'boot-change') { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + act(() => store.dispatch(setBootId('new-snapshot-server-boot'))) + } + if (scenario === 'recovering' || scenario === 'revision-change') { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...content, soulIntentRevision: scenario === 'revision-change' ? 2 : 1, + recoverySummary: { ...content.recoverySummary, recoveryState: scenario === 'recovering' ? 'recovering' : 'live' }, + } }))) + } + const current = getFreshAgentPaneContent(store) + const result = { ...native, capabilities: { ...native.capabilities, send: true }, + sessionType: scenario === 'wrong-type' ? 'freshopencode' : native.sessionType, + threadId: scenario === 'wrong-thread' ? 'different-conversation' : native.threadId, + provider: scenario === 'wrong-provider' ? 'opencode' : 'codex', + extensions: { codex: { statusFromLiveState: true, + ...(scenario === 'native' ? { nativeHistoryAvailable: true, statusFromLiveState: false } : {}), + ...(scenario === 'vacant' ? { ownerKind: 'vacant' } : {}), + } } } + await act(async () => pending.resolve(result)) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(true) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(getFreshAgentPaneContent(store)).toEqual(current) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }, + ) + + it.each(['attach', 'respawn'] as const)('rejects an owned legacy %s verdict after supervisor recovery becomes live', async (verdict) => { + const store = createStore() + setFreshAgentReconcileActive(true) + const listeners = new Set<(message: Record) => void>() + wsMock.onMessage.mockImplementation((listener) => { listeners.add(listener); return () => listeners.delete(listener) }) + const locator = { sessionId: 'late-legacy-reconcile-thread', sessionType: 'freshcodex' as const, provider: 'codex' as const } + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', ...locator, sessionRef: { provider: 'codex', sessionId: locator.sessionId }, + resumeSessionId: locator.sessionId, createRequestId: 'late-legacy-reconcile-request', status: 'idle', + } })) + render() + act(() => store.dispatch(markSessionLost(locator))) + const request = sentFreshAgentMessages('pane.reconcile.request')[0] + expect(request).toBeDefined() + const recovering = { ...getFreshAgentPaneContent(store), soulId: 'late-legacy-reconcile-soul', + recoverySummary: { desiredState: 'running' as const, recoveryState: 'recovering' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: recovering }))) + const live = { ...recovering, recoverySummary: { ...recovering.recoverySummary, recoveryState: 'live' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: live }))) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(1) + wsMock.send.mockClear() + act(() => listeners.forEach((listener) => listener({ type: 'pane.reconcile.result', reconcileId: request.reconcileId, + serverInstanceId: 'late-legacy-server', verdicts: [{ paneKey: 'tab-1:pane-1', verdict, + sessionRef: { provider: 'codex', sessionId: locator.sessionId } }] }))) + expect(getFreshAgentPaneContent(store)).toEqual(live) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(true) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + setFreshAgentReconcileActive(false) + }) + + it('preserves unmanaged legacy lost reconciliation and accepts its owned attach verdict', async () => { + const store = createStore() + setFreshAgentReconcileActive(true) + const listeners = new Set<(message: Record) => void>() + wsMock.onMessage.mockImplementation((listener) => { listeners.add(listener); return () => listeners.delete(listener) }) + const locator = { sessionId: 'unmanaged-reconcile-thread', sessionType: 'freshcodex' as const, provider: 'codex' as const } + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, + sessionRef: { provider: 'codex', sessionId: locator.sessionId }, createRequestId: 'unmanaged-reconcile-request', status: 'idle' } })) + render() + act(() => store.dispatch(markSessionLost(locator))) + const request = sentFreshAgentMessages('pane.reconcile.request')[0] + expect(request).toBeDefined() + act(() => listeners.forEach((listener) => listener({ type: 'pane.reconcile.result', reconcileId: request.reconcileId, + serverInstanceId: 'unmanaged-server', verdicts: [{ paneKey: 'tab-1:pane-1', verdict: 'attach', + sessionRef: { provider: 'codex', sessionId: locator.sessionId } }] }))) + expect(Object.values(store.getState().freshAgent.sessions).find((session) => session.sessionId === locator.sessionId)?.lost).toBe(false) + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionId: locator.sessionId, + createRequestId: 'unmanaged-reconcile-request', serverInstanceId: 'unmanaged-server' }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + setFreshAgentReconcileActive(false) + }) + + it('ignores a queued legacy lost callback after same-session attachment truth arrives', async () => { + vi.useFakeTimers() + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}) + try { + const store = createStore() + const locator = { sessionId: 'queued-legacy-truth-thread', sessionType: 'freshcodex' as const, provider: 'codex' as const } + store.dispatch(sessionInit(locator)) + store.dispatch(sessionSnapshotReceived({ ...locator, latestTurnId: 'retained-turn', status: 'idle' })) + store.dispatch(historyPageReceived({ ...locator, turns: [] })) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, + sessionRef: { provider: 'codex', sessionId: locator.sessionId }, createRequestId: 'queued-legacy-truth-request', status: 'idle' } })) + render() + act(() => store.dispatch(markSessionLost(locator))) + const before = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.session.snapshot', latestTurnId: 'retained-turn', status: 'idle' } })) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(getFreshAgentPaneContent(store)).toEqual(before) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + } finally { clearTimeoutSpy.mockRestore(); cleanup(); vi.useRealTimers() } + }) + + it('preserves explicit resume for a managed live session after genuine exit without a lost flag', async () => { + const store = createStore() + const locator = { sessionId: 'managed-live-manual-resume', sessionType: 'freshcodex' as const, provider: 'codex' as const } + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', ...locator, createRequestId: 'managed-live-manual-request', status: 'idle', + sessionRef: { provider: 'codex', sessionId: locator.sessionId }, resumeSessionId: locator.sessionId, + soulId: 'managed-live-manual-soul', recoverySummary: { desiredState: 'running', recoveryState: 'live', + durabilityState: 'resume_captured', allocationState: 'verified_durable' }, + } })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + act(() => handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, + event: { type: 'freshAgent.exit', code: 0 } })) + fireEvent.click(screen.getByRole('button', { name: 'Resume session', exact: true })) + await waitFor(() => expect(getFreshAgentPaneContent(store).createRequestId).not.toBe('managed-live-manual-request')) + expect(getFreshAgentPaneContent(store).sessionId).toBeUndefined() + expect(getFreshAgentPaneContent(store).sessionRef).toEqual({ provider: 'codex', sessionId: locator.sessionId }) + expect(getFreshAgentPaneContent(store).resumeSessionId).toBe(locator.sessionId) + expect(sentFreshAgentMessages('freshAgent.kill')).toHaveLength(0) + }) + + it('reattaches the same managed lost conversation with the recovered owner generation', async () => { + const store = createStore() + const locator = { sessionId: 'managed-recovered-owner-thread', sessionType: 'freshcodex' as const, provider: 'codex' as const } + store.dispatch(sessionInit(locator)) + const owner = { type: 'session.runtimeOwner' as const, provider: 'codex' as const, sessionId: locator.sessionId, + epoch: 1, generation: 1, ownerKind: 'fresh-agent' as const, operationId: 'initial-owner', transition: 'handoff-committed' as const } + store.dispatch(applyRuntimeOwner(owner)) + const summary = { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, + createRequestId: 'managed-owner-request', sessionRef: { provider: 'codex', sessionId: locator.sessionId }, + resumeSessionId: locator.sessionId, status: 'idle', soulId: 'managed-owner-soul', recoverySummary: summary } })) + render() + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.attach')[0]).toMatchObject({ observedEpoch: 1, observedGeneration: 1 })) + act(() => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(store), recoverySummary: { ...summary, recoveryState: 'recovering' }, + } })) + store.dispatch(markSessionLost(locator)) + }) + act(() => store.dispatch(applyRuntimeOwner({ ...owner, generation: 2, operationId: 'recovered-owner' }))) + const recovering = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...recovering, recoverySummary: summary, + } }))) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.attach')[0]).toMatchObject({ ...locator, observedEpoch: 1, observedGeneration: 2 }) + expect(getFreshAgentPaneContent(store)).toEqual({ ...recovering, recoverySummary: summary }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('pane.reconcile.request')).toHaveLength(0) + }) + + it('stops active busy polling on managed intervention and resumes it after recovery', async () => { + vi.useFakeTimers() + try { + const store = createStore() + const locator = { sessionType: 'freshcodex' as const, provider: 'codex' as const, sessionId: 'poll-transition-thread' } + store.dispatch(sessionInit(locator)) + store.dispatch(setSessionStatus({ ...locator, status: 'running' })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ status: 'running', turns: [], capabilities: { send: true } }) + const recoverySummary = { desiredState: 'running' as const, recoveryState: 'live' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', ...locator, createRequestId: 'poll-transition-request', status: 'running', + soulId: 'poll-transition-soul', soulIntentRevision: 7, recoverySummary, + } })) + await act(async () => { + render() + await vi.advanceTimersByTimeAsync(0) + }) + const initialReads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + await act(async () => { await vi.advanceTimersByTimeAsync(3_000 + SNAPSHOT_DEBOUNCE_MS) }) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(initialReads) + await act(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(store), recoverySummary: { ...recoverySummary, recoveryState: 'blocked' }, + } })) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) }) + const interventionReads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.at(-1)?.[3].soulId).toBe('poll-transition-soul') + for (let poll = 0; poll < 5; poll += 1) { + await act(async () => { await vi.advanceTimersByTimeAsync(3_000 + SNAPSHOT_DEBOUNCE_MS) }) + } + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(interventionReads) + await act(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(store), recoverySummary: { ...recoverySummary, recoveryState: 'recovering' }, + } })) + }) + await act(async () => { await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) }) + const recoveringReads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + await act(async () => { await vi.advanceTimersByTimeAsync(3_000 + SNAPSHOT_DEBOUNCE_MS) }) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(recoveringReads) + await act(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...getFreshAgentPaneContent(store), recoverySummary: { ...recoverySummary, recoveryState: 'live' }, + } })) + await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS) + }) + const resumedReads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.at(-1)?.[3].soulId).toBeUndefined() + expect(sentFreshAgentMessages('freshAgent.attach').length).toBeGreaterThan(0) + await act(async () => { await vi.advanceTimersByTimeAsync(3_000 + SNAPSHOT_DEBOUNCE_MS) }) + expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(resumedReads) + } finally { + cleanup() + vi.useRealTimers() + } + }) + + it.each([ + ['freshclaude', 'claude', 'blocked'], ['freshclaude', 'claude', 'lost'], ['freshclaude', 'claude', 'recovering'], + ['freshcodex', 'codex', 'blocked'], ['freshcodex', 'codex', 'lost'], ['freshcodex', 'codex', 'recovering'], + ['freshopencode', 'opencode', 'blocked'], ['freshopencode', 'opencode', 'lost'], ['freshopencode', 'opencode', 'recovering'], + ] as const)('reloads saved %s/%s history during %s intervention without starting a runtime', async (sessionType, provider, recoveryState) => { + const store = createStore() + const sessionId = provider === 'claude' ? CLAUDE_THREAD_ID : 'saved-history-thread' + const locator = { sessionId, sessionType, provider } + store.dispatch(sessionInit(locator)) + store.dispatch(markSessionLost(locator)) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', capabilities: { send: true, interrupt: true, fork: true }, + turns: [{ id: 'saved-turn', turnId: 'saved-turn', source: 'durable', role: 'assistant', summary: '', + items: [{ id: 'saved-text', kind: 'text', text: 'Saved conversation before recovery' }] }], + }) + const content = { + kind: 'fresh-agent' as const, sessionType, provider, sessionId, + sessionRef: { provider, sessionId }, resumeSessionId: sessionId, + createRequestId: 'saved-history-request', status: 'stuck' as const, + soulId: 'saved-history-soul', soulIntentRevision: 12, + recoverySummary: { + desiredState: 'running' as const, recoveryState, reason: 'provider_unavailable', + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const, + }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Saved conversation before recovery')).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith(sessionType, provider, sessionId, expect.objectContaining({ soulId: 'saved-history-soul' })) + if (recoveryState === 'recovering') { + expect(screen.queryByTestId('managed-runtime-recovery-card')).not.toBeInTheDocument() + expect(screen.queryByText('Restoring session...')).not.toBeInTheDocument() + } else expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /restart sidecar and resume session/i })).not.toBeInTheDocument() + const layout = store.getState().panes.layouts['tab-1'] + expect(layout?.type === 'leaf' && layout.content).toEqual(content) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + expect(apiMock.stopManagedRuntimeSoul).not.toHaveBeenCalled() + expect(apiMock.retryManagedRuntimeSoul).not.toHaveBeenCalled() + }) + + it.each([ + ['freshclaude', 'claude', 'lost', savedClaudeNativeHistory], + ['freshclaude', 'claude', 'blocked', savedClaudeNativeHistory], + ['freshcodex', 'codex', 'lost', savedCodexNativeHistory], + ['freshopencode', 'opencode', 'lost', savedOpenCodeNativeHistory], + ] as const)( + 'shows actual history-only binary output on a cold %s/%s %s reload', async (sessionType, provider, recoveryState, captured) => { + const store = createStore() + const history = FreshAgentSnapshotSchema.parse(captured) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(history) + const content = { kind: 'fresh-agent' as const, sessionType, provider, sessionId: history.threadId, + createRequestId: 'native-reload', status: 'error' as const, soulId: 'durable-native-soul', soulIntentRevision: 7, + recoverySummary: { desiredState: 'stopped' as const, recoveryState, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText(`Saved native ${provider === 'claude' ? 'Claude' : provider === 'codex' ? 'Codex' : 'OpenCode'} answer`)).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith(sessionType, provider, history.threadId, expect.objectContaining({ soulId: content.soulId })) + expect(getFreshAgentPaneContent(store)).toEqual(content) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + }, + ) + + it('renders persisted native Codex custom tool invocation and result after cold reload', async () => { + const store = createStore() + const history = FreshAgentSnapshotSchema.parse(savedCodexTools) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(history) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: history.threadId, createRequestId: 'native-tools-reload', status: 'error' as const, + soulId: 'tools-soul', soulIntentRevision: 7, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Toggle activity details' })) + const tool = await screen.findByRole('button', { name: 'apply_patch tool call' }) + expect(screen.getAllByRole('button', { name: 'apply_patch tool call' })).toHaveLength(1) + fireEvent.click(tool) + expect(await screen.findByText(/Patch saved/)).toBeInTheDocument() + expect(screen.getByText(/\*\*\* Begin Patch/, { selector: 'pre' })).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex', 'codex', history.threadId, expect.objectContaining({ soulId: content.soulId })) + expect(getFreshAgentPaneContent(store)).toEqual(content) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(0) + }) + + it.each([ + ['freshcodex', 'codex', savedCodexNativeHistory, 0], + ['freshopencode', 'opencode', savedOpenCodeNativeHistory, 1000], + ] as const)('uses distinct live and native revision bases across %s/%s recovery', async (sessionType, provider, captured, nativeRevision) => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(captured) + native.revision = nativeRevision + const live = { ...native, revision: 100, extensions: { [provider]: { statusFromLiveState: true } }, + turns: [{ id: 'live-turn', turnId: 'live-turn', role: 'assistant' as const, source: 'durable' as const, + summary: '', items: [{ id: 'live-text', kind: 'text' as const, text: 'Previously loaded live answer' }] }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(live) + const content = { kind: 'fresh-agent' as const, sessionType, provider, sessionId: native.threadId, + createRequestId: 'revision-source-request', status: 'idle' as const, soulId: 'revision-source-soul', soulIntentRevision: 5 } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Previously loaded live answer')).toBeInTheDocument() + const loaded = getFreshAgentPaneContent(store) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + wsMock.send.mockClear() + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...loaded, + recoverySummary: { desiredState: 'running', recoveryState: 'blocked', durabilityState: 'resume_captured', allocationState: 'verified_durable' } } }))) + expect(await screen.findByText(`Saved native ${provider === 'codex' ? 'Codex' : 'OpenCode'} answer`)).toBeInTheDocument() + expect(screen.queryByText('Previously loaded live answer')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store).sessionId).toBe(native.threadId) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + // A resumed live read uses its own revision basis, even when below native history's. + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...live, revision: 101, + turns: [{ ...live.turns[0], items: [{ id: 'resumed-text', kind: 'text', text: 'Resumed live answer' }] }] }) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...loaded, + recoverySummary: { desiredState: 'running', recoveryState: 'healthy', durabilityState: 'resume_captured', allocationState: 'verified_durable' } } }))) + expect(await screen.findByText('Resumed live answer')).toBeInTheDocument() + }) + + it.each(([ + ['success', 5, 'race-soul'], ['failure', 5, 'race-soul'], + ['success', 6, 'race-soul'], ['failure', 6, 'race-soul'], + ['success', 5, 'replaced-race-soul'], ['failure', 5, 'replaced-race-soul'], + ['success', 5, undefined], ['failure', 5, undefined], + ] as const).flatMap(([outcome, currentRevision, currentSoulId]) => (['blocked', 'recovering'] as const).map((recoveryState) => ({ outcome, currentRevision, currentSoulId, recoveryState }))))('ignores an ordinary snapshot $outcome after $recoveryState history at revision $currentRevision for $currentSoulId', async ({ outcome, currentRevision, currentSoulId, recoveryState }) => { + const store = createStore() + let resolveLive!: (value: unknown) => void + let rejectLive!: (error: Error) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve, reject) => { resolveLive = resolve; rejectLive = reject })) + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: native.threadId, createRequestId: 'live-to-native-race', status: 'idle' as const, + soulId: currentSoulId === undefined ? undefined : 'race-soul', soulIntentRevision: 5 } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + const blocked = { ...content, soulId: currentSoulId, soulIntentRevision: currentRevision, + recoverySummary: { desiredState: 'running' as const, recoveryState, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: blocked }))) + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + const identity = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + await act(async () => { + if (outcome === 'failure') rejectLive(new ApiError(404, 'Old ordinary snapshot failed', { code: 'FRESH_AGENT_LOST_SESSION' })) + else resolveLive({ ...native, revision: 999, extensions: { codex: { statusFromLiveState: true } }, + turns: [{ id: 'old-live', turnId: 'old-live', role: 'assistant', summary: '', items: [{ id: 'old-live-text', kind: 'text', text: 'Old ordinary snapshot answer' }] }] }) + }) + expect(screen.getByText('Saved native Codex answer')).toBeInTheDocument() + expect(screen.queryByText('Old ordinary snapshot answer')).not.toBeInTheDocument() + expect(screen.queryByText(/Old ordinary snapshot failed/)).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(identity) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it.each(['success', 'failure'] as const)('keeps an ordinary snapshot %s fenced after Retry while both current reads are pending', async (outcome) => { + const store = createStore() + let resolveOld!: (value: unknown) => void + let rejectOld!: (error: Error) => void + let resolveNative!: (value: unknown) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject })) + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: native.threadId, createRequestId: 'retry-with-old-live-read', status: 'idle' as const, + soulId: 'retry-read-soul', soulIntentRevision: 5 } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve) => { resolveNative = resolve })) + const blocked = { ...content, recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: blocked }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + apiMock.retryManagedRuntimeSoul.mockImplementation(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...blocked, + recoverySummary: { ...blocked.recoverySummary, recoveryState: 'recovering' } } })) + }) + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + await waitFor(() => expect(getFreshAgentPaneContent(store).recoverySummary?.recoveryState).toBe('recovering')) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1) + // Automatic recovery keeps the history source; live authority starts a new ordinary read. + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...blocked, + recoverySummary: { ...blocked.recoverySummary, recoveryState: 'live' } } }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(3)) + const current = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + await act(async () => { + if (outcome === 'failure') rejectOld(new ApiError(404, 'Before-Retry ordinary read failed', { code: 'FRESH_AGENT_LOST_SESSION' })) + else resolveOld({ ...native, revision: 999, extensions: { codex: { statusFromLiveState: true } }, + turns: [{ id: 'pre-retry', turnId: 'pre-retry', role: 'assistant', summary: '', items: [{ id: 'pre-retry-text', kind: 'text', text: 'Before-Retry ordinary answer' }] }] }) + }) + expect(screen.queryByText('Before-Retry ordinary answer')).not.toBeInTheDocument() + expect(screen.queryByText(/Before-Retry ordinary read failed/)).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(current) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + // The initial saved-history read is still useful and has no live actor authority. + await act(async () => resolveNative(native)) + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toEqual(current) + }) + + it('keeps saved native history through a vacant resumed read and accepts an authoritative live empty update', async () => { + const store = createStore() + const native = FreshAgentSnapshotSchema.parse(savedOpenCodeNativeHistory) + native.revision = 1000 + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshopencode' as const, provider: 'opencode' as const, + sessionId: native.threadId, sessionRef: { provider: 'opencode' as const, sessionId: native.threadId }, resumeSessionId: native.threadId, + createRequestId: 'native-to-vacant-read', status: 'idle' as const, soulId: 'native-vacant-soul', soulIntentRevision: 5, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Saved native OpenCode answer')).toBeInTheDocument() + const empty = { ...native, revision: 0, latestTurnId: null, turns: [], + extensions: { opencode: { ownerKind: 'vacant', ownerEpoch: 1, ownerGeneration: 2 } } } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(empty) + const recovering = { ...content, recoverySummary: { ...content.recoverySummary, recoveryState: 'recovering' as const } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: recovering }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(screen.getByText('Saved native OpenCode answer')).toBeInTheDocument() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...empty, + extensions: { opencode: { statusFromLiveState: true } } }) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...recovering, soulIntentRevision: 6, + recoverySummary: { ...recovering.recoverySummary, recoveryState: 'live' } } }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(screen.queryByText('Saved native OpenCode answer')).not.toBeInTheDocument()) + expect(getFreshAgentPaneContent(store).sessionId).toBe(native.threadId) + }) + + it('ignores initial native history after a newer resumed live snapshot has rendered', async () => { + const store = createStore() + let resolveNative!: (value: unknown) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve) => { resolveNative = resolve })) + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: native.threadId, createRequestId: 'native-to-live-race', status: 'idle' as const, + soulId: 'native-to-live-soul', soulIntentRevision: 5, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...native, revision: 101, extensions: { codex: { statusFromLiveState: true } }, + turns: [{ id: 'new-live', turnId: 'new-live', role: 'assistant', summary: '', items: [{ id: 'new-live-text', kind: 'text', text: 'New resumed live answer' }] }] }) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, + recoverySummary: { ...content.recoverySummary, recoveryState: 'healthy' } } }))) + expect(await screen.findByText('New resumed live answer')).toBeInTheDocument() + await act(async () => resolveNative(native)) + expect(screen.getByText('New resumed live answer')).toBeInTheDocument() + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + }) + + it('keeps an initial history read read-only when Retry recovery clears intervention', async () => { + const store = createStore() + let resolveHistory!: (result: unknown) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve) => { resolveHistory = resolve })) + // Hold the resumed runtime's ordinary snapshot independently of the cold history read. + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: savedCodexNativeHistory.threadId, createRequestId: 'retry-history-request', status: 'error' as const, + soulId: 'retry-history-soul', soulIntentRevision: 1, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + apiMock.retryManagedRuntimeSoul.mockImplementation(async () => { + store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, + status: 'starting', recoverySummary: { ...content.recoverySummary, recoveryState: 'recovering' } } })) + }) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + await waitFor(() => expect(getFreshAgentPaneContent(store).status).toBe('starting')) + await act(async () => resolveHistory(savedCodexNativeHistory)) + expect(await screen.findByText('Saved native Codex answer')).toBeInTheDocument() + expect(getFreshAgentPaneContent(store).status).toBe('starting') + expect(getFreshAgentPaneContent(store).resumeSessionId).toBeUndefined() + }) + + it('fences initial history against a newer same-soul intent revision', async () => { + const store = createStore() + let resolveOld!: (result: unknown) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: savedCodexNativeHistory.threadId, createRequestId: 'revision-history-request', status: 'error' as const, + soulId: 'same-soul', soulIntentRevision: 1, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + const next = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + next.turns[1].items[0] = { id: 'revision-answer', kind: 'text', text: 'Current revision history' } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(next) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, soulIntentRevision: 2 } }))) + expect(await screen.findByText('Current revision history')).toBeInTheDocument() + await act(async () => resolveOld(savedCodexNativeHistory)) + expect(screen.getByText('Current revision history')).toBeInTheDocument() + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + }) + + it('does not apply late history from a replaced soul with otherwise identical pane identity', async () => { + const store = createStore() + let resolveOld!: (result: unknown) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: savedCodexNativeHistory.threadId, createRequestId: 'shared-presentation', status: 'error' as const, + soulId: 'old-soul', soulIntentRevision: 1, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + const next = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + next.turns[1].items[0] = { id: 'next-answer', kind: 'text', text: 'Current soul answer' } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(next) + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, soulId: 'new-soul' } }))) + expect(await screen.findByText('Current soul answer')).toBeInTheDocument() + await act(async () => resolveOld(savedCodexNativeHistory)) + expect(screen.getByText('Current soul answer')).toBeInTheDocument() + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + }) + + it.each(['success', 'failure'] as const)('ignores delayed managed native history %s after explicit replacement', async (outcome) => { + const store = createStore() + let resolveHistory!: (result: unknown) => void + let rejectHistory!: (error: Error) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(new Promise((resolve, reject) => { resolveHistory = resolve; rejectHistory = reject })) + apiMock.stopManagedRuntimeSoul.mockResolvedValue({ outcome: 'verified_empty', soul: { soulId: 'old-native-soul', intentRevision: 4 } }) + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + sessionId: savedCodexNativeHistory.threadId, createRequestId: 'old-native-request', status: 'error' as const, + soulId: 'old-native-soul', soulIntentRevision: 4, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex', 'codex', content.sessionId, expect.objectContaining({ soulId: content.soulId }))) + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await waitFor(() => expect(getFreshAgentPaneContent(store).createRequestId).not.toBe(content.createRequestId)) + await act(async () => { + if (outcome === 'success') resolveHistory(savedCodexNativeHistory) + else rejectHistory(new Error('Old native helper failure')) + }) + expect(screen.queryByText('Saved native Codex answer')).not.toBeInTheDocument() + expect(screen.queryByText('Old native helper failure')).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store).soulId).toBeUndefined() + expect(getFreshAgentPaneContent(store).sessionId).toBeUndefined() + }) + + it.each([ + ['freshcodex', 'codex', 'blocked'], ['freshcodex', 'codex', 'lost'], + ['freshopencode', 'opencode', 'blocked'], ['freshopencode', 'opencode', 'lost'], + ] as const)('keeps loaded %s/%s turns when %s history GET returns a cold empty snapshot', async (sessionType, provider, recoveryState) => { + const store = createStore() + const sessionId = 'loaded-history-thread' + // Native cold GETs stamp a vacant owner and idle/empty transcript, even + // when the durable conversation still exists. Neither provider resumes. + const cold = FreshAgentSnapshotSchema.parse({ + sessionType, provider, threadId: sessionId, + ...(provider === 'opencode' ? { sessionId, latestTurnId: null } : { summary: '' }), + revision: 0, status: 'idle', + capabilities: { send: true, interrupt: provider === 'opencode', approvals: false, questions: false, + fork: true, worktrees: false, diffs: provider === 'opencode', childThreads: false, + undo: provider === 'opencode', redo: provider === 'opencode' }, + tokenUsage: { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0 }, + pendingApprovals: [], pendingQuestions: [], worktrees: [], diffs: [], childThreads: [], turns: [], + extensions: { [provider]: { ownerKind: 'vacant', ownerEpoch: 1, ownerGeneration: 2 } }, + }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...cold, + extensions: { [provider]: { statusFromLiveState: true } }, + turns: [{ id: 'loaded-turn', turnId: 'loaded-turn', source: 'durable', role: 'assistant', summary: '', + items: [{ id: 'loaded-text', kind: 'text', text: 'Already loaded durable conversation' }] }], + }) + const content = { + kind: 'fresh-agent' as const, sessionType, provider, sessionId, + sessionRef: { provider, sessionId }, resumeSessionId: sessionId, createRequestId: 'loaded-history-request', + status: 'idle' as const, soulId: 'loaded-history-soul', soulIntentRevision: 12, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Already loaded durable conversation')).toBeInTheDocument() + let resolveCold!: (snapshot: typeof cold) => void + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise((resolve) => { resolveCold = resolve })) + wsMock.send.mockClear() + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...content, recoverySummary: { desiredState: 'running', recoveryState, reason: 'provider_unavailable', + durabilityState: 'resume_captured', allocationState: 'verified_durable' }, + } }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2)) + await act(async () => resolveCold(cold)) + expect(screen.getByText('Already loaded durable conversation')).toBeInTheDocument() + expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + + // A real authoritative empty update still replaces the transcript, even + // while intervention is visible; only the cold unavailable read is kept. + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...cold, revision: 1, + extensions: { [provider]: { statusFromLiveState: true } }, + }) + act(() => store.dispatch(markSessionLost({ sessionType, provider, sessionId }))) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(screen.queryByText('Already loaded durable conversation')).not.toBeInTheDocument()) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it('shows a lost Claude history refusal without claiming that runtime restoration is pending', async () => { + const store = createStore() + const locator = { sessionType: 'freshclaude' as const, provider: 'claude' as const, sessionId: CLAUDE_THREAD_ID } + store.dispatch(sessionInit(locator)) + store.dispatch(markSessionLost(locator)) + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new ApiError(404, 'Saved Claude transcript could not be read', { + code: 'FRESH_AGENT_LOST_SESSION', + })) + const content = { + kind: 'fresh-agent' as const, ...locator, sessionRef: { provider: locator.provider, sessionId: locator.sessionId }, + resumeSessionId: locator.sessionId, createRequestId: 'claude-history-refused', status: 'idle' as const, + soulId: 'claude-history-soul', soulIntentRevision: 12, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, reason: 'provider_unavailable', + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText(/Saved Claude transcript could not be read/)).toBeInTheDocument() + expect(screen.queryByText('Restoring session...')).not.toBeInTheDocument() + expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + const layout = store.getState().panes.layouts['tab-1'] + expect(layout?.type === 'leaf' && layout.content).toEqual(content) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it.each([ + ['blocked', 'opencode', false], ['lost', 'opencode', false], + ['blocked', 'codex', true], ['lost', 'codex', true], + ] as const)('keeps %s %s history snapshot refusal read-only (missing soul %s)', async (recoveryState, provider, missingSoul) => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockRejectedValue(new ApiError(409, 'Saved history is temporarily unavailable', { + code: 'RESTORE_UNAVAILABLE', ownerGeneration: 8, ownerKind: 'fresh-agent', + })) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', sessionType: provider === 'codex' ? 'freshcodex' : 'freshopencode', provider, + sessionId: 'saved-refused-thread', sessionRef: { provider, sessionId: 'saved-refused-thread' }, + createRequestId: 'saved-refused-request', status: 'idle', soulId: missingSoul ? undefined : 'saved-refused-soul', soulIntentRevision: 12, + recoverySummary: { desiredState: 'running', recoveryState, reason: 'provider_unavailable', durabilityState: 'resume_captured', allocationState: 'verified_durable' }, + } })) + render() + expect(await screen.findByText(/Saved history is temporarily unavailable/)).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + const layout = store.getState().panes.layouts['tab-1'] + expect(layout?.type === 'leaf' && layout.content).toMatchObject({ sessionId: 'saved-refused-thread', createRequestId: 'saved-refused-request', soulIntentRevision: 12 }) + }) + + it.each(['blocked', 'lost'] as const)( + 'does not re-drive a managed %s projection from the Fresh Agent .lost recovery effect', + async (recoveryState) => { + vi.useFakeTimers() + try { + const store = createStore() + const locator = { + sessionId: 'managed-recovery-thread', + sessionType: 'freshcodex' as const, + provider: 'codex' as const, + } + store.dispatch(sessionInit(locator)) + store.dispatch(sessionSnapshotReceived({ + ...locator, + latestTurnId: 'turn-before-loss', + status: 'idle', + })) + store.dispatch(historyPageReceived({ + ...locator, + turns: [], + })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'managed-recovery-create', + sessionId: locator.sessionId, + sessionRef: { provider: 'codex', sessionId: locator.sessionId }, + status: 'idle', + soulId: 'managed-soul', + soulIntentRevision: 12, + recoverySummary: { + desiredState: 'running', + recoveryState, + reason: 'provider_unavailable', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + }, + }, + })) + + render( + + + , + ) + + expect(screen.getByTestId('managed-runtime-recovery-card')).toBeInTheDocument() + wsMock.send.mockClear() + + act(() => store.dispatch(markSessionLost(locator))) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(sentFreshAgentMessages('freshAgent.create').filter((message) => ( + !message.sessionRef && !message.resumeSessionId + ))).toHaveLength(0) + expect(wsMock.send.mock.calls.some(([message]) => ( + message?.type === 'pane.reconcile.request' + ))).toBe(false) + } finally { + vi.useRealTimers() + } + }, + ) + + it.each(['lost', 'live'] as const)('does not re-drive a deferred .lost callback after a managed %s projection arrives', async (recoveryState) => { + vi.useFakeTimers() + // Keep the callback alive through the projection update so this test + // exercises the callback's own managed-runtime guard, not only the effect + // guard. The callback is still driven by the real fake-timer queue below. + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}) + try { + const store = createStore() + const locator = { + sessionId: 'managed-deferred-recovery-thread', + sessionType: 'freshcodex' as const, + provider: 'codex' as const, + } + store.dispatch(sessionInit(locator)) + store.dispatch(sessionSnapshotReceived({ + ...locator, + latestTurnId: 'turn-before-loss', + status: 'idle', + })) + store.dispatch(historyPageReceived({ ...locator, turns: [] })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'managed-deferred-recovery-create', + sessionId: locator.sessionId, + sessionRef: { provider: 'codex', sessionId: locator.sessionId }, + status: 'idle', + }, + })) + + render( + + + , + ) + wsMock.send.mockClear() + + act(() => store.dispatch(markSessionLost(locator))) + const current = getFreshAgentPaneContent(store) + act(() => store.dispatch(updatePaneContent({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + ...current, + soulId: 'managed-deferred-soul', + soulIntentRevision: 13, + recoverySummary: { + desiredState: 'running', + recoveryState, + reason: 'provider_unavailable', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + }, + }, + }))) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionId: locator.sessionId, + createRequestId: current.createRequestId, soulId: 'managed-deferred-soul' }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + expect(wsMock.send.mock.calls.some(([message]) => ( + message?.type === 'pane.reconcile.request' + ))).toBe(false) + } finally { + clearTimeoutSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('retries a blocked managed Fresh Agent with its current soul revision and refreshes inventory', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshcodex', + provider: 'codex', + createRequestId: 'managed-retry-create', + sessionId: 'managed-retry-thread', + sessionRef: { provider: 'codex', sessionId: 'managed-retry-thread' }, + status: 'error', + soulId: 'managed-retry-soul', + soulIntentRevision: 19, + recoverySummary: { + desiredState: 'running', + recoveryState: 'blocked', + reason: 'provider_unavailable', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + }, + }, + })) + + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + + await waitFor(() => { + expect(apiMock.retryManagedRuntimeSoul).toHaveBeenCalledWith('managed-retry-soul', 19) + expect(apiMock.getManagedRuntimeInventory).toHaveBeenCalledTimes(1) + }) + }) + + it('clears prior managed retry feedback when a different Fresh Agent conversation occupies the pane', async () => { + apiMock.retryManagedRuntimeSoul.mockRejectedValueOnce(new Error('Old conversation repair failed')) + const store = createStore() + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex', + createRequestId: 'old-retry-create', status: 'error' as const, soulId: 'old-retry-soul', soulIntentRevision: 19, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, reason: 'STORE_UNREADABLE', + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Retry recovery' })) + expect(await within(screen.getByTestId('managed-runtime-recovery-card')).findByRole('status')).toHaveTextContent('Old conversation repair failed') + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { + ...content, createRequestId: 'new-retry-create', soulId: 'new-retry-soul', + } }))) + expect(within(screen.getByTestId('managed-runtime-recovery-card')).queryByRole('status')).toBeNull() + }) + + it.each(['repair', 'reason', 'stale_result', 'stale_error', 'different_create', 'different_soul'] as const)('handles a managed Fresh Agent retry: %s', async (scenario) => { + let resolve!: (value: unknown) => void + let reject!: (error: Error) => void + apiMock.retryManagedRuntimeSoul.mockReturnValueOnce(new Promise((res, rej) => { resolve = res; reject = rej })) + const store = createStore() + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex', + createRequestId: 'retry-create', status: 'error' as const, soulId: 'retry-soul', soulIntentRevision: 19, + recoverySummary: { desiredState: 'running' as const, recoveryState: 'blocked' as const, reason: 'STORE_UNREADABLE', + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + const card = screen.getByTestId('managed-runtime-recovery-card') + fireEvent.click(within(card).getByRole('button', { name: 'Retry recovery' })) + expect(apiMock.retryManagedRuntimeSoul).toHaveBeenCalledWith('retry-soul', 19) + const stale = scenario.startsWith('stale') || scenario.startsWith('different') + if (stale) act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, + ...(scenario === 'different_create' ? { createRequestId: 'another-create' } + : scenario === 'different_soul' ? { soulId: 'another-soul' } : { soulIntentRevision: 20 }), + } }))) + await act(async () => { + if (scenario === 'stale_error') reject(new Error('Obsolete retry failure')) + else resolve({ outcome: 'blocked', view: { soulId: 'retry-soul', intentRevision: 19, recoveryReason: 'OLD_RUNTIME_NOT_EMPTY' }, + probe: { kind: 'blocked', data: { reason: 'OLD_RUNTIME_NOT_EMPTY', retry_hint: { manualRetry: true, + ...(scenario === 'reason' ? {} : { repair: 'Confirm the old process has stopped, then retry.' }) } } } }) + }) + if (stale) expect(within(card).queryByRole('status')).toBeNull() + else expect(await within(card).findByRole('status')).toHaveTextContent(scenario === 'repair' + ? 'Confirm the old process has stopped, then retry.' : 'The previous agent process could not be confirmed stopped. Check it before retrying recovery.') + }) + + const retainedBeforeStartNewSnapshot = { status: 'idle', turns: [ + { id: 'retained-turn', role: 'assistant', items: [{ id: 'retained-text', kind: 'text', text: 'Conversation retained before starting new' }] }, + ] } + + it.each([['freshclaude', 'claude'], ['kilroy', 'claude'], ['freshcodex', 'codex'], ['freshopencode', 'opencode']] as const)('explains retained %s history without permitting writes or replacing the conversation', async (sessionType, provider) => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...retainedBeforeStartNewSnapshot, provider, sessionType, + threadId: CLAUDE_RESTORE_THREAD_ID, sessionId: CLAUDE_RESTORE_THREAD_ID, revision: 1, + extensions: { [provider]: { nativeHistoryAvailable: true, nativeHistoryRetention: { partial: true, + omittedNativeTurns: 12, omittedItems: 0, omittedBodies: 1, firstTurnId: 'retained-turn', lastTurnId: 'retained-turn' } } }, + capabilities: { send: false, interrupt: false, fork: false }, + }) + const content = { kind: 'fresh-agent' as const, sessionType, provider, createRequestId: 'retained-history-create', + sessionRef: { provider, sessionId: CLAUDE_RESTORE_THREAD_ID }, status: 'error' as const, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const } } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Conversation retained before starting new')).toBeInTheDocument() + expect(screen.getByRole('note', { name: 'Retained conversation history' })).toHaveTextContent( + 'Showing retained conversation history. Older turns or large content were omitted from this view. The saved conversation has not been changed.') + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(getFreshAgentPaneContent(store)).toEqual(content) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it('reads a cold lost conversation without a soul as history without adopting live status or starting a runtime', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...retainedBeforeStartNewSnapshot, + status: 'running', capabilities: { send: true, interrupt: true, fork: true }, + extensions: { codex: { statusFromLiveState: true } }, + }) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'cold-lost-create', sessionRef: { provider: 'codex' as const, sessionId: 'cold-lost-thread' }, + status: 'error' as const, closeError: 'Previous close was not confirmed', soulIntentRevision: 9, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Conversation retained before starting new')).toBeInTheDocument() + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex', 'codex', 'cold-lost-thread', expect.not.objectContaining({ soulId: expect.anything() })) + expect(getFreshAgentPaneContent(store)).toEqual(content) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeDisabled() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + expect(await screen.findByRole('status')).toHaveTextContent('Your conversation has been kept') + expect(getFreshAgentPaneContent(store)).toEqual(content) + expect(screen.getByText('Conversation retained before starting new')).toBeInTheDocument() + expect(screen.getByText('Close failed: Previous close was not confirmed')).toBeInTheDocument() + expect(apiMock.stopManagedRuntimeSoul).not.toHaveBeenCalled() + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: expect.stringMatching(/^freshAgent\.|^pane\.reconcile/) })) + }) + + it.each([ + ['freshclaude', 'claude'], ['kilroy', 'claude'], ['freshcodex', 'codex'], ['freshopencode', 'opencode'], + ] as const)('clears a failed close only after stopping the persisted managed soul and replacing a restored %s conversation', async (sessionType, provider) => { + const store = createStore() + let resolveStop!: (result: unknown) => void + apiMock.stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve) => { resolveStop = resolve })) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(retainedBeforeStartNewSnapshot) + const content = { + kind: 'fresh-agent' as const, sessionType, provider, createRequestId: 'lost-restored-create', + sessionRef: { provider, sessionId: CLAUDE_RESTORE_THREAD_ID }, status: 'error' as const, + soulId: 'persisted-lost-soul', soulIntentRevision: 17, + closeError: 'Previous close was not confirmed', + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Conversation retained before starting new')).toBeInTheDocument() + expect(screen.getByText('Close failed: Previous close was not confirmed')).toBeInTheDocument() + wsMock.send.mockClear() + + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await waitFor(() => expect(apiMock.stopManagedRuntimeSoul).toHaveBeenCalledWith('persisted-lost-soul', 17)) + expect(getFreshAgentPaneContent(store)).toMatchObject(content) + expect(screen.getByText('Close failed: Previous close was not confirmed')).toBeInTheDocument() + expect(screen.getByText('Conversation retained before starting new')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.kill')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + await act(async () => resolveStop({ outcome: 'verified_empty', soul: { soulId: 'persisted-lost-soul', intentRevision: 17 } })) + await waitFor(() => expect(getFreshAgentPaneContent(store).createRequestId).not.toBe(content.createRequestId)) + expect(getFreshAgentPaneContent(store).soulId).toBeUndefined() + expect(getFreshAgentPaneContent(store).sessionRef).toBeUndefined() + expect(getFreshAgentPaneContent(store).closeError).toBeUndefined() + expect(screen.queryByText('Close failed: Previous close was not confirmed')).toBeNull() + }) + + it.each(['termination_unconfirmed', 'blocked_ownership', 'backend_unavailable', 'http_failure', 'missing_revision', 'missing_soul'])( + 'retains a lost managed Fresh Agent and reports %s cleanup inline', async (outcome) => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(retainedBeforeStartNewSnapshot) + if (outcome === 'http_failure') apiMock.stopManagedRuntimeSoul.mockRejectedValueOnce(new Error('Server is unavailable')) + else apiMock.stopManagedRuntimeSoul.mockResolvedValueOnce({ outcome, soul: { soulId: 'lost-soul', intentRevision: 9 } }) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'lost-create', sessionId: 'lost-thread', + sessionRef: { provider: 'codex' as const, sessionId: 'lost-thread' }, status: 'error' as const, + soulId: outcome === 'missing_soul' ? undefined : 'lost-soul', + soulIntentRevision: outcome === 'missing_revision' ? undefined : 9, + closeError: 'Previous close was not confirmed', + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + expect(await screen.findByText('Conversation retained before starting new')).toBeInTheDocument() + expect(screen.getByText('Close failed: Previous close was not confirmed')).toBeInTheDocument() + const retained = getFreshAgentPaneContent(store) + wsMock.send.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + expect(await screen.findByRole('status')).toHaveTextContent(outcome === 'http_failure' ? 'Server is unavailable' : 'Your conversation has been kept') + expect(getFreshAgentPaneContent(store)).toMatchObject(retained) + expect(screen.getByText('Close failed: Previous close was not confirmed')).toBeInTheDocument() + expect(screen.getByText('Conversation retained before starting new')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.kill')).toHaveLength(0) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + }, + ) + + it.each(['verified_empty', 'termination_unconfirmed', 'http_failure'])('does not alter a different Fresh Agent pane after a late %s stop result', async (outcome) => { + const store = createStore() + let resolveStop!: (result: unknown) => void + let rejectStop!: (error: Error) => void + apiMock.stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve, reject) => { resolveStop = resolve; rejectStop = reject })) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'old-lost-create', sessionRef: { provider: 'codex' as const, sessionId: 'old-thread' }, + status: 'error' as const, soulId: 'old-soul', soulIntentRevision: 11, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await waitFor(() => expect(apiMock.stopManagedRuntimeSoul).toHaveBeenCalledWith('old-soul', 11)) + const replacement = { ...content, createRequestId: 'different-create', soulId: 'different-soul', + sessionRef: { provider: 'codex' as const, sessionId: 'different-thread' } } + act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: replacement }))) + await act(async () => { + if (outcome === 'http_failure') rejectStop(new Error('Stale request failed')) + else resolveStop({ outcome, soul: { soulId: 'old-soul', intentRevision: 12 } }) + }) + expect(getFreshAgentPaneContent(store)).toMatchObject(replacement) + expect(within(screen.getByTestId('managed-runtime-recovery-card')).queryByRole('status')).toBeNull() + }) + + it.each(['newer_pane', 'older_result', 'wrong_soul'])('does not replace Fresh Agent authority after a %s stop response', async (scenario) => { + const store = createStore() + let resolveStop!: (value: unknown) => void + apiMock.stopManagedRuntimeSoul.mockReturnValueOnce(new Promise((resolve) => { resolveStop = resolve })) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'same-create', status: 'error' as const, soulId: 'same-soul', soulIntentRevision: 21, + sessionRef: { provider: 'codex' as const, sessionId: 'retained-thread' }, + recoverySummary: { desiredState: 'stopped' as const, recoveryState: 'lost' as const, + durabilityState: 'resume_captured' as const, allocationState: 'verified_durable' as const }, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + if (scenario === 'newer_pane') act(() => store.dispatch(updatePaneContent({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, soulIntentRevision: 24 } }))) + await act(async () => resolveStop({ outcome: 'verified_empty', soul: { + soulId: scenario === 'wrong_soul' ? 'different-soul' : 'same-soul', intentRevision: scenario === 'older_result' ? 20 : 22, + } })) + expect(getFreshAgentPaneContent(store)).toMatchObject({ ...content, soulIntentRevision: scenario === 'newer_pane' ? 24 : 21 }) + const card = screen.getByTestId('managed-runtime-recovery-card') + if (scenario === 'newer_pane') expect(within(card).queryByRole('status')).toBeNull() + else expect(await within(card).findByRole('status')).toHaveTextContent('Your conversation has been kept') + }) + + it('immediately retries a running managed Fresh Agent using the committed stop revision', async () => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ status: 'stuck', turns: [] }) + let serverRevision = 17 + let running = true + apiMock.stopManagedRuntimeSoul.mockImplementation((_soulId: string, revision: number) => { + if (revision !== serverRevision) return Promise.reject(new Error('Stale intent revision')) + if (running) { + running = false + serverRevision += 1 + return Promise.resolve({ outcome: 'backend_unavailable', soul: { soulId: 'running-fresh-soul', intentRevision: serverRevision } }) + } + return Promise.resolve({ outcome: 'verified_empty', soul: { soulId: 'running-fresh-soul', intentRevision: serverRevision } }) + }) + const content = { + kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'running-stuck-create', sessionRef: { provider: 'codex' as const, sessionId: 'retained-thread' }, + status: 'stuck' as const, soulId: 'running-fresh-soul', soulIntentRevision: 17, + } + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + expect(await screen.findByText(/Cleanup could not be confirmed/)).toBeInTheDocument() + expect(getFreshAgentPaneContent(store)).toMatchObject({ ...content, soulIntentRevision: 18 }) + fireEvent.click(screen.getByRole('button', { name: 'Start new conversation' })) + await waitFor(() => expect(apiMock.stopManagedRuntimeSoul).toHaveBeenNthCalledWith(2, 'running-fresh-soul', 18)) + await waitFor(() => expect(getFreshAgentPaneContent(store).createRequestId).not.toBe(content.createRequestId)) + }) + it('follows a managed same-soul fork even when another view issued the request', async () => { const store = createStore() let onMessage: ((message: Record) => void) | undefined diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 96daaafc5..4dc0201d9 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -46,6 +46,8 @@ const { mockApiGet, mockApiPost, mockApiPatch, + mockManagedVisibility, + mockManagedSoul, saveServerSettingsPatchSpy, cancelCreateSpy, cancelWsCreateSpy, @@ -64,6 +66,8 @@ const { mockApiGet: vi.fn(), mockApiPost: vi.fn(), mockApiPatch: vi.fn(), + mockManagedVisibility: vi.fn(), + mockManagedSoul: vi.fn(), saveServerSettingsPatchSpy: vi.fn((patch: unknown) => ({ type: 'settings/saveServerSettingsPatch', payload: patch, @@ -141,6 +145,8 @@ vi.mock('@/lib/ws-client', () => ({ })) vi.mock('@/lib/api', () => ({ + updateManagedRuntimeViewVisibility: mockManagedVisibility, + getManagedRuntimeSoul: mockManagedSoul, api: { get: (path: string, options?: unknown) => options === undefined ? mockApiGet(path) : mockApiGet(path, options), post: (path: string, body: unknown) => mockApiPost(path, body), @@ -469,6 +475,8 @@ describe('PaneContainer', () => { mockApiGet.mockReset() mockApiPost.mockReset() mockApiPatch.mockReset() + mockManagedVisibility.mockReset() + mockManagedSoul.mockReset() saveServerSettingsPatchSpy.mockClear() cancelCreateSpy.mockClear() cancelWsCreateSpy.mockClear() @@ -489,6 +497,40 @@ describe('PaneContainer', () => { }) describe('terminal cleanup on pane close', () => { + it('closes a managed Fresh Agent after kill hides its view before inventory reaches the browser', async () => { + const node: PaneNode = { + type: 'leaf', id: 'pane-managed-close', + content: { + kind: 'fresh-agent', provider: 'codex', sessionType: 'freshcodex', + createRequestId: 'managed-close-create', sessionId: 'managed-close-thread', status: 'connected', + soulId: 'managed-close-soul', viewIntentId: 'managed-close-view', + viewIntentRevision: 2, soulIntentRevision: 7, + }, + } + const store = createStore({ layouts: { 'tab-1': node }, activePane: { 'tab-1': node.id } }) + let stopped = false + mockManagedVisibility.mockImplementation(async (viewId, visibility, revision, soulRevision) => { + expect(stopped).toBe(true) + expect([viewId, visibility, revision, soulRevision]).toEqual(['managed-close-view', 'detached', 2, 7]) + // The real supervisor contract verifies that the hidden, verified + // stopped view satisfies this stale detach without a new mutation. + return { viewId, soulId: 'managed-close-soul', visibility: 'hidden', revision: 3, soulIntentRevision: 8 } + }) + renderWithStore(, store) + fireEvent.click(screen.getByRole('button', { name: /close pane/i })) + expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.kill', sessionId: 'managed-close-thread' })) + expect(mockManagedVisibility).not.toHaveBeenCalled() + stopped = true + await act(async () => ackFreshAgentKillsMocked()) + await waitFor(() => expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ type: 'panes.closed' }))) + expect(store.getState().panes.layouts['tab-1']).toEqual(node) + await act(async () => ackPanesClosedBatchesMocked()) + await waitFor(() => expect(store.getState().tabs.tabs).toEqual([])) + expect(store.getState().panes.layouts['tab-1']).toBeUndefined() + expect(mockManagedVisibility).toHaveBeenCalledTimes(1) + expect(mockSend.mock.calls.some(([msg]) => msg.type === 'freshAgent.create')).toBe(false) + }) + it('closing a pane sends the plain identity-driven detach AND the pane-close evidence keyed by the pane\'s createRequestId (delta-round-7 F2 / delta-r7-r2 F2)', async () => { const pane1Id = 'pane-1' const pane2Id = 'pane-2' diff --git a/test/unit/client/lib/api.test.ts b/test/unit/client/lib/api.test.ts index 4fbc720c0..3dbcbbd7f 100644 --- a/test/unit/client/lib/api.test.ts +++ b/test/unit/client/lib/api.test.ts @@ -18,6 +18,8 @@ import { requestSessionHandoff, SessionHandoffErrorCodeSchema, SessionHandoffResultSchema, + stopManagedRuntimeSoul, + getManagedRuntimeInventory, } from '@/lib/api' import { RestoreStaleRevisionResponseSchema, @@ -27,10 +29,57 @@ import { import { codexContractSnapshot, } from '../../../fixtures/fresh-agent/codex/contract-fixtures.js' +import lostFreshAgentInventory from '../../../fixtures/managed-runtime/lost-fresh-agent-inventory.json' const mockFetch = vi.fn() global.fetch = mockFetch +describe('managed runtime stop outcome', () => { + beforeEach(() => mockFetch.mockReset()) + + it.each(['verified_empty', 'termination_unconfirmed', 'blocked_ownership', 'backend_unavailable'])( + 'returns the authoritative %s outcome while allowing additive soul fields', async (outcome) => { + mockFetch.mockResolvedValueOnce(mockJson({ outcome, soul: { soulId: 'persisted/soul', intentRevision: 9, freshAgentSessionId: 'retained-thread' } })) + expect(await stopManagedRuntimeSoul('persisted/soul', 8, 'stop-request')).toEqual({ outcome, soul: { soulId: 'persisted/soul', intentRevision: 9 } }) + expect(mockFetch).toHaveBeenCalledWith('/api/runtime/souls/persisted%2Fsoul/stop', expect.objectContaining({ + method: 'POST', body: JSON.stringify({ requestId: 'stop-request', expectedIntentRevision: 8 }), + })) + }, + ) + + it.each([{}, { outcome: 'stopped' }, { outcome: null }])('rejects a successful HTTP response without a known cleanup outcome: %j', async (body) => { + mockFetch.mockResolvedValueOnce(mockJson(body)) + await expect(stopManagedRuntimeSoul('soul', 8)).rejects.toThrow() + }) + + it.each([ + { outcome: 'verified_empty' }, { outcome: 'verified_empty', soul: {} }, + { outcome: 'verified_empty', soul: { soulId: 'soul', intentRevision: -1 } }, + ])('rejects a stop response without valid returned revision authority: %j', async (body) => { + mockFetch.mockResolvedValueOnce(mockJson(body)) + await expect(stopManagedRuntimeSoul('soul', 8)).rejects.toThrow() + }) + + it('retains the authoritative Fresh launch correlation when reading inventory', async () => { + const inventory = structuredClone(lostFreshAgentInventory) + Object.assign(inventory.souls[0], { freshAgentCreateRequestId: 'original-fresh-create' }) + mockFetch.mockResolvedValueOnce(mockJson(inventory)) + const parsed = await getManagedRuntimeInventory() + expect(parsed.souls[0]).toMatchObject({ freshAgentCreateRequestId: 'original-fresh-create', freshAgentSessionType: 'freshopencode' }) + }) + + it('accepts the persisted lost Fresh Agent inventory serialized by the real Rust route', async () => { + // Captured by restored_web_stops_persisted_lost_soul_only_after_verified_cleanup. + mockFetch.mockResolvedValueOnce(mockJson(lostFreshAgentInventory)) + const inventory = await getManagedRuntimeInventory() + expect(inventory.souls[0]).toMatchObject({ + freshAgentSessionId: 'fresh-retained-thread', freshAgentSessionType: 'freshopencode', + freshAgentRuntimeVariant: 'opencode', nativeSessionId: 'retained-thread', + desiredState: 'stopped', recoveryState: 'lost', cleanupState: 'termination_unconfirmed', + }) + }) +}) + function mockJson(value: unknown) { return { ok: true, @@ -286,6 +335,12 @@ describe('visible-first read-model helpers', () => { ) }) + it('reads managed history from the exact soul after web restart without an alias lookup', async () => { + mockFetch.mockResolvedValueOnce(mockJson(codexContractSnapshot)) + await getFreshAgentThreadSnapshot('freshcodex', 'codex', 'presentation-alias', { soulId: 'retained-soul' }) + expect(mockFetch).toHaveBeenCalledWith('/api/runtime/souls/retained-soul/history', expect.any(Object)) + }) + it('appends the snapshot trigger to the fresh-agent snapshot query when provided', async () => { mockFetch.mockResolvedValueOnce(mockJson(codexContractSnapshot)) diff --git a/test/unit/client/lib/pane-reconcile.fresh-agent.test.ts b/test/unit/client/lib/pane-reconcile.fresh-agent.test.ts index 537d82510..638153a70 100644 --- a/test/unit/client/lib/pane-reconcile.fresh-agent.test.ts +++ b/test/unit/client/lib/pane-reconcile.fresh-agent.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' // Mock localStorage BEFORE importing slices (persistMiddleware reads it at import time) const localStorageMock = (() => { @@ -168,6 +169,35 @@ afterEach(() => { setFreshAgentReconcileActive(false) }) +describe('managed bootstrap history fold', () => { + it.each([ + ['freshclaude', 'claude', DURABLE], + ['kilroy', 'claude', DURABLE], + ['freshcodex', 'codex', 'native-managed-codex'], + ['freshopencode', 'opencode', 'ses_managed_opencode'], + ] as const)('preserves the %s canonical source through an actual fresh fold', (sessionType, provider, nativeId) => { + const store = configureStore({ reducer: { panes: panesReducer } }) + const sessionRef = { provider, sessionId: nativeId } + store.dispatch(initLayout({ tabId: 'owned-tab', paneId: 'owned-pane', content: { + kind: 'fresh-agent', sessionType, provider, createRequestId: FA_CREATE_REQUEST_ID, + sessionId: 'old-live-handle', sessionRef, resumeSessionId: nativeId, status: 'connected', + soulId: 'owned-soul', soulIntentRevision: 7, + } })) + const request = buildReconcileRequest(store.getState() as RootState, { includeFreshAgent: true })! + const result = resultFor(request, [{ paneKey: request.panes[0].paneKey, verdict: 'fresh', reason: 'identity_never_observed' }]) + const outcome = foldVerdicts(store.dispatch as AppDispatch, request, result) + expect(outcome.fresh).toBe(1) + const root = store.getState().panes.layouts['owned-tab'] + expect(root.type).toBe('leaf') + if (root.type !== 'leaf') throw new Error('expected owned pane') + expect(root.content).toMatchObject({ sessionRef, resumeSessionId: nativeId, soulId: 'owned-soul', + soulIntentRevision: 7, createRequestId: FA_CREATE_REQUEST_ID, pendingReconcile: 'fresh', reconcileEpoch: 1, status: 'creating' }) + expect((root.content as FreshAgentPaneContent).sessionId).toBeUndefined() + expect(buildReconcileRequest(store.getState() as RootState, { includeFreshAgent: true })!.panes[0]) + .toMatchObject({ createRequestId: FA_CREATE_REQUEST_ID, sessionRef }) + }) +}) + describe('fresh-agent reconcile capability latch', () => { it('defaults to inactive, follows setFreshAgentReconcileActive', () => { expect(isFreshAgentReconcileActive()).toBe(false) diff --git a/test/unit/client/store/paneCloseGate.test.ts b/test/unit/client/store/paneCloseGate.test.ts index fe01f5a70..9cbef05ef 100644 --- a/test/unit/client/store/paneCloseGate.test.ts +++ b/test/unit/client/store/paneCloseGate.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { configureStore } from '@reduxjs/toolkit' -const { mockSend, handlers } = vi.hoisted(() => ({ +const { mockSend, handlers, mockManagedRuntimeViewVisibility, mockGetManagedRuntimeSoul } = vi.hoisted(() => ({ mockSend: vi.fn(), handlers: new Set<(msg: unknown) => void>(), + mockManagedRuntimeViewVisibility: vi.fn(), + mockGetManagedRuntimeSoul: vi.fn(), })) vi.mock('@/lib/ws-client', () => ({ @@ -18,6 +20,11 @@ vi.mock('@/lib/ws-client', () => ({ }), })) +vi.mock('@/lib/api', () => ({ + updateManagedRuntimeViewVisibility: mockManagedRuntimeViewVisibility, + getManagedRuntimeSoul: mockGetManagedRuntimeSoul, +})) + import tabsReducer, { addTab, closeTab, @@ -74,6 +81,10 @@ function sentCallsOf(type: string): Array> { return mockSend.mock.calls.map(([m]) => m as Record).filter((m) => m.type === type) } +function expectManagedVisibilityCall(index: number, expected: [string, 'visible' | 'detached', number, number]) { + expect(mockManagedRuntimeViewVisibility.mock.calls[index - 1]?.slice(0, 4)).toEqual(expected) +} + function terminalContent(crid: string, terminalId?: string): PaneContent { return { kind: 'terminal', @@ -95,10 +106,70 @@ function freshAgentContent(crid: string): PaneContent { } as PaneContent } -function createStore() { +function managedTerminalContent( + crid: string, + terminalId: string, + viewIntentId: string, + viewIntentRevision: number, + soulIntentRevision: number, +): PaneContent { + return { + ...terminalContent(crid, terminalId), + viewIntentId, + viewIntentRevision, + soulIntentRevision, + soulId: `soul-${viewIntentId}`, + } as PaneContent +} + +function managedViewResult( + viewIntentId: string, + visibility: 'visible' | 'detached', + revision: number, + soulIntentRevision: number, +) { + return { + viewId: viewIntentId, + soulId: `soul-${viewIntentId}`, + ownerId: 'owner-1', + workspaceId: 'workspace-1', + kind: 'automatic_primary' as const, + preferredTabId: 'tab-1', + preferredPaneId: `pane-${viewIntentId}`, + title: viewIntentId, + placementGroup: 'default', + visibility, + revision, + soulIntentRevision, + createdAt: 1, + updatedAt: 2, + } +} + +function managedSoulDetail( + viewIntentId: string, + visibility: 'visible' | 'detached', + revision: number, + soulIntentRevision: number, +) { + return { + revision: 100, + readiness: {}, + soul: { intentRevision: soulIntentRevision }, + viewIntents: [managedViewResult(viewIntentId, visibility, revision, soulIntentRevision)], + actualUsage: null, + } +} + +function createStore(preloadedState?: { + tabs: ReturnType + panes: ReturnType + connection: ReturnType +}) { return configureStore({ reducer: { tabs: tabsReducer, panes: panesReducer, connection: connectionReducer }, middleware: (getDefault) => getDefault().concat(terminalDetachMiddleware as any), + preloadedState, }) } @@ -121,6 +192,103 @@ function createTwoPaneStore(opts?: { cridB?: string; terminalIdB?: string; termi return store } +function createManagedTwoPaneStore(legacyViewId?: string) { + const store = createStore() + store.dispatch(addTab({ id: 'tab-1', mode: 'shell' })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: managedTerminalContent('req-a', 'term-a', 'view-a', 2, 7), + })) + store.dispatch(splitPane({ + tabId: 'tab-1', + paneId: 'pane-1', + direction: 'vertical', + newContent: managedTerminalContent('req-b', 'term-b', 'view-b', 3, 8), + newPaneId: 'pane-2', + })) + mockSend.mockClear() + if (legacyViewId) { + const state = structuredClone(store.getState()) + const legacy = collectPaneEntries(state.panes.layouts['tab-1']).find(({ content }) => ( + (content as { viewIntentId?: string }).viewIntentId === legacyViewId + ))! + delete (legacy.content as { createRequestId?: string }).createRequestId + return createStore(state) + } + return store +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((complete) => { resolve = complete }) + return { promise, resolve } +} + +/** A durable backend that rejects stale fences and records real visibility transitions. */ +function installManagedViewBackend() { + const views = new Map([ + ['view-a', managedViewResult('view-a', 'visible', 2, 7)], + ['view-b', managedViewResult('view-b', 'visible', 3, 8)], + ]) + const mutate = async (viewId: string, visibility: 'visible' | 'detached', revision: number, soulRevision: number) => { + const current = views.get(viewId)! + if (revision !== current.revision || soulRevision !== current.soulIntentRevision) { + throw new Error('stale managed view revision') + } + // The supervisor checks the soul fence but advances only the view revision, + // including a visible -> visible mutation (view_intents.rs). + const next = managedViewResult(viewId, visibility, revision + 1, soulRevision) + views.set(viewId, next) + return next + } + mockManagedRuntimeViewVisibility.mockImplementation(mutate) + mockGetManagedRuntimeSoul.mockImplementation(async (soulId: string) => { + const view = views.get(soulId.replace(/^soul-/, ''))! + return managedSoulDetail(view.viewId, view.visibility, view.revision, view.soulIntentRevision) + }) + return { views, mutate } +} + +function projectManagedView(store: ReturnType, view: ReturnType) { + const pane = paneContents(store, 'tab-1').find(({ content }) => ( + (content as { viewIntentId?: string }).viewIntentId === view.viewId + ))! + store.dispatch(updatePaneContent({ + tabId: 'tab-1', + paneId: pane.paneId, + content: { ...pane.content, viewIntentRevision: view.revision, soulIntentRevision: view.soulIntentRevision } as PaneContent, + })) +} + +const managedCloseCases = [ + { + name: 'pane', + viewId: 'view-b', + viewRevision: 3, + soulRevision: 8, + start: (store: ReturnType) => store.dispatch( + closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' }), + ), + }, + { + name: 'tab', + viewId: 'view-a', + viewRevision: 2, + soulRevision: 7, + start: (store: ReturnType) => store.dispatch(closeTab('tab-1')), + }, + { + name: 'replace', + viewId: 'view-b', + viewRevision: 3, + soulRevision: 8, + start: (store: ReturnType) => store.dispatch( + replacePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' }), + ), + }, +] + function paneContents(store: ReturnType, tabId: string): Array<{ paneId: string; content: PaneContent }> { const root = store.getState().panes.layouts[tabId] return root ? collectPaneEntries(root) : [] @@ -137,6 +305,8 @@ function paneCloseErrors(store: ReturnType, tabId: string) { beforeEach(() => { mockSend.mockClear() handlers.clear() + mockManagedRuntimeViewVisibility.mockReset() + mockGetManagedRuntimeSoul.mockReset() }) afterEach(() => { @@ -144,6 +314,841 @@ afterEach(() => { }) describe('closePaneWithCleanup — the acknowledged close gate (F2)', () => { + it('does not roll back a stopped hidden view when a later tab detach fails', async () => { + const store = createManagedTwoPaneStore() + installManagedViewBackend() + mockManagedRuntimeViewVisibility + .mockResolvedValueOnce({ ...managedViewResult('view-a', 'visible', 4, 9), visibility: 'hidden' }) + .mockRejectedValueOnce(new Error('second view refused')) + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await close + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneCloseErrors(store, 'tab-1')).toHaveProperty('pane-1') + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([viewId, visibility]) => ( + viewId === 'view-a' && visibility === 'visible' + ))).toHaveLength(0) + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([viewId, visibility]) => ( + viewId === 'view-b' && visibility === 'visible' + ))).toHaveLength(1) + }) + + it.each(managedCloseCases.flatMap((closeCase) => [ + { ...closeCase, identity: 'durable' }, + { ...closeCase, identity: 'legacy' }, + ]))('retains the panes and reports the actual close failure after a refused $name view update ($identity identity)', async ({ name, start, identity }) => { + let store = createManagedTwoPaneStore() + if (identity === 'legacy') { + const state = structuredClone(store.getState()) + for (const { content } of collectPaneEntries(state.panes.layouts['tab-1'])) { + delete (content as { createRequestId?: string }).createRequestId + } + store = createStore(state) + } + installManagedViewBackend() + mockManagedRuntimeViewVisibility.mockRejectedValueOnce(new Error('managed view refusal')) + + const close = start(store) + if (identity === 'durable') { + expect(mockManagedRuntimeViewVisibility).not.toHaveBeenCalled() + if (name === 'tab') ackPanesClosedBatches() + else ackAllPaneCloses() + } + await close + + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneContents(store, 'tab-1').map(({ paneId, content }) => [paneId, content.kind])).toEqual([ + ['pane-1', 'terminal'], + ['pane-2', 'terminal'], + ]) + const failedPaneIds = name === 'tab' ? ['pane-1', 'pane-2'] : ['pane-2'] + expect(paneCloseErrors(store, 'tab-1')).toEqual(Object.fromEntries( + failedPaneIds.map((paneId) => [paneId, 'The pane could not be closed, so it was left open. Try again.']), + )) + expect(store.getState().panes.closingTabs?.['tab-1']).toBeUndefined() + expect(store.getState().panes.closingPanes?.['tab-1:pane-2']).toBeUndefined() + expect(mockManagedRuntimeViewVisibility.mock.calls[0]?.[1]).toBe('detached') + expect(sentCallsOf('pane.opened').map((message) => message.createRequestId)).toEqual( + identity === 'legacy' ? [] : name === 'tab' ? ['req-a', 'req-b'] : ['req-b'], + ) + }) + + it('bounds unacknowledged visible repair attempts and records the unresolved outcome', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + installManagedViewBackend() + mockManagedRuntimeViewVisibility.mockRejectedValue(new TypeError('response lost')) + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const close = store.dispatch(closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await vi.advanceTimersByTimeAsync(0) + await close + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledTimes(2) + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([, visibility]) => visibility === 'visible')).toHaveLength(2) + expect(paneContents(store, 'tab-1')).toHaveLength(2) + expect(errorLog.mock.calls.some((call) => call.some((arg) => ( + arg && typeof arg === 'object' + && (arg as { event?: string }).event === 'managed_view_visibility_uncertain_outcome' + && (arg as { phase?: string }).phase === 'retry' + )))).toBe(true) + } finally { + errorLog.mockRestore() + } + }) + + it.each(managedCloseCases.flatMap((closeCase) => ['repair first', 'detach first'].flatMap((ordering) => [ + { ...closeCase, ordering, failure: 'transport', error: new TypeError('Failed to fetch') }, + { ...closeCase, ordering, failure: 'response body', error: new SyntaxError('Invalid response JSON') }, + ])))('fences a delayed server detach after a visible read in a $name close ($failure, $ordering)', async ({ name, viewId, start, ordering, error }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + const failedViewId = name === 'tab' ? 'view-b' : viewId + let commitOriginal!: () => Promise> + let originalCommit: Promise> | undefined + const reads: Array<{ visibility: string; revision: number }> = [] + mockGetManagedRuntimeSoul.mockImplementation(async (soulId: string) => { + const current = backend.views.get(soulId.replace(/^soul-/, ''))! + reads.push({ visibility: current.visibility, revision: current.revision }) + return managedSoulDetail(current.viewId, current.visibility, current.revision, current.soulIntentRevision) + }) + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + if (id === failedViewId && visibility === 'detached') { + // The client loses its response while the server-side transaction is + // still queued. It must validate the original fences when it commits. + commitOriginal = () => originalCommit ??= backend.mutate(id, visibility, revision, soulRevision) + throw error + } + if (id === failedViewId && visibility === 'visible' && ordering === 'detach first' && !originalCommit) { + // The queued detach wins after GET returned visible but before the + // repair PATCH checks that read's now-stale revision. + await commitOriginal() + } + return backend.mutate(id, visibility, revision, soulRevision) + }) + + const close = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(0) + await close + const originalOutcome = await commitOriginal().then(() => 'committed', () => 'stale') + await vi.advanceTimersByTimeAsync(0) + + expect(reads[0]?.visibility).toBe('visible') + expect(originalOutcome).toBe(ordering === 'repair first' ? 'stale' : 'committed') + expect(paneContents(store, 'tab-1')).toHaveLength(2) + expect(backend.views.get(failedViewId)?.visibility).toBe('visible') + expect(backend.views.get('view-a')?.visibility).toBe('visible') + const visibleCalls = mockManagedRuntimeViewVisibility.mock.calls.filter(([id, visibility]) => id === failedViewId && visibility === 'visible') + expect(visibleCalls).toHaveLength(ordering === 'repair first' ? 1 : 2) + expect(visibleCalls[0]?.[2]).toBe(reads[0].revision) + if (ordering === 'detach first') { + expect(reads[1]?.visibility).toBe('detached') + expect(visibleCalls[1]?.[2]).toBe(reads[1].revision) + } + }) + + it.each(managedCloseCases)('does not let an old timed-out detach resurrect a successfully retried $name close', async ({ viewId, start }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + const response = deferred>() + let originalResult!: ReturnType + let firstDetach = true + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + const result = await backend.mutate(id, visibility, revision, soulRevision) + if (id === viewId && visibility === 'detached' && firstDetach) { + firstDetach = false + originalResult = result + return response.promise + } + return result + }) + + const firstClose = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await firstClose + expect(backend.views.get(viewId)?.visibility).toBe('visible') + projectManagedView(store, backend.views.get(viewId)!) + + const retry = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(0) + await retry + expect(paneContents(store, 'tab-1').some(({ content }) => (content as { viewIntentId?: string }).viewIntentId === viewId)).toBe(false) + const callsBeforeLateResponse = mockManagedRuntimeViewVisibility.mock.calls.length + response.resolve(originalResult) + await vi.advanceTimersByTimeAsync(0) + + expect(mockManagedRuntimeViewVisibility.mock.calls).toHaveLength(callsBeforeLateResponse) + expect(backend.views.get(viewId)?.visibility).toBe('detached') + }) + + it.each(managedCloseCases)('does not PATCH from an older authoritative read after a newer $name close succeeds', async ({ viewId, start }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + const response = deferred>() + const read = deferred>() + let originalResult!: ReturnType + let firstDetach = true + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + const result = await backend.mutate(id, visibility, revision, soulRevision) + if (id === viewId && visibility === 'detached' && firstDetach) { + firstDetach = false + originalResult = result + return response.promise + } + return result + }) + mockGetManagedRuntimeSoul.mockReturnValueOnce(read.promise) + + const firstClose = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await firstClose + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledTimes(1) + projectManagedView(store, backend.views.get(viewId)!) + const retry = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(0) + await retry + const callsBeforeRead = mockManagedRuntimeViewVisibility.mock.calls.length + const current = backend.views.get(viewId)! + read.resolve(managedSoulDetail(viewId, current.visibility, current.revision, current.soulIntentRevision)) + await vi.advanceTimersByTimeAsync(0) + response.resolve(originalResult) + await vi.advanceTimersByTimeAsync(0) + + expect(mockManagedRuntimeViewVisibility.mock.calls).toHaveLength(callsBeforeRead) + expect(backend.views.get(viewId)?.visibility).toBe('detached') + }) + + it.each(managedCloseCases.flatMap((closeCase) => [ + { ...closeCase, legacy: false }, + ...(closeCase.name === 'tab' ? [] : [{ ...closeCase, legacy: true }]), + ]))('keeps an older repair read inert while a newer $name close is still pending (legacy=$legacy)', async ({ viewId, start, legacy }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore(legacy ? viewId : undefined) + const backend = installManagedViewBackend() + const originalResponse = deferred>() + const retryResponse = deferred>() + const read = deferred>() + let originalResult!: ReturnType + let detachCount = 0 + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + if (legacy && id === viewId && visibility === 'detached' && detachCount === 1) { + detachCount++ + // Keep the stale-fence legacy retry pending until its response; its + // absent create identity means Redux supplies no pending close flag. + await retryResponse.promise + return backend.mutate(id, visibility, revision, soulRevision) + } + const result = await backend.mutate(id, visibility, revision, soulRevision) + if (id === viewId && visibility === 'detached') { + if (++detachCount === 1) { + originalResult = result + return originalResponse.promise + } + return retryResponse.promise + } + return result + }) + mockGetManagedRuntimeSoul.mockReturnValueOnce(read.promise) + const firstClose = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await firstClose + if (!legacy) projectManagedView(store, backend.views.get(viewId)!) + + const retry = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(0) + const callsBeforeRead = mockManagedRuntimeViewVisibility.mock.calls.length + const current = backend.views.get(viewId)! + read.resolve(managedSoulDetail(viewId, current.visibility, current.revision, current.soulIntentRevision)) + originalResponse.resolve(originalResult) + await vi.advanceTimersByTimeAsync(0) + expect(mockManagedRuntimeViewVisibility.mock.calls).toHaveLength(callsBeforeRead) + expect(backend.views.get(viewId)?.visibility).toBe('detached') + retryResponse.resolve(current) + await vi.advanceTimersByTimeAsync(0) + await retry + expect(paneContents(store, 'tab-1').some(({ content }) => (content as { viewIntentId?: string }).viewIntentId === viewId)).toBe(legacy) + expect(backend.views.get(viewId)?.visibility).toBe(legacy ? 'visible' : 'detached') + }) + + it.each(managedCloseCases)('retains an older repair when a newer $name close fails before its visibility transaction', async ({ viewId, start }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + const originalResponse = deferred>() + const originalRead = deferred>() + let originalResult!: ReturnType + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + const result = await backend.mutate(id, visibility, revision, soulRevision) + if (id === viewId && visibility === 'detached') { + originalResult = result + return originalResponse.promise + } + return result + }) + mockGetManagedRuntimeSoul.mockReturnValueOnce(originalRead.promise) + const firstClose = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await firstClose + expect(backend.views.get(viewId)?.visibility).toBe('detached') + + const retry = start(store) + // The original response arrives while the newer close waits for evidence, + // which subsequently refuses; no new visibility transaction ever starts. + originalResponse.resolve(originalResult) + const current = backend.views.get(viewId)! + originalRead.resolve(managedSoulDetail(viewId, current.visibility, current.revision, current.soulIntentRevision)) + await vi.advanceTimersByTimeAsync(0) + ackAllPaneCloses({ success: false }) + ackPanesClosedBatches({ success: false }) + await retry + expect(paneContents(store, 'tab-1')).toHaveLength(2) + expect(backend.views.get(viewId)?.visibility).toBe('visible') + }) + + it.each(managedCloseCases.flatMap((closeCase) => [ + { ...closeCase, failure: 'transport', error: new TypeError('Failed to fetch') }, + { ...closeCase, failure: 'response body', error: new SyntaxError('Invalid response JSON') }, + ]))('repairs a committed detach after $failure failure in a $name close with authoritative fences', async ({ name, viewId, start, error }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + // A tab close exercises rollback of its first view and repair of the + // ambiguous failing view in the same transaction. + const failedViewId = name === 'tab' ? 'view-b' : viewId + let committed!: ReturnType + mockManagedRuntimeViewVisibility.mockImplementation(async (id, visibility, revision, soulRevision) => { + const result = await backend.mutate(id, visibility, revision, soulRevision) + if (id === failedViewId && visibility === 'detached') { + committed = result + throw error + } + return result + }) + + const close = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(0) + await close + + expect(paneContents(store, 'tab-1')).toHaveLength(2) + expect(backend.views.get(failedViewId)?.visibility).toBe('visible') + expect(backend.views.get('view-a')?.visibility).toBe('visible') + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledWith(`soul-${failedViewId}`, expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(mockManagedRuntimeViewVisibility.mock.calls.some((call) => ( + call[0] === failedViewId && call[1] === 'visible' && call[2] === committed.revision && call[3] === committed.soulIntentRevision + ))).toBe(true) + }) + + it('detaches a managed pane after close evidence and before removing its layout', async () => { + const store = createStore() + store.dispatch(addTab({ id: 'tab-1', mode: 'shell' })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: terminalContent('req-a', 'term-a'), + })) + store.dispatch(splitPane({ + tabId: 'tab-1', + paneId: 'pane-1', + direction: 'vertical', + newContent: managedTerminalContent('req-b', 'term-b', 'view-b', 4, 9), + newPaneId: 'pane-2', + })) + mockSend.mockClear() + mockManagedRuntimeViewVisibility.mockResolvedValue( + managedViewResult('view-b', 'detached', 5, 10), + ) + + const close = store.dispatch(closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await close + + expectManagedVisibilityCall(1, ['view-b', 'detached', 4, 9]) + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1']) + }) + + it('keeps the tab open, reasserts panes, and rolls back earlier managed detaches when one refuses', async () => { + mockGetManagedRuntimeSoul.mockResolvedValue(managedSoulDetail('view-b', 'visible', 3, 8)) + const store = createStore() + store.dispatch(addTab({ id: 'tab-1', mode: 'shell' })) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: managedTerminalContent('req-a', 'term-a', 'view-a', 2, 7), + })) + store.dispatch(splitPane({ + tabId: 'tab-1', + paneId: 'pane-1', + direction: 'vertical', + newContent: managedTerminalContent('req-b', 'term-b', 'view-b', 3, 8), + newPaneId: 'pane-2', + })) + mockSend.mockClear() + mockManagedRuntimeViewVisibility + .mockImplementation(async (viewId, visibility, revision, soulRevision) => managedViewResult(viewId, visibility, revision + 1, soulRevision)) + .mockResolvedValueOnce(managedViewResult('view-a', 'detached', 4, 9)) + .mockRejectedValueOnce(new Error('managed view refusal')) + + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await close + + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1', 'pane-2']) + expectManagedVisibilityCall(1, ['view-a', 'detached', 2, 7]) + expectManagedVisibilityCall(2, ['view-b', 'detached', 3, 8]) + expectManagedVisibilityCall(3, ['view-b', 'visible', 3, 8]) + expectManagedVisibilityCall(4, ['view-a', 'visible', 4, 9]) + expect(sentCallsOf('pane.opened')).toEqual([ + expect.objectContaining({ createRequestId: 'req-a', tabId: 'tab-1' }), + expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), + ]) + expect(paneCloseErrors(store, 'tab-1')).toEqual({ + 'pane-1': 'The pane could not be closed, so it was left open. Try again.', + 'pane-2': 'The pane could not be closed, so it was left open. Try again.', + }) + }) + + it('a managed detach timeout settles a pane close, clears its close mark, keeps the pane, and reasserts it open', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + mockManagedRuntimeViewVisibility.mockReturnValue(new Promise(() => {})) + + const close = store.dispatch(closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + let settled = false + void close.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + await close + + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1', 'pane-2']) + expect(paneCloseErrors(store, 'tab-1')).toEqual({ + 'pane-2': 'The pane could not be closed, so it was left open. Try again.', + }) + expect(store.getState().panes.closingPanes?.['tab-1:pane-2']).toBeUndefined() + expect(sentCallsOf('pane.opened')).toEqual([ + expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), + ]) + }) + + it('a managed detach timeout settles a whole-tab close, clears its close mark, keeps the tab, and reasserts every pane', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + mockManagedRuntimeViewVisibility.mockReturnValue(new Promise(() => {})) + + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + let settled = false + void close.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + await close + + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1', 'pane-2']) + expect(store.getState().panes.closingTabs?.['tab-1']).toBeUndefined() + expect(paneCloseErrors(store, 'tab-1')).toEqual({ + 'pane-1': 'The pane could not be closed, so it was left open. Try again.', + 'pane-2': 'The pane could not be closed, so it was left open. Try again.', + }) + expect(sentCallsOf('pane.opened')).toEqual([ + expect.objectContaining({ createRequestId: 'req-a', tabId: 'tab-1' }), + expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), + ]) + }) + + it('a later managed detach timeout rolls back earlier success with its returned fences before refusing the tab close', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + mockManagedRuntimeViewVisibility.mockImplementation(( + viewId: string, + visibility: 'visible' | 'detached', + ) => { + if (viewId === 'view-a' && visibility === 'detached') { + return Promise.resolve(managedViewResult('view-a', 'detached', 4, 9)) + } + if (viewId === 'view-a' && visibility === 'visible') { + return Promise.resolve(managedViewResult('view-a', 'visible', 5, 10)) + } + return new Promise(() => {}) + }) + + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + expectManagedVisibilityCall(1, ['view-a', 'detached', 2, 7]) + expectManagedVisibilityCall(2, ['view-b', 'detached', 3, 8]) + expect(mockManagedRuntimeViewVisibility.mock.calls.find(([viewId, visibility]) => ( + viewId === 'view-a' && visibility === 'visible' + ))?.slice(0, 4)).toEqual(['view-a', 'visible', 4, 9]) + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1', 'pane-2']) + }) + + it.each(managedCloseCases)('repairs a late managed detach outcome for a $name close after the local timeout', async ({ + viewId, + viewRevision, + soulRevision, + start, + }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + let resolveDetach: (view: ReturnType) => void = () => {} + const lateDetach = new Promise>((resolve) => { + resolveDetach = resolve + }) + mockManagedRuntimeViewVisibility.mockImplementation(( + requestedViewId: string, + visibility: 'visible' | 'detached', + ) => { + if (visibility === 'detached') return lateDetach + return Promise.resolve(managedViewResult(requestedViewId, 'visible', viewRevision + 1, soulRevision + 1)) + }) + + const close = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + const originalDetachCall = mockManagedRuntimeViewVisibility.mock.calls.find(([, visibility]) => visibility === 'detached') + expect(originalDetachCall?.[5]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) })) + // The close gate times out locally, but the original mutation stays + // observable so a supervisor commit after the stale repair read can still + // deliver its returned fences to reconciliation. + expect((originalDetachCall?.[5] as { signal: AbortSignal }).signal.aborted).toBe(false) + const firstVisibleCall = mockManagedRuntimeViewVisibility.mock.calls.find(([, visibility]) => visibility === 'visible') + expect(firstVisibleCall?.slice(0, 4)).toEqual([viewId, 'visible', viewRevision, soulRevision]) + expect(firstVisibleCall?.[5]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect((firstVisibleCall?.[5] as { signal: AbortSignal }).signal.aborted).toBe(false) + + resolveDetach(managedViewResult(viewId, 'detached', viewRevision + 1, soulRevision + 1)) + await vi.advanceTimersByTimeAsync(0) + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([, visibility]) => visibility === 'visible')).toHaveLength(2) + }) + + it.each(managedCloseCases)('keeps watching a timed out $name detach after a stale visible read until its original response arrives', async ({ + viewId, + viewRevision, + soulRevision, + start, + }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const backend = installManagedViewBackend() + const lateDetach = deferred>() + let originalResult!: ReturnType + const authoritativeSnapshots: Array<{ visibility: string; revision: number; soulIntentRevision: number }> = [] + mockManagedRuntimeViewVisibility.mockImplementation(async ( + requestedViewId: string, + visibility: 'visible' | 'detached', + revision: number, + soulIntentRevision: number, + ) => { + const result = await backend.mutate(requestedViewId, visibility, revision, soulIntentRevision) + if (requestedViewId === viewId && visibility === 'detached') { + originalResult = result + return lateDetach.promise + } + return result + }) + mockGetManagedRuntimeSoul.mockImplementation(async (soulId: string) => { + const currentViewId = soulId.replace(/^soul-/, '') + // Only the first snapshot predates the committed detach; every retry + // reads the actual state, and every PATCH validates its revision. + const current = authoritativeSnapshots.length === 0 + ? managedViewResult(viewId, 'visible', viewRevision, soulRevision) + : backend.views.get(currentViewId)! + authoritativeSnapshots.push({ + visibility: current.visibility, + revision: current.revision, + soulIntentRevision: current.soulIntentRevision, + }) + return managedSoulDetail( + currentViewId, + current.visibility, + current.revision, + current.soulIntentRevision, + ) + }) + + const close = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledWith( + `soul-${viewId}`, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(authoritativeSnapshots).toEqual([ + { visibility: 'visible', revision: viewRevision, soulIntentRevision: soulRevision }, + { visibility: 'detached', revision: viewRevision + 1, soulIntentRevision: soulRevision }, + ]) + const initialVisibleCall = mockManagedRuntimeViewVisibility.mock.calls.find(([ + requestedViewId, + visibility, + ]) => requestedViewId === viewId && visibility === 'visible') + expect(initialVisibleCall?.slice(0, 4)).toEqual([viewId, 'visible', viewRevision, soulRevision]) + expect((initialVisibleCall?.[5] as { signal: AbortSignal }).signal.aborted).toBe(false) + + expect(backend.views.get(viewId)?.visibility).toBe('visible') + lateDetach.resolve(originalResult) + await vi.advanceTimersByTimeAsync(0) + + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([requestedViewId, visibility]) => ( + requestedViewId === viewId && visibility === 'visible' + )).map((call) => call.slice(0, 4))).toEqual([ + [viewId, 'visible', viewRevision, soulRevision], + [viewId, 'visible', viewRevision, soulRevision], + [viewId, 'visible', viewRevision + 1, soulRevision], + [viewId, 'visible', viewRevision + 1, soulRevision], + [viewId, 'visible', viewRevision + 2, soulRevision], + ]) + expect(backend.views.get(viewId)?.visibility).toBe('visible') + expect(backend.views.get(viewId)?.revision).toBe(viewRevision + 3) + }) + + it.each(managedCloseCases)('uses authoritative fences after a stale visible repair for a $name close', async ({ + viewId, + viewRevision, + soulRevision, + start, + }) => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + let resolveDetach: (view: ReturnType) => void = () => {} + const lateDetach = new Promise>((resolve) => { + resolveDetach = resolve + }) + let visibleAttempts = 0 + mockManagedRuntimeViewVisibility.mockImplementation(( + requestedViewId: string, + visibility: 'visible' | 'detached', + ) => { + if (visibility === 'detached') return lateDetach + visibleAttempts += 1 + if (visibleAttempts === 1) return Promise.reject(new Error('stale managed view revision')) + return Promise.resolve(managedViewResult(requestedViewId, 'visible', 41, 52)) + }) + mockGetManagedRuntimeSoul.mockResolvedValue( + managedSoulDetail(viewId, 'detached', 40, 52), + ) + + const close = start(store) + ackAllPaneCloses() + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + resolveDetach(managedViewResult(viewId, 'detached', viewRevision + 1, soulRevision + 1)) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(0) + + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledWith( + `soul-${viewId}`, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + const visibleCalls = mockManagedRuntimeViewVisibility.mock.calls.filter(([, visibility]) => visibility === 'visible') + expect(visibleCalls[1]?.slice(0, 4)).toEqual([viewId, 'visible', 40, 52]) + }) + + it('records a structured uncertainty when a late detach rejects and authoritative reconciliation fails', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = createManagedTwoPaneStore() + let rejectDetach: (error: Error) => void = () => {} + const lateDetach = new Promise>((_, reject) => { + rejectDetach = reject + }) + mockManagedRuntimeViewVisibility.mockImplementation(( + viewId: string, + visibility: 'visible' | 'detached', + ) => { + if (viewId === 'view-b' && visibility === 'detached') return lateDetach + return Promise.reject(new Error('visible repair unavailable')) + }) + mockGetManagedRuntimeSoul.mockRejectedValue(new Error('authoritative read unavailable')) + + const close = store.dispatch(closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + rejectDetach(new Error('detach response lost')) + await vi.advanceTimersByTimeAsync(0) + + expect(errorSpy.mock.calls.some((args) => args.some((arg) => ( + typeof arg === 'object' + && arg !== null + && (arg as { event?: unknown }).event === 'managed_view_visibility_uncertain_outcome' + )))).toBe(true) + errorSpy.mockRestore() + }) + + it('repairs a rollback visibility PATCH that resolves after its local timeout', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + let resolveSecondDetach: (view: ReturnType) => void = () => {} + const secondDetach = new Promise>((resolve) => { + resolveSecondDetach = resolve + }) + let resolveRollback: (view: ReturnType) => void = () => {} + const lateRollback = new Promise>((resolve) => { + resolveRollback = resolve + }) + let rollbackVisibleAttempts = 0 + mockManagedRuntimeViewVisibility.mockImplementation(( + viewId: string, + visibility: 'visible' | 'detached', + ) => { + if (viewId === 'view-a' && visibility === 'detached') { + return Promise.resolve(managedViewResult('view-a', 'detached', 4, 9)) + } + if (viewId === 'view-b' && visibility === 'detached') return secondDetach + if (viewId === 'view-a' && visibility === 'visible') { + rollbackVisibleAttempts += 1 + return rollbackVisibleAttempts === 1 + ? lateRollback + : Promise.resolve(managedViewResult('view-a', 'visible', 5, 10)) + } + return Promise.resolve(managedViewResult(viewId, 'visible', 5, 10)) + }) + + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + expect(mockManagedRuntimeViewVisibility.mock.calls.filter(([, visibility]) => visibility === 'visible')).toHaveLength(3) + resolveSecondDetach(managedViewResult('view-b', 'detached', 4, 9)) + resolveRollback(managedViewResult('view-a', 'visible', 5, 10)) + await vi.advanceTimersByTimeAsync(0) + expect(store.getState().tabs.tabs.some((tab) => tab.id === 'tab-1')).toBe(true) + expect(paneContents(store, 'tab-1').map((pane) => pane.paneId)).toEqual(['pane-1', 'pane-2']) + }) + + it('keeps watching a timed out rollback after a stale repair read and repairs its late visible commit', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + const durableViews = new Map([ + ['view-a', managedViewResult('view-a', 'visible', 2, 7)], + ['view-b', managedViewResult('view-b', 'visible', 3, 8)], + ]) + let resolveSecondDetach: (view: ReturnType) => void = () => {} + const secondDetach = new Promise>((resolve) => { + resolveSecondDetach = resolve + }) + let resolveRollback: (view: ReturnType) => void = () => {} + const lateRollback = new Promise>((resolve) => { + resolveRollback = resolve + }) + let rollbackVisibleAttempts = 0 + const authoritativeSnapshots: Array<{ visibility: string; revision: number; soulIntentRevision: number }> = [] + mockManagedRuntimeViewVisibility.mockImplementation(( + viewId: string, + visibility: 'visible' | 'detached', + ) => { + if (viewId === 'view-a' && visibility === 'detached') { + const detached = managedViewResult('view-a', 'detached', 4, 9) + durableViews.set(viewId, detached) + return Promise.resolve(detached) + } + if (viewId === 'view-b' && visibility === 'detached') return secondDetach + if (viewId === 'view-a' && visibility === 'visible') { + rollbackVisibleAttempts += 1 + if (rollbackVisibleAttempts === 1) return lateRollback + if (rollbackVisibleAttempts === 2) return Promise.reject(new Error('stale rollback fence')) + const repaired = managedViewResult('view-a', 'visible', 6, 11) + durableViews.set(viewId, repaired) + return Promise.resolve(repaired) + } + const current = durableViews.get(viewId) ?? managedViewResult(viewId, visibility, 1, 1) + const next = managedViewResult(viewId, visibility, current.revision + 1, current.soulIntentRevision + 1) + durableViews.set(viewId, next) + return Promise.resolve(next) + }) + mockGetManagedRuntimeSoul.mockImplementation(async (soulId: string) => { + const viewId = soulId.replace(/^soul-/, '') + const current = durableViews.get(viewId)! + authoritativeSnapshots.push({ + visibility: current.visibility, + revision: current.revision, + soulIntentRevision: current.soulIntentRevision, + }) + return managedSoulDetail(viewId, current.visibility, current.revision, current.soulIntentRevision) + }) + + const close = store.dispatch(closeTab('tab-1')) + ackPanesClosedBatches() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + await close + + expect(mockGetManagedRuntimeSoul).toHaveBeenCalledWith( + 'soul-view-a', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ) + expect(authoritativeSnapshots).toEqual([{ + visibility: 'detached', + revision: 4, + soulIntentRevision: 9, + }]) + const firstRollbackCall = mockManagedRuntimeViewVisibility.mock.calls.find(([ + viewId, + visibility, + ]) => viewId === 'view-a' && visibility === 'visible') + expect(firstRollbackCall?.slice(0, 4)).toEqual(['view-a', 'visible', 4, 9]) + expect((firstRollbackCall?.[5] as { signal: AbortSignal }).signal.aborted).toBe(false) + + const lateVisible = managedViewResult('view-a', 'visible', 5, 10) + durableViews.set('view-a', lateVisible) + resolveRollback(lateVisible) + await vi.advanceTimersByTimeAsync(0) + + const rollbackVisibleCalls = mockManagedRuntimeViewVisibility.mock.calls + .filter(([viewId, visibility]) => viewId === 'view-a' && visibility === 'visible') + .map((call) => call.slice(0, 4)) + expect(rollbackVisibleCalls).toEqual([ + ['view-a', 'visible', 4, 9], + ['view-a', 'visible', 4, 9], + ['view-a', 'visible', 4, 9], + ['view-a', 'visible', 5, 10], + ]) + expect(durableViews.get('view-a')?.visibility).toBe('visible') + + resolveSecondDetach(managedViewResult('view-b', 'detached', 4, 9)) + await vi.advanceTimersByTimeAsync(0) + }) + it('success: the pane.close is acked BEFORE the layout loses the pane (success → pane gone)', async () => { const store = createTwoPaneStore() const close = store.dispatch(closePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) @@ -895,6 +1900,94 @@ describe('replacePaneWithCleanup — the context-menu replace gate (F2)', () => expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), ]) }) + + it('managed success orders close evidence before visibility and replaces only after the visibility PATCH resolves', async () => { + const store = createManagedTwoPaneStore() + const order: string[] = [] + let sawReplacement = false + const unsubscribe = store.subscribe(() => { + if (sawReplacement) return + if (paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2')?.content.kind === 'picker') { + sawReplacement = true + order.push('replace') + } + }) + mockSend.mockImplementation((message: unknown) => { + if ((message as { type?: string }).type === 'pane.closed') order.push('close-evidence') + }) + let resolveDetach: (view: ReturnType) => void = () => {} + const detach = new Promise>((resolve) => { + resolveDetach = resolve + }) + mockManagedRuntimeViewVisibility.mockImplementation(async () => { + order.push('visibility-start') + const view = await detach + order.push('visibility-resolved') + return view + }) + + const replace = store.dispatch(replacePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + expect(order).toEqual(['close-evidence']) + expect(paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2')?.content.kind).toBe('terminal') + + ackAllPaneCloses() + await vi.waitFor(() => expect(order).toEqual(['close-evidence', 'visibility-start'])) + expect(paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2')?.content.kind).toBe('terminal') + + resolveDetach(managedViewResult('view-b', 'detached', 4, 9)) + await replace + unsubscribe() + + expect(order).toEqual(['close-evidence', 'visibility-start', 'visibility-resolved', 'replace']) + expect(paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2')?.content.kind).toBe('picker') + }) + + it('managed refusal keeps the original content, surfaces the close failure, and reasserts open', async () => { + mockGetManagedRuntimeSoul.mockResolvedValue(managedSoulDetail('view-b', 'visible', 3, 8)) + const store = createManagedTwoPaneStore() + mockManagedRuntimeViewVisibility + .mockImplementation(async (viewId, visibility, revision, soulRevision) => managedViewResult(viewId, visibility, revision + 1, soulRevision)) + .mockRejectedValueOnce(new Error('managed view refusal')) + + const replace = store.dispatch(replacePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await replace + + const entry = paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2') + expect(entry?.content.kind).toBe('terminal') + expect((entry?.content as { closeError?: string }).closeError).toBe( + 'The pane could not be closed, so it was left open. Try again.', + ) + expect(store.getState().panes.closingPanes?.['tab-1:pane-2']).toBeUndefined() + expect(sentCallsOf('pane.opened')).toEqual([ + expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), + ]) + }) + + it('managed detach timeout settles replace without installing a picker', async () => { + vi.useFakeTimers() + const store = createManagedTwoPaneStore() + mockManagedRuntimeViewVisibility.mockReturnValue(new Promise(() => {})) + + const replace = store.dispatch(replacePaneWithCleanup({ tabId: 'tab-1', paneId: 'pane-2' })) + ackAllPaneCloses() + await vi.advanceTimersByTimeAsync(KILL_ACK_TIMEOUT_MS + 50) + let settled = false + void replace.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(true) + await replace + + const entry = paneContents(store, 'tab-1').find((pane) => pane.paneId === 'pane-2') + expect(entry?.content.kind).toBe('terminal') + expect((entry?.content as { closeError?: string }).closeError).toBe( + 'The pane could not be closed, so it was left open. Try again.', + ) + expect(store.getState().panes.closingPanes?.['tab-1:pane-2']).toBeUndefined() + expect(sentCallsOf('pane.opened')).toEqual([ + expect.objectContaining({ createRequestId: 'req-b', tabId: 'tab-1' }), + ]) + }) }) describe('setPaneCloseError reducer', () => { diff --git a/test/unit/client/store/panesSlice.fresh-agent-reconcile.test.ts b/test/unit/client/store/panesSlice.fresh-agent-reconcile.test.ts index 0ca394143..75107d185 100644 --- a/test/unit/client/store/panesSlice.fresh-agent-reconcile.test.ts +++ b/test/unit/client/store/panesSlice.fresh-agent-reconcile.test.ts @@ -272,6 +272,58 @@ describe('applyFreshAgentReconcileAttach', () => { }) describe('resetFreshAgentPaneForReconcileCreate', () => { + it.each([ + ['freshclaude', 'claude', DURABLE], + ['kilroy', 'claude', DURABLE], + ['freshcodex', 'codex', 'native-managed-codex'], + ['freshopencode', 'opencode', 'ses_managed_opencode'], + ])('retains managed bootstrap history for %s while clearing its live handle', (sessionType, provider, nativeId) => { + const sessionRef = { provider, sessionId: nativeId } + const state = stateWithFreshAgentPane({ sessionType, provider, sessionRef, resumeSessionId: nativeId, + sessionId: 'presentation-live', serverInstanceId: 'server-old', status: 'connected', + soulId: 'owned-soul', soulIntentRevision: 7, incarnationId: 'original-incarnation' }) + const next = panesReducer(state, resetFreshAgentPaneForReconcileCreate({ + tabId, paneId, intent: 'fresh', reason: 'identity_never_observed', + })) + expect(leafContent(next, tabId)).toMatchObject({ sessionRef, resumeSessionId: nativeId, + createRequestId: ORIGINAL_CREATE_REQUEST_ID, soulId: 'owned-soul', soulIntentRevision: 7, + incarnationId: 'original-incarnation', status: 'creating', pendingReconcile: 'fresh', reconcileEpoch: 1 }) + expect(leafContent(next, tabId).sessionId).toBeUndefined() + expect(leafContent(next, tabId).serverInstanceId).toBeUndefined() + }) + + it.each(['duplicate_session_claim', 'no_recoverable_identity'])('clears managed durable identity for genuine fresh %s', (reason) => { + const state = stateWithFreshAgentPane({ soulId: 'owned-soul', sessionRef: { provider: 'claude', sessionId: DURABLE }, resumeSessionId: DURABLE }) + const next = panesReducer(state, resetFreshAgentPaneForReconcileCreate({ tabId, paneId, intent: 'fresh', reason })) + expect(leafContent(next, tabId).sessionRef).toBeUndefined() + expect(leafContent(next, tabId).resumeSessionId).toBeUndefined() + expect(leafContent(next, tabId).createRequestId).toBe(ORIGINAL_CREATE_REQUEST_ID) + }) + + it.each([ + undefined, + { provider: 'codex', sessionId: 'foreign-native' }, + { provider: 'claude', sessionId: 'freshclaude-placeholder' }, + ])('does not reconstruct a managed bootstrap locator from %j', (sessionRef) => { + const state = stateWithFreshAgentPane({ soulId: 'owned-soul', sessionRef, resumeSessionId: sessionRef ? DURABLE : undefined }) + const next = panesReducer(state, resetFreshAgentPaneForReconcileCreate({ tabId, paneId, intent: 'fresh', reason: 'identity_never_observed' })) + expect(leafContent(next, tabId).sessionRef).toBeUndefined() + expect(leafContent(next, tabId).resumeSessionId).toBeUndefined() + }) + + it('does not retain managed identity after invalid respawn degrades to fresh', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const state = stateWithFreshAgentPane({ soulId: 'owned-soul', sessionRef: { provider: 'claude', sessionId: DURABLE }, resumeSessionId: DURABLE }) + const next = panesReducer(state, resetFreshAgentPaneForReconcileCreate({ tabId, paneId, intent: 'respawn', + reason: 'identity_never_observed', sessionRef: { provider: 'codex', sessionId: 'wrong-native' } })) + expect(leafContent(next, tabId).sessionRef).toBeUndefined() + expect(leafContent(next, tabId).resumeSessionId).toBeUndefined() + expect(leafContent(next, tabId).pendingReconcile).toBe('fresh') + expect(errorSpy).toHaveBeenCalled() + } finally { errorSpy.mockRestore() } + }) + it('respawn adopts the server-named sessionRef and arms pendingReconcile', () => { const state = stateWithFreshAgentPane({ sessionId: 'live-old', serverInstanceId: 'srv-old', status: 'connected' }) const next = panesReducer(state, resetFreshAgentPaneForReconcileCreate({ diff --git a/test/unit/lib/managed-runtime-recovery.test.ts b/test/unit/lib/managed-runtime-recovery.test.ts index f22e8030c..3c62c2e23 100644 --- a/test/unit/lib/managed-runtime-recovery.test.ts +++ b/test/unit/lib/managed-runtime-recovery.test.ts @@ -2,7 +2,7 @@ import { configureStore } from '@reduxjs/toolkit' import { describe, expect, it } from 'vitest' import tabsReducer, { addTab, setActiveTab, updateTab } from '@/store/tabsSlice' import { handleUiCommand } from '@/lib/ui-commands' -import panesReducer from '@/store/panesSlice' +import panesReducer, { startNewManagedRuntimeConversation, updatePaneContent } from '@/store/panesSlice' import managedRuntimeReducer from '@/store/managedRuntimeSlice' import { applyManagedRuntimeMergePlan, @@ -331,6 +331,191 @@ describe('managed runtime recovery merge', () => { }) }) + it('binds the newly launched Fresh Agent after start-new before native identity arrives', () => { + const state = baseState() + state.tabs.tabs[0].mode = 'freshcodex' + state.panes.layouts['user-tab'].content = { + kind: 'fresh-agent', createRequestId: 'old-create', status: 'error', + sessionType: 'freshcodex', provider: 'codex', sessionId: 'old-native', + sessionRef: { provider: 'codex', sessionId: 'old-native' }, + soulId: 'old-soul', viewIntentId: 'old-view', + } + const store = storeWithState(state) + store.dispatch(startNewManagedRuntimeConversation({ tabId: 'user-tab', paneId: 'user-pane' })) + const restarted = store.getState().panes.layouts['user-tab'].content + store.dispatch(updatePaneContent({ tabId: 'user-tab', paneId: 'user-pane', content: { + ...restarted, sessionId: 'fresh-runtime-id', status: 'connected', + } })) + const inventory = snapshot([soul({ + provider: 'codex', nativeSessionId: undefined, freshAgentSessionId: 'fresh-runtime-id', + freshAgentSessionType: 'freshcodex', terminalId: undefined, + terminalCreateRequestId: undefined, terminalMode: undefined, + })]) + const plan = buildManagedRuntimeMergePlan(inventory, store.getState() as any) + expect(plan.creates).toHaveLength(0) + expect(plan.updates).toHaveLength(1) + applyManagedRuntimeMergePlan(store as any, plan) + expect(store.getState().tabs.tabs).toHaveLength(1) + expect(store.getState().panes.layouts['user-tab'].content).toMatchObject({ + kind: 'fresh-agent', createRequestId: restarted.createRequestId, + sessionId: 'fresh-runtime-id', soulId: 'soul-one', viewIntentId: 'view-one', + }) + expect(store.getState().panes.layouts['user-tab'].content.sessionRef).toBeUndefined() + }) + + it.each(['freshclaude', 'kilroy', 'freshcodex', 'freshopencode'] as const)('adopts native-free %s through its runtime session identity', (sessionType) => { + const provider = sessionType === 'freshcodex' ? 'codex' : sessionType === 'freshopencode' ? 'opencode' : 'claude' + const state = baseState() + state.panes.layouts['user-tab'].content = { + kind: 'fresh-agent', createRequestId: 'pending-create', status: 'connected', + sessionType, provider, sessionId: 'runtime-pending', + } + const plan = buildManagedRuntimeMergePlan(snapshot([soul({ + provider: sessionType === 'kilroy' ? 'kilroy' : provider, + nativeSessionId: undefined, terminalId: undefined, terminalCreateRequestId: undefined, + freshAgentSessionId: 'runtime-pending', freshAgentSessionType: sessionType, + })]), state) + expect(plan.creates).toHaveLength(0) + expect(plan.updates[0].content).toMatchObject({ kind: 'fresh-agent', provider, sessionType, sessionId: 'runtime-pending', soulId: 'soul-one' }) + expect(plan.updates[0].content.sessionRef).toBeUndefined() + }) + + it('preserves the public Claude identity when a Kilroy native session becomes available', () => { + const state = baseState() + const nativeSessionId = 'd4430000-0000-4444-8444-000000000093' + state.panes.layouts['user-tab'].content = { kind: 'fresh-agent', createRequestId: 'kilroy-create', status: 'connected', + sessionType: 'kilroy', provider: 'claude', sessionId: 'kilroy-runtime', + } + const plan = buildManagedRuntimeMergePlan(snapshot([soul({ + provider: 'kilroy', nativeSessionId, terminalId: undefined, terminalCreateRequestId: undefined, + freshAgentSessionId: 'kilroy-runtime', freshAgentSessionType: 'kilroy', + })]), state) + expect(plan.creates).toHaveLength(0) + expect(plan.updates[0].content).toMatchObject({ kind: 'fresh-agent', provider: 'claude', sessionType: 'kilroy', + sessionId: nativeSessionId, sessionRef: { provider: 'claude', sessionId: nativeSessionId }, + }) + applyManagedRuntimeMergePlan(storeWithState(state) as any, plan) + }) + + it.each(['provider', 'sessionType', 'createRequestId'] as const)('never binds a pending Fresh launch with mismatched %s', (mismatch) => { + const state = baseState() + state.panes.layouts['user-tab'].content = { kind: 'fresh-agent', createRequestId: 'pending-create', status: 'creating', + sessionType: 'freshcodex', provider: 'codex', + } + const plan = buildManagedRuntimeMergePlan(snapshot([soul({ + provider: mismatch === 'provider' ? 'opencode' : 'codex', nativeSessionId: undefined, + terminalId: undefined, terminalCreateRequestId: undefined, freshAgentSessionId: 'runtime-pending', + freshAgentSessionType: mismatch === 'sessionType' ? 'freshopencode' : 'freshcodex', + freshAgentCreateRequestId: mismatch === 'createRequestId' ? 'different-create' : 'pending-create', + })]), state) + expect(plan.updates).toHaveLength(0) + expect(plan.creates).toHaveLength(1) + }) + + it.each(['create', 'view'] as const)('prefers the originating Fresh %s identity over an earlier pane sharing its session', (identity) => { + const state = baseState() + const content = { kind: 'fresh-agent', status: 'connected', sessionType: 'freshcodex', provider: 'codex', sessionId: 'shared-runtime' } + state.panes.layouts['user-tab'] = { type: 'split', id: 'split-root', direction: 'horizontal', sizes: [50, 50], children: [ + { type: 'leaf', id: 'other-pane', content: { ...content, createRequestId: 'other-create' } }, + { type: 'leaf', id: 'origin-pane', content: { ...content, createRequestId: 'origin-create', + ...(identity === 'view' ? { viewIntentId: 'view-one' } : {}) } }, + ] } + const inventory = snapshot([soul({ provider: 'codex', nativeSessionId: undefined, terminalId: undefined, + terminalCreateRequestId: undefined, freshAgentSessionId: 'shared-runtime', freshAgentSessionType: 'freshcodex', + freshAgentCreateRequestId: identity === 'create' ? 'origin-create' : 'other-create', + recoveryState: 'lost', desiredState: 'stopped', recoveryReason: 'provider_state_missing', + })]) + const store = storeWithState(state) + const plan = buildManagedRuntimeMergePlan(inventory, store.getState() as any) + expect(plan.creates).toHaveLength(0) + expect(plan.updates.map((update) => update.paneId)).toEqual(['origin-pane']) + applyManagedRuntimeMergePlan(store as any, plan) + const panes = store.getState().panes.layouts['user-tab'].children + expect(panes[0].content.recoverySummary).toBeUndefined() + expect(panes[1].content).toMatchObject({ createRequestId: 'origin-create', viewIntentId: 'view-one', + recoverySummary: { recoveryState: 'lost', desiredState: 'stopped' } }) + }) + + it.each([true, false])('preserves an explicit Fresh view sharing the automatic view creation seed (origin present: %s)', (originPresent) => { + const state = baseState() + const content = { kind: 'fresh-agent', createRequestId: 'source-create', status: 'connected', + sessionType: 'freshcodex', provider: 'codex', sessionId: 'shared-runtime', soulId: 'soul-one' } + const explicitPane = { type: 'leaf', id: 'explicit-pane', content: { ...content, viewIntentId: 'z-explicit' } } + state.panes.layouts['user-tab'] = originPresent + ? { type: 'split', id: 'split-root', direction: 'horizontal', sizes: [50, 50], children: [explicitPane, + { type: 'leaf', id: 'origin-pane', content: { ...content, createRequestId: 'reminted-create', viewIntentId: 'a-automatic' } }] } + : explicitPane + const inventory = snapshot([soul({ provider: 'codex', nativeSessionId: undefined, terminalId: undefined, + terminalCreateRequestId: undefined, freshAgentSessionId: 'shared-runtime', freshAgentSessionType: 'freshcodex', + freshAgentCreateRequestId: 'source-create', + })], [view({ viewId: 'a-automatic' }), view({ viewId: 'z-explicit', kind: 'explicit' })]) + const plan = buildManagedRuntimeMergePlan(inventory, state) + expect(plan.updates.find((update) => update.content.viewIntentId === 'z-explicit')?.paneId).toBe('explicit-pane') + if (originPresent) { + expect(plan.creates).toHaveLength(0) + expect(plan.updates.find((update) => update.content.viewIntentId === 'a-automatic')?.paneId).toBe('origin-pane') + } else { + expect(plan.updates).toHaveLength(1) + expect(plan.creates.map((create) => create.content.viewIntentId)).toEqual(['a-automatic']) + } + const store = storeWithState(state) + applyManagedRuntimeMergePlan(store as any, plan) + expect(buildManagedRuntimeMergePlan(inventory, store.getState() as any).creates).toHaveLength(0) + }) + + it('adopts a restored Fresh pane through native identity when its local create key was reminted', () => { + const state = baseState() + state.panes.layouts['user-tab'].content = { kind: 'fresh-agent', createRequestId: 'restored-local-create', + status: 'connected', sessionType: 'freshcodex', provider: 'codex', sessionId: 'native-one', + sessionRef: { provider: 'codex', sessionId: 'native-one' } } + const plan = buildManagedRuntimeMergePlan(snapshot([soul({ provider: 'codex', nativeSessionId: 'native-one', + terminalId: undefined, terminalCreateRequestId: undefined, freshAgentSessionId: 'runtime-one', + freshAgentSessionType: 'freshcodex', freshAgentCreateRequestId: 'original-server-create', + })]), state) + expect(plan.creates).toHaveLength(0) + expect(plan.updates[0]).toMatchObject({ paneId: 'user-pane', content: { kind: 'fresh-agent', + createRequestId: 'restored-local-create', viewIntentId: 'view-one' } }) + }) + + it.each(['ack-first', 'inventory-first'] as const)('correlates concurrent Fresh launches with %s delivery and still restores unrelated cold views', (order) => { + const state = baseState() + state.panes.layouts['user-tab'] = { type: 'split', id: 'split-root', direction: 'horizontal', sizes: [50, 50], children: [ + { type: 'leaf', id: 'first-pane', content: { kind: 'fresh-agent', createRequestId: 'first-create', status: 'creating', sessionType: 'freshcodex', provider: 'codex' } }, + { type: 'leaf', id: 'second-pane', content: { kind: 'fresh-agent', createRequestId: 'second-create', status: 'creating', sessionType: 'freshcodex', provider: 'codex' } }, + ] } + const store = storeWithState(state) + const inventory = snapshot(['second', 'first', 'cold'].map((key) => soul({ + soulId: `${key}-soul`, provider: 'codex', nativeSessionId: undefined, + terminalId: undefined, terminalCreateRequestId: undefined, + freshAgentCreateRequestId: `${key}-create`, freshAgentSessionId: `${key}-runtime`, freshAgentSessionType: 'freshcodex', + })), ['second', 'first', 'cold'].map((key) => view({ + viewId: `${key}-view`, soulId: `${key}-soul`, preferredTabId: 'user-tab', preferredPaneId: `${key}-preferred-pane`, + }))) + const acknowledge = () => { + for (const key of ['first', 'second']) { + const root = store.getState().panes.layouts['user-tab'] + const leaf = root.children.find((node: any) => node.id === `${key}-pane`) + store.dispatch(updatePaneContent({ tabId: 'user-tab', paneId: `${key}-pane`, content: { + ...leaf.content, sessionId: `${key}-runtime`, status: 'connected', + } })) + } + } + if (order === 'ack-first') acknowledge() + const plan = buildManagedRuntimeMergePlan(inventory, store.getState() as any) + expect(plan.updates.map((update) => update.paneId).sort()).toEqual(['first-pane', 'second-pane']) + expect(plan.creates).toHaveLength(1) + expect(plan.creates[0].content.soulId).toBe('cold-soul') + applyManagedRuntimeMergePlan(store as any, plan) + if (order === 'inventory-first') acknowledge() + const root = store.getState().panes.layouts['user-tab'] + for (const key of ['first', 'second']) expect(root.children.find((node: any) => node.id === `${key}-pane`).content).toMatchObject({ + kind: 'fresh-agent', createRequestId: `${key}-create`, sessionId: `${key}-runtime`, soulId: `${key}-soul`, viewIntentId: `${key}-view`, + }) + expect(store.getState().tabs.tabs).toHaveLength(2) + const replay = buildManagedRuntimeMergePlan(inventory, store.getState() as any) + expect(replay.creates).toHaveLength(0) + }) + it('does not adopt an unrelated pane that merely lacks a terminal id', () => { const state = baseState() state.panes.layouts['user-tab'].content = { @@ -439,4 +624,168 @@ describe('managed runtime recovery merge', () => { }, }) }) + + it('updates an already represented stopped/lost terminal in place without creating a replacement', () => { + const state = baseState() + state.tabs.tabs[0].mode = 'opencode' + state.panes.layouts['user-tab'].content = { + kind: 'terminal', + createRequestId: 'create-one', + terminalId: 'terminal-one', + streamId: 'stream-one', + status: 'exited', + mode: 'opencode', + shell: 'system', + sessionRef: { provider: 'opencode', sessionId: 'ses_one' }, + soulId: 'soul-one', + viewIntentId: 'view-one', + } + const lost = soul({ + launchState: 'stopped', + desiredState: 'stopped', + recoveryState: 'lost', + recoveryReason: 'provider_state_missing', + terminalId: undefined, + terminalStreamId: undefined, + terminalCreateRequestId: undefined, + }) + + const plan = buildManagedRuntimeMergePlan(snapshot([lost]), state) + + expect(plan.creates).toHaveLength(0) + expect(plan.updates).toHaveLength(1) + expect(plan.updates[0]).toMatchObject({ tabId: 'user-tab', paneId: 'user-pane' }) + expect(plan.updates[0].content).toMatchObject({ + kind: 'terminal', + soulId: 'soul-one', + viewIntentId: 'view-one', + recoverySummary: { desiredState: 'stopped', recoveryState: 'lost' }, + }) + }) + + it('updates an already represented stopped/lost Fresh Agent in place', () => { + const state = baseState() + state.tabs.tabs[0].mode = 'freshopencode' + state.panes.layouts['user-tab'].content = { + kind: 'fresh-agent', + createRequestId: 'fresh-create-one', + status: 'exited', + sessionType: 'freshopencode', + provider: 'opencode', + sessionId: 'ses_one', + resumeSessionId: 'ses_one', + sessionRef: { provider: 'opencode', sessionId: 'ses_one' }, + soulId: 'soul-one', + viewIntentId: 'view-one', + incarnationId: 'incarnation-one', + } + const lost = soul({ + launchState: 'stopped', + desiredState: 'stopped', + recoveryState: 'lost', + terminalId: undefined, + terminalStreamId: undefined, + terminalMode: undefined, + terminalCwd: undefined, + terminalCreateRequestId: undefined, + }) + + const plan = buildManagedRuntimeMergePlan(snapshot([lost]), state) + + expect(plan.creates).toHaveLength(0) + expect(plan.updates).toHaveLength(1) + expect(plan.updates[0].content).toMatchObject({ + kind: 'fresh-agent', + soulId: 'soul-one', + viewIntentId: 'view-one', + sessionRef: { provider: 'opencode', sessionId: 'ses_one' }, + recoverySummary: { desiredState: 'stopped', recoveryState: 'lost' }, + }) + }) + + it('never creates a pane for an absent stopped/lost view', () => { + const lost = soul({ + launchState: 'stopped', + desiredState: 'stopped', + recoveryState: 'lost', + terminalId: undefined, + terminalStreamId: undefined, + terminalCreateRequestId: undefined, + }) + const plan = buildManagedRuntimeMergePlan(snapshot([lost]), baseState()) + + expect(plan.creates).toHaveLength(0) + expect(plan.updates).toHaveLength(0) + }) + + it('clears a lost managed terminal before an old inventory snapshot can reattach it', () => { + const state = baseState() + state.tabs.tabs[0].mode = 'opencode' + state.panes.layouts['user-tab'].content = { + kind: 'terminal', + createRequestId: 'old-create', + terminalId: 'terminal-one', + streamId: 'stream-one', + status: 'error', + mode: 'opencode', + shell: 'system', + namingHandle: 'old-managed-name-handle', + nameRef: { kind: 'pending', id: 'old-managed-name-handle' }, + soulId: 'soul-one', + incarnationId: 'incarnation-one', + viewIntentId: 'view-one', + viewIntentRevision: 2, + soulIntentRevision: 3, + incidentId: 'incident-one', + placementGroup: 'Recovered agents', + resourceSummary: { + configured: soul().configuredLimits, + effective: soul().effectiveLimits, + }, + recoverySummary: { + desiredState: 'stopped', + recoveryState: 'lost', + reason: 'provider_state_missing', + durabilityState: 'resume_captured', + allocationState: 'verified_durable', + }, + } + const store = storeWithState(state) + const oldCreateRequestId = store.getState().panes.layouts['user-tab'].type === 'leaf' + ? store.getState().panes.layouts['user-tab'].content.createRequestId + : undefined + + store.dispatch(startNewManagedRuntimeConversation({ tabId: 'user-tab', paneId: 'user-pane' })) + + const content = store.getState().panes.layouts['user-tab'] + if (content.type !== 'leaf' || content.content.kind !== 'terminal') { + throw new Error('expected a terminal pane after starting a new conversation') + } + expect(content.content.createRequestId).not.toBe(oldCreateRequestId) + expect(content.content.status).toBe('creating') + expect(content.content.terminalId).toBeUndefined() + expect(content.content.namingHandle).toBeUndefined() + expect(content.content.nameRef).toBeUndefined() + expect(content.content.soulId).toBeUndefined() + expect(content.content.incarnationId).toBeUndefined() + expect(content.content.viewIntentId).toBeUndefined() + expect(content.content.viewIntentRevision).toBeUndefined() + expect(content.content.soulIntentRevision).toBeUndefined() + expect(content.content.incidentId).toBeUndefined() + expect(content.content.placementGroup).toBeUndefined() + expect(content.content.resourceSummary).toBeUndefined() + expect(content.content.recoverySummary).toBeUndefined() + + const oldInventoryPlan = buildManagedRuntimeMergePlan( + snapshot([soul({ + desiredState: 'stopped', + launchState: 'stopped', + recoveryState: 'lost', + recoveryReason: 'provider_state_missing', + })]), + store.getState() as any, + ) + expect(oldInventoryPlan.creates).toHaveLength(0) + expect(oldInventoryPlan.updates).toHaveLength(0) + }) }) diff --git a/test/unit/tooling/testing/opencode-native-history.test.ts b/test/unit/tooling/testing/opencode-native-history.test.ts index ddaf0a889..5917fe038 100644 --- a/test/unit/tooling/testing/opencode-native-history.test.ts +++ b/test/unit/tooling/testing/opencode-native-history.test.ts @@ -152,4 +152,12 @@ describe('resumed OpenCode readiness is an input-mode signal, not a home-screen expect(hasOpenCodePromptModelText('Build GPT-5.6 Luna', ['', ''])).toBe(false) expect(openCodeTerminalReady('\x1b[?2004hBuild GPT-5.6 Luna', [])).toBe(false) }) + + it.each(['Big Pickle', 'GPT-5.6 Luna'])('recognizes the configured %s model with ANSI styling and active input', (model) => { + const banner = `\x1b[32mBuild\x1b[0m \x1b[31m${model}\x1b[0m` + expect(openCodeTerminalReady(`\x1b[?2004h${banner}`, [model])).toBe(true) + expect(openCodeTerminalReady(banner, [model])).toBe(false) + expect(openCodeTerminalReady(`\x1b[?2004h${banner}\x1b[?2004l`, [model])).toBe(false) + expect(openCodeTerminalReady(`\x1b[?2004h\x1b[31m${model}\x1b[0m`, [model])).toBe(false) + }) }) diff --git a/test/unit/tooling/testing/runtime-phase5-loss-receipt.test.ts b/test/unit/tooling/testing/runtime-phase5-loss-receipt.test.ts index 30df26739..63e25f273 100644 --- a/test/unit/tooling/testing/runtime-phase5-loss-receipt.test.ts +++ b/test/unit/tooling/testing/runtime-phase5-loss-receipt.test.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { PHASE5_LOSS_ASSERTIONS_FILE, PHASE5_LOSS_INCIDENT_FILE, + buildPhase5LossReceipt, phase5LossRetainedBundle, validatePhase5LossReceipt, } from '../../../../scripts/testing/runtime-phase5-loss-evidence.js' @@ -140,7 +141,7 @@ function createFixture(): { updatedAt: '2026-09-09T20:00:00.010Z', } const assertions = { - schemaVersion: 1, + schemaVersion: 2, caseId: 'P5-G02', candidateSha: sha, receiptRunId: runId, @@ -173,7 +174,12 @@ function createFixture(): { }], }, browser: { - displayedNoticeIds: [incident.noticeId], + routineNoticeCount: 0, + recoveryCards: [{ + paneId: 'pane-phase5', + lossMessageVisible: true, + startNewConversationEnabled: true, + }], endedPane: { soulId, incarnationId, @@ -267,7 +273,8 @@ function createFixture(): { }, summary: { provider: 'opencode', soulId, incarnationId, incidentId, - exactCleanupVerified: true, displayedNoticeCount: 1, foreignObjectsTouched: 0, + exactCleanupVerified: true, routineNoticeCount: 0, + actionableRecoveryCardCount: 1, foreignObjectsTouched: 0, }, } return { @@ -306,12 +313,70 @@ describe('Phase 5 loss receipt validation', () => { expect(retained.index.sourceReceiptSha256).toMatch(/^[0-9a-f]{64}$/) }) + it('builds a receipt for one actionable lost-pane card and no routine popup', () => { + const fx = createFixture() + const receipt = buildPhase5LossReceipt({ + ...fx, + candidateSha: sha, + runtimeImage, + receiptRunId: runId, + candidateBefore: fx.receipt.candidateIntegrity.before, + candidateAfter: fx.receipt.candidateIntegrity.after, + }) + expect(receipt.summary).toEqual(fx.receipt.summary) + expect(validatePhase5LossReceipt({ + ...fx, candidateSha: sha, runtimeImage, receipt, + }).summary).toEqual(fx.receipt.summary) + }) + + it.each([ + ['missing cards', (row: any) => { delete row.browser.recoveryCards }, /missing.*recoveryCards/i], + ['no card', (row: any) => { row.browser.recoveryCards = [] }, /exactly one.*card/i], + ['duplicate cards', (row: any) => { row.browser.recoveryCards.push({ ...row.browser.recoveryCards[0] }) }, /exactly one.*card/i], + ['wrong pane', (row: any) => { row.browser.recoveryCards[0].paneId = 'pane-unrelated' }, /exact.*pane/i], + ['missing pane identity', (row: any) => { delete row.browser.recoveryCards[0].paneId }, /missing.*paneId/i], + ['hidden loss message', (row: any) => { row.browser.recoveryCards[0].lossMessageVisible = false }, /loss message/i], + ['disabled action', (row: any) => { row.browser.recoveryCards[0].startNewConversationEnabled = false }, /actionable/i], + ['missing action', (row: any) => { delete row.browser.recoveryCards[0].startNewConversationEnabled }, /missing.*startNewConversationEnabled/i], + ['unintended popup', (row: any) => { row.browser.routineNoticeCount = 1 }, /routine.*popup/i], + ['missing popup measurement', (row: any) => { delete row.browser.routineNoticeCount }, /missing.*routineNoticeCount/i], + ['invalid popup measurement', (row: any) => { row.browser.routineNoticeCount = -1 }, /notice count/i], + ['fabricated displayed notice', (row: any) => { row.browser.displayedNoticeIds = ['notice-unobserved'] }, /unknown.*displayedNoticeIds/i], + ] as const)('rejects %s in the measured browser evidence', (_name, mutate, error) => { + const fx = createFixture() + fx.rewrite(PHASE5_LOSS_ASSERTIONS_FILE, mutate) + fx.rehash(PHASE5_LOSS_ASSERTIONS_FILE) + expect(() => validatePhase5LossReceipt({ + ...fx, candidateSha: sha, runtimeImage, receipt: fx.receipt, + })).toThrow(error) + }) + it('rejects schema v1 and unknown receipt fields', () => { const fx = createFixture() expect(() => validatePhase5LossReceipt({ ...fx, candidateSha: sha, runtimeImage, receipt: { ...fx.receipt, schemaVersion: 1 } })).toThrow(/schema v2/i) expect(() => validatePhase5LossReceipt({ ...fx, candidateSha: sha, runtimeImage, receipt: { ...fx.receipt, credentialsTouched: false } })).toThrow(/unknown field/i) }) + it('rejects the previous assertion schema that claimed a routine notice was displayed', () => { + const fx = createFixture() + fx.rewrite(PHASE5_LOSS_ASSERTIONS_FILE, (row) => { row.schemaVersion = 1 }) + fx.rehash(PHASE5_LOSS_ASSERTIONS_FILE) + expect(() => validatePhase5LossReceipt({ + ...fx, candidateSha: sha, runtimeImage, receipt: fx.receipt, + })).toThrow(/wrong schema/i) + }) + + it.each([ + ['popup', (summary: any) => { summary.routineNoticeCount = 1 }], + ['card', (summary: any) => { summary.actionableRecoveryCardCount = 0 }], + ] as const)('rejects a %s summary that contradicts hashed browser evidence', (_name, mutate) => { + const fx = createFixture() + mutate(fx.receipt.summary) + expect(() => validatePhase5LossReceipt({ + ...fx, candidateSha: sha, runtimeImage, receipt: fx.receipt, + })).toThrow(/summary/i) + }) + it.each([ ['missing cleanup', 'cleanup.json'], ['missing incident', PHASE5_LOSS_INCIDENT_FILE], @@ -348,7 +413,7 @@ describe('Phase 5 loss receipt validation', () => { for (const mutation of [ (row: any) => { row.test.total = 0; row.test.passed = 0 }, (row: any) => { row.test.skipped = 1 }, - (row: any) => { row.browser.displayedNoticeIds = [] }, + (row: any) => { row.browser.recoveryCards = [] }, ]) { const fx = createFixture() fx.rewrite(PHASE5_LOSS_ASSERTIONS_FILE, mutation) @@ -422,6 +487,7 @@ describe('Phase 5 loss receipt validation', () => { for (const [file, mutation] of [ [PHASE5_LOSS_INCIDENT_FILE, (row: any) => { row.cleanup.foreignObjectsTouched = 1 }], [PHASE5_LOSS_INCIDENT_FILE, (row: any) => { row.cleanup.ownedHandleRef = 'registry://wrong' }], + [PHASE5_LOSS_INCIDENT_FILE, (row: any) => { row.noticeId = '' }], [PHASE5_LOSS_ASSERTIONS_FILE, (row: any) => { row.providerState.credentialIntegrity[0].afterSha256 = '1'.repeat(64) }], [PHASE5_LOSS_ASSERTIONS_FILE, (row: any) => { row.browser.endedPane.incarnationId = 'incarnation-wrong' }], ] as const) { diff --git a/test/unit/tooling/testing/runtime-test-broker.test.ts b/test/unit/tooling/testing/runtime-test-broker.test.ts index 671c5409e..35897a4fc 100644 --- a/test/unit/tooling/testing/runtime-test-broker.test.ts +++ b/test/unit/tooling/testing/runtime-test-broker.test.ts @@ -1,5 +1,8 @@ import { createHash } from 'node:crypto' import os from 'node:os' +import fs from 'node:fs/promises' +import http from 'node:http' +import path from 'node:path' import { describe, expect, it } from 'vitest' import { RestrictedDockerBroker } from '../../../../scripts/testing/runtime-test-broker.js' @@ -149,3 +152,202 @@ describe('managed provider host gateway', () => { expect(validate(createBody([...ordinaryBinds, actorBind], undefined, ['host.docker.internal:203.0.113.7'])).ok).toBe(false) }) }) + +const ownedId = 'a'.repeat(64) +const helperId = 'b'.repeat(64) +const providerVolume = 'freshell-provider-aaaaaaaaaaaaaaaaaaaaaaaa' +const helperName = 'freshell-history-11111111-1111-4111-8111-111111111111' +function historyHelperBody() { + return { + Image: 'sha256:fixture-image', + User: '65534:0', + Tty: true, + Entrypoint: ['/runtime/freshell-session-host'], + Cmd: ['native-history-only', '--provider', 'codex', '--session-id', 'native-owned', '--provider-home', '/home/freshell/provider'], + Env: ['HOME=/home/freshell/provider'], + HostConfig: { + NetworkMode: 'none', + ReadonlyRootfs: true, + CapDrop: ['ALL'], + SecurityOpt: ['no-new-privileges'], + Memory: 256 * 1024 * 1024, + MemorySwap: 256 * 1024 * 1024, + NanoCpus: 500_000_000, + PidsLimit: 32, + Tmpfs: { '/tmp': 'rw,noexec,nosuid,nodev,size=16m' }, + Mounts: [ + { Type: 'bind', Source: '/tmp/owned/freshell-session-host', Target: '/runtime/freshell-session-host', ReadOnly: true }, + { Type: 'volume', Source: providerVolume, Target: '/home/freshell/provider', ReadOnly: true }, + ], + }, + } +} + +type ForwardedRequest = { method: string; url: string; body: any } +type BrokerHttpFixture = { + request(method: string, url: string, body?: unknown): Promise<{ status: number; body: string }> + forwarded: ForwardedRequest[] + broker: RestrictedDockerBroker + loseHelperAck(): void + returnHelperId(id: string): void +} + +async function withHttpBroker(run: (fixture: BrokerHttpFixture) => Promise) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'frs-broker-history-')) + const socket = path.join(dir, 'docker.sock') + const proxy = path.join(dir, 'broker.sock') + const forwarded: ForwardedRequest[] = [] + let loseAck = false + let helperAckId = helperId + const upstream = http.createServer(async (req, res) => { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(Buffer.from(chunk)) + const raw = Buffer.concat(chunks).toString() + const body = raw ? JSON.parse(raw) : undefined + forwarded.push({ method: req.method!, url: req.url!, body }) + if (req.url?.startsWith('/v1.47/containers/create?')) { + if (body.Entrypoint?.[0] === '/runtime/freshell-session-host' && loseAck) { + req.socket.destroy() + return + } + res.statusCode = 201 + res.end(JSON.stringify({ Id: body.Entrypoint ? helperAckId : ownedId })) + return + } + if (req.method === 'DELETE' || req.url?.endsWith('/start') || req.url?.endsWith('/update')) { + res.statusCode = 204 + res.end() + return + } + res.statusCode = 200 + res.end(req.url?.includes('/wait?') ? '{"StatusCode":0}' + : req.url?.includes('/logs?') ? '{"threadId":"native-owned"}' : JSON.stringify({ Name: providerVolume })) + }) + const instance = new RestrictedDockerBroker({ + realSocketPath: socket, + proxySocketPath: proxy, + runtimeRootPrefix: '/tmp/owned/r', + allowedHostBinaryPaths: new Set(['/tmp/owned/freshell-session-host', '/tmp/owned/other-approved-host']), + allowedImageRefs: new Set(['sha256:fixture-image', 'sha256:other-approved-image']), + allowTerminalWorkloads: true, + allowedWorkspaceRoots: new Set(['/workspace']), + testRunId: 'run-one', + logPath: path.join(dir, 'broker.jsonl'), + }) + const request: BrokerHttpFixture['request'] = (method, url, body) => new Promise((resolve, reject) => { + const req = http.request({ socketPath: proxy, path: url, method }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + res.on('end', () => resolve({ status: res.statusCode!, body: Buffer.concat(chunks).toString() })) + }) + req.on('error', reject) + req.end(body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body)) + }) + try { + await new Promise((resolve) => upstream.listen(socket, resolve)) + await instance.start() + await run({ request, forwarded, broker: instance, loseHelperAck: () => { loseAck = true }, returnHelperId: (id) => { helperAckId = id } }) + } finally { + await instance.close() + await new Promise((resolve) => upstream.close(() => resolve())) + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe('receipt-owned read-only history HTTP routes', () => { + it('forwards the owned volume and exact helper lifecycle while refusing foreign sources and controls', async () => { + await withHttpBroker(async ({ request, forwarded, broker }) => { + expect((await request('POST', '/v1.47/containers/create?name=owned-runtime', createBody([...ordinaryBinds, actorBind]))).status).toBe(201) + expect((await request('GET', `/v1.47/volumes/${providerVolume}`)).status).toBe(200) + const beforeForeign = forwarded.length + expect((await request('GET', '/v1.47/volumes/freshell-provider-ffffffffffffffffffffffff')).status).toBe(403) + expect(forwarded).toHaveLength(beforeForeign) + expect((await request('POST', `/v1.47/containers/create?name=${helperName}`, historyHelperBody())).status).toBe(201) + expect(broker.receiptIds().has(helperId)).toBe(true) + expect(broker.receipts()).toHaveLength(1) + expect(broker.eventsSnapshot().at(-1)).toMatchObject({ helperName, containerId: helperId, ownerContainerId: ownedId, providerVolumeName: providerVolume }) + const beforeDuplicate = forwarded.length + expect((await request('POST', `/v1.47/containers/create?name=${helperName}`, historyHelperBody())).status).toBe(403) + expect(forwarded).toHaveLength(beforeDuplicate) + expect((await request('POST', `/v1.47/containers/${helperId}/start`)).status).toBe(204) + expect((await request('POST', `/v1.47/containers/${helperId}/wait?condition=not-running`)).status).toBe(200) + for (const stderr of ['0', '1']) { + expect((await request('GET', `/v1.47/containers/${helperId}/logs?stdout=1&stderr=${stderr}`)).body).toContain('native-owned') + } + for (const [method, url] of [ + ['POST', `/v1.47/containers/${helperId}/update`], + ['POST', `/v1.47/containers/${helperId}/kill`], + ['POST', `/v1.47/containers/${'f'.repeat(64)}/wait?condition=not-running`], + ['DELETE', '/v1.47/containers/freshell-history-foreign?force=1'], + ['POST', `/v1.47/containers/${ownedId}/wait?condition=not-running`], + ['DELETE', `/v1.47/containers/${helperId}?force=0`], + ['POST', `/v1.47/containers/${helperName}/start`], + ]) { + const before = forwarded.length + expect((await request(method, url)).status).toBe(403) + expect(forwarded).toHaveLength(before) + } + expect((await request('POST', `/v1.47/containers/${ownedId}/update`, {})).status).toBe(204) + expect((await request('DELETE', `/v1.47/containers/${helperName}?force=1`)).status).toBe(204) + expect(forwarded.at(-1)?.url).toBe(`/v1.47/containers/${helperName}?force=1`) + expect((await request('DELETE', `/v1.47/containers/${helperId}?force=1`)).status).toBe(204) + }) + }) + it('refuses foreign and modified helper topology before forwarding', async () => { + await withHttpBroker(async ({ request, forwarded }) => { + await request('POST', '/v1.47/containers/create?name=owned-runtime', createBody([...ordinaryBinds, actorBind])) + const variants = [ + (body: any) => { body.Image = 'sha256:other' }, + (body: any) => { body.Image = 'sha256:other-approved-image' }, + (body: any) => { body.HostConfig.Mounts[0].Source = '/tmp/owned/other-approved-host' }, + (body: any) => { body.Labels = { 'com.freshell.managed': 'true' } }, + (body: any) => { body.HostConfig.Mounts[1].Source = 'freshell-provider-ffffffffffffffffffffffff' }, + (body: any) => { body.HostConfig.Mounts[0].Source = '/etc/passwd' }, + (body: any) => { body.HostConfig.Mounts[1].ReadOnly = false }, + (body: any) => { body.HostConfig.Mounts.push({ Type: 'bind', Source: '/tmp/owned/control', Target: '/run/freshell', ReadOnly: false }) }, + (body: any) => { body.Cmd[0] = 'serve' }, + (body: any) => { body.Entrypoint = ['/bin/sh'] }, + (body: any) => { body.Cmd[6] = '/other/home' }, + (body: any) => { body.HostConfig.NetworkMode = 'bridge' }, + ] + for (const mutate of variants) { + const body = historyHelperBody() + mutate(body) + const before = forwarded.length + expect((await request('POST', `/v1.47/containers/create?name=${helperName}`, body)).status).toBe(403) + expect(forwarded).toHaveLength(before) + } + const before = forwarded.length + expect((await request('POST', '/v1.47/containers/create?name=arbitrary', historyHelperBody())).status).toBe(403) + expect((await request('POST', `/v1.47/containers/${'f'.repeat(64)}/stop`)).status).toBe(403) + expect(forwarded).toHaveLength(before) + }) + }) + it('cleans only the reserved owned helper name after a lost create acknowledgement', async () => { + await withHttpBroker(async ({ request, forwarded, broker, loseHelperAck }) => { + await request('POST', '/v1.47/containers/create?name=owned-runtime', createBody([...ordinaryBinds, actorBind])) + loseHelperAck() + expect((await request('POST', `/v1.47/containers/create?name=${helperName}`, historyHelperBody())).status).toBe(503) + expect(broker.receiptIds().has(helperId)).toBe(false) + const before = forwarded.length + expect((await request('DELETE', '/v1.47/containers/freshell-history-22222222-2222-4222-8222-222222222222?force=1')).status).toBe(403) + expect((await request('POST', `/v1.47/containers/${helperId}/start`)).status).toBe(403) + expect(forwarded).toHaveLength(before) + expect((await request('DELETE', `/v1.47/containers/${helperName}?force=1`)).status).toBe(204) + expect(forwarded.at(-1)?.url).toBe(`/v1.47/containers/${helperName}?force=1`) + }) + }) + it('refuses a non-full helper acknowledgement while retaining exact reserved-name cleanup', async () => { + await withHttpBroker(async ({ request, forwarded, broker, returnHelperId }) => { + await request('POST', '/v1.47/containers/create?name=owned-runtime', createBody([...ordinaryBinds, actorBind])) + returnHelperId('short-id') + expect((await request('POST', `/v1.47/containers/create?name=${helperName}`, historyHelperBody())).status).toBe(503) + expect([...broker.receiptIds()]).toEqual([ownedId]) + const before = forwarded.length + expect((await request('POST', '/v1.47/containers/short-id/start')).status).toBe(403) + expect(forwarded).toHaveLength(before) + expect((await request('DELETE', `/v1.47/containers/${helperName}?force=1`)).status).toBe(204) + }) + }) + +})