diff --git a/AGENTS.md b/AGENTS.md index 963efb852..63ce2b848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,6 +288,8 @@ stderr note). Provisioning, rotation, and revocation live in Agent-driven tab/pane creation is focus-neutral by contract: server-broadcast ui.command create/split folds carry `activate:false` into `addTab`/`splitPane` and never change the user's active tab/pane — focus moves only via the explicit select-tab/select-pane verbs. The Rust layout store (`crates/freshell-freshagent/src/layout_store.rs`) mirrors the same rule: agent creates/splits never move the server cursor either, so REST/MCP cursor-relative operations (next/prev tab, omitted targets) keep addressing the user's selection through the mirror window. Client-side this contract has three layers: (1) pane components gate mount-time autofocus on `focusEligible` (tab active + pane active + visible), and non-eligible browser iframes render `inert`; (2) eligible mounts are additionally ownership-gated (`usePaneFocusAdoption` + `src/lib/pane-focus-ownership.ts`): components record at teardown whether their pane held document focus, so an agent-driven leaf→split remount refocuses only if the pane owned focus before — flips (explicit select) always bypass, and same-target re-selects (no eligibility transition) are signaled via a per-pane focus epoch (`panes.focusEpochByPaneId`, nudged only by the select folds, never by pointer mousedowns, and forwarded by PaneContainer as the `focusEpoch` prop); (3) the app-wide focus-steal guard (`src/lib/focus-steal-guard.ts` via `useFocusStealGuard` in App) rebuffs nested-document focus hoists from `data-focus-locked` non-eligible iframes, which `inert` alone cannot stop (Chromium-verified). A store-level selection serial (`paneSelectionMiddleware`) bumps on every `setActivePane`/`nudgePaneFocus`/`setActiveTab` dispatch, and — by before/after state diff — on any `addTab`/`switchToNextTab`/`switchToPrevTab`/`splitPane`/`addPane`/`removeTab`/`closePane` that actually moved the selection (agent folds pass `activate:false` and stay serial-invisible; background closes move nothing): ownership records captured before any later selection are voided (newer selection wins). Screenshot captures (`captureUiScreenshot`, driven by the `screenshot.capture` ui.command behind `POST /api/screenshots` and the MCP capture verbs) render through html2canvas's off-DOM clone and never mutate the live page: background tabs stay mounted and fully laid out under `.tab-hidden` (CSS `visibility`, not `display` — kept measurable for xterm), html2canvas skips visibility-hidden subtrees when painting, so the clone's target chain (target up to body) gets an inline `visibility: visible` reveal via `onclone` — armed live-side only when the target has a hidden ancestor. Because nothing dispatches and nothing moves, there is no queue, no deadline/ttl, no `screenshot.cancel`, no restore, and no engagement detection; iframes are pre-rendered separately (html2canvas cannot paint them) and swapped into the clone by index+src-fingerprint correlation (a tree that changed between preparation and clone gets no replacements rather than a wrong one), and xterm's WebGL renderers are suspended around the render (refcounted `suspendTerminalRenderersForScreenshot`) so their canvases read back complete. Records for panes re-appearing in layouts (e.g. reopened tabs) are forgotten, and an eligible mount with focus stranded on `document.body` always uses unknown-pane focus semantics (stranding typing is worse than a rare honest refocus — round-14/16 trade). Locked iframes wake on pointerdown anywhere in the pane shell and on Enter/Space targeted at the shell itself (`useIframeFocusLock`) — keyboard-parity click-to-wake. +Freshclaude and Kilroy negotiate `freshAgentAttentionReplayV1` to replay attention missed during a same-page disconnect from `ready.freshAgentAttention`. First connect, reload and server restart seed the page's high-water marks without replaying older highlights or bells; waiting marks are replayed only while an approval or question remains pending. + ### Data Flow 1. Browser loads → fetches settings from the Rust server's `/api/settings` route and sessions from `/api/sessions` diff --git a/crates/freshell-freshagent/src/attention_ledger.rs b/crates/freshell-freshagent/src/attention_ledger.rs new file mode 100644 index 000000000..ae98606af --- /dev/null +++ b/crates/freshell-freshagent/src/attention_ledger.rs @@ -0,0 +1,312 @@ +//! Bounded attention marks for same-page WebSocket reconnects. No guard is +//! held across an await. Marks survive an unrequested runtime death so a +//! disconnected browser can still learn that its turn ended. + +use std::collections::BTreeMap; +use std::sync::Mutex; + +const DEFAULT_CAPACITY: usize = 4096; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AttentionMark { + pub session_id: String, + pub session_type: String, + pub last_turn_complete_at: Option, + pub last_waiting_at: Option, +} + +struct Entry { + mark: AttentionMark, + group: u64, + touched: u64, +} + +#[derive(Default)] +struct Inner { + entries: BTreeMap, + touch: u64, +} + +pub struct AttentionLedger { + inner: Mutex, + capacity: usize, +} + +impl Default for AttentionLedger { + fn default() -> Self { + Self::with_capacity(DEFAULT_CAPACITY) + } +} + +impl AttentionLedger { + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: Mutex::new(Inner::default()), + capacity, + } + } + + pub fn observe(&self, id: &str, session_type: &str) { + self.update(id, session_type, |_| {}); + } + + pub fn record_turn_complete(&self, id: &str, session_type: &str, at: i64) { + self.update(id, session_type, |mark| { + mark.last_turn_complete_at = mark.last_turn_complete_at.max(Some(at)); + }); + } + + pub fn record_waiting(&self, id: &str, session_type: &str, at: i64) { + self.update(id, session_type, |mark| { + mark.last_waiting_at = mark.last_waiting_at.max(Some(at)); + }); + } + + fn update(&self, id: &str, session_type: &str, update: impl Fn(&mut AttentionMark)) { + if self.capacity == 0 { + return; + } + let mut inner = self.inner.lock().expect("attention ledger lock"); + inner.touch += 1; + let touch = inner.touch; + let group = inner + .entries + .entry(id.to_string()) + .or_insert_with(|| Entry { + mark: AttentionMark { + session_id: id.to_string(), + session_type: session_type.to_string(), + last_turn_complete_at: None, + last_waiting_at: None, + }, + group: touch, + touched: touch, + }) + .group; + for entry in inner + .entries + .values_mut() + .filter(|entry| entry.group == group) + { + entry.mark.session_type = session_type.to_string(); + update(&mut entry.mark); + entry.touched = touch; + } + Self::trim(&mut inner, self.capacity); + } + + pub fn clear_waiting(&self, id: &str) { + let mut inner = self.inner.lock().expect("attention ledger lock"); + let Some(group) = inner.entries.get(id).map(|entry| entry.group) else { + return; + }; + inner.touch += 1; + let touch = inner.touch; + for entry in inner + .entries + .values_mut() + .filter(|entry| entry.group == group) + { + entry.mark.last_waiting_at = None; + entry.touched = touch; + } + } + + /// Keep both envelope ids addressable. Later edges and control responses + /// update the whole alias group, including panes that missed the rekey. + pub fn alias(&self, from: &str, to: &str) { + let mut inner = self.inner.lock().expect("attention ledger lock"); + let Some(source) = inner.entries.get(from) else { + return; + }; + let mut mark = source.mark.clone(); + let source_group = source.group; + let target_group = inner.entries.get(to).map(|entry| { + mark.last_turn_complete_at = mark + .last_turn_complete_at + .max(entry.mark.last_turn_complete_at); + mark.last_waiting_at = mark.last_waiting_at.max(entry.mark.last_waiting_at); + entry.group + }); + inner.touch += 1; + let touch = inner.touch; + for entry in inner + .entries + .values_mut() + .filter(|entry| entry.group == source_group || Some(entry.group) == target_group) + { + entry.group = source_group; + entry.touched = touch; + entry.mark.last_turn_complete_at = mark.last_turn_complete_at; + entry.mark.last_waiting_at = mark.last_waiting_at; + } + mark.session_id = to.to_string(); + inner.entries.insert( + to.to_string(), + Entry { + mark, + group: source_group, + touched: touch, + }, + ); + Self::trim(&mut inner, self.capacity); + } + + /// A requested close forgets every envelope for that conversation. + pub fn forget(&self, id: &str) { + let mut inner = self.inner.lock().expect("attention ledger lock"); + if let Some(group) = inner.entries.get(id).map(|entry| entry.group) { + inner.entries.retain(|_, entry| entry.group != group); + } + } + + pub fn snapshot(&self) -> Vec { + self.inner + .lock() + .expect("attention ledger lock") + .entries + .values() + .map(|entry| entry.mark.clone()) + .collect() + } + + fn trim(inner: &mut Inner, capacity: usize) { + while inner.entries.len() > capacity { + let oldest_group = inner + .entries + .values() + .min_by_key(|entry| entry.touched) + .map(|entry| entry.group) + .expect("non-empty ledger"); + // An alias must not outlive the id used by a requested close. + // Removing the whole group keeps forget/rekey coherent at capacity. + inner.entries.retain(|_, entry| entry.group != oldest_group); + } + } +} + +impl crate::claude_edges::AttentionObserver for AttentionLedger { + fn on_registered(&self, id: &str, session_type: &str) { + self.observe(id, session_type); + } + fn on_turn_complete(&self, id: &str, session_type: &str, at: i64) { + self.record_turn_complete(id, session_type, at); + } + fn on_waiting(&self, id: &str, session_type: &str, at: i64) { + self.record_waiting(id, session_type, at); + } + fn on_pending_drained(&self, id: &str) { + self.clear_waiting(id); + } + fn on_rekeyed(&self, old: &str, new: &str) { + self.alias(old, new); + } + fn on_forgotten(&self, id: &str) { + self.forget(id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_monotonic_marks_and_drains_only_waiting() { + let ledger = AttentionLedger::with_capacity(4); + ledger.observe("session", "kilroy"); + assert_eq!( + ledger.snapshot(), + vec![AttentionMark { + session_id: "session".into(), + session_type: "kilroy".into(), + last_turn_complete_at: None, + last_waiting_at: None, + }] + ); + ledger.record_turn_complete("session", "kilroy", 100); + ledger.record_turn_complete("session", "kilroy", 99); + ledger.record_waiting("session", "kilroy", 101); + ledger.record_waiting("session", "kilroy", 98); + assert_eq!(ledger.snapshot()[0].last_turn_complete_at, Some(100)); + assert_eq!(ledger.snapshot()[0].last_waiting_at, Some(101)); + ledger.clear_waiting("session"); + assert_eq!(ledger.snapshot()[0].last_turn_complete_at, Some(100)); + assert_eq!(ledger.snapshot()[0].last_waiting_at, None); + } + + #[test] + fn copies_aliases_and_merges_existing_target_marks() { + let ledger = AttentionLedger::with_capacity(4); + ledger.record_turn_complete("old", "freshclaude", 100); + ledger.record_waiting("old", "freshclaude", 90); + ledger.record_turn_complete("durable", "freshclaude", 110); + ledger.alias("old", "durable"); + let marks = ledger.snapshot(); + assert_eq!(marks.len(), 2); + assert_eq!(marks[0].session_id, "durable"); + assert_eq!(marks[0].last_turn_complete_at, Some(110)); + assert_eq!(marks[0].last_waiting_at, Some(90)); + assert_eq!(marks[1].last_turn_complete_at, Some(110)); + ledger.clear_waiting("durable"); + assert!(ledger + .snapshot() + .iter() + .all(|mark| mark.last_waiting_at.is_none())); + ledger.record_turn_complete("durable", "freshclaude", 120); + assert!(ledger + .snapshot() + .iter() + .all(|mark| mark.last_turn_complete_at == Some(120))); + ledger.forget("old"); + assert!(ledger.snapshot().is_empty()); + } + + #[test] + fn evicts_least_recently_touched_without_snapshot_refreshing_lru() { + let ledger = AttentionLedger::with_capacity(2); + ledger.observe("a", "freshclaude"); + ledger.observe("b", "freshclaude"); + ledger.observe("a", "freshclaude"); + ledger.snapshot(); + ledger.observe("c", "freshclaude"); + assert_eq!( + ledger + .snapshot() + .iter() + .map(|mark| mark.session_id.as_str()) + .collect::>(), + vec!["a", "c"] + ); + } + + #[test] + fn eviction_keeps_alias_groups_consistent_for_a_later_requested_close() { + let ledger = AttentionLedger::with_capacity(3); + ledger.record_turn_complete("z-legacy", "freshclaude", 100); + ledger.alias("z-legacy", "a-durable"); + ledger.observe("other-1", "freshclaude"); + ledger.observe("other-2", "freshclaude"); + // The canonical id sorts first among equally old aliases. Evicting + // just that entry would leave a legacy mark which forget cannot find. + ledger.forget("a-durable"); + let remaining: Vec = ledger + .snapshot() + .into_iter() + .map(|mark| mark.session_id) + .collect(); + assert_eq!(remaining, vec!["other-1", "other-2"]); + } + + #[test] + fn zero_capacity_and_unknown_controls_do_not_add_entries() { + let ledger = AttentionLedger::with_capacity(0); + ledger.observe("session", "freshclaude"); + ledger.record_turn_complete("session", "freshclaude", 1); + assert!(ledger.snapshot().is_empty()); + let ledger = AttentionLedger::with_capacity(2); + ledger.clear_waiting("unknown"); + ledger.alias("unknown", "other"); + ledger.forget("unknown"); + assert!(ledger.snapshot().is_empty()); + } +} diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 63edb3c15..e43e48122 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -821,6 +821,11 @@ impl FreshClaudeState { self.edges.subscribe() } + /// Last attention marks retained for negotiated same-page reconnects. + pub fn attention_marks(&self) -> Vec { + self.edges.attention_marks() + } + /// Wire where every sidecar's CLI reaches this server (G19): its /// environment carries `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`. /// Set-once; `false` when an endpoint was already wired. diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index 2a950c9ab..03f6a24ae 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -10,9 +10,9 @@ //! source are strictly ordered per session (G24); //! 2. is logged at info as `freshagent.turn.complete` / `freshagent.turn.waiting` //! with the provider, the ids, the source and `at` (never content, G14); -//! 3. is broadcast as the client frame (its shape is unchanged); -//! 4. is reported to the [`AttentionObserver`], when one is installed (the -//! client-alerts work's attention ledger, §9.2 I-1); +//! 3. is retained in the replay ledger before it is broadcast as the client +//! frame (its shape is unchanged); +//! 4. is reported to an additional [`AttentionObserver`], when installed; //! 5. is published as a typed [`ClaudeTurnEvent`] on a low-volume channel of its //! own (the agent API's turn correlation, §9.3 I3), which never carries //! stream traffic and so cannot lag behind it. A result the user's Stop @@ -139,6 +139,7 @@ pub(crate) struct SettledReport { #[derive(Clone)] pub(crate) struct ClaudeEdgeHub { broadcast_tx: Arc>, + ledger: Arc, observer: Arc>>>, turn_events: broadcast::Sender, } @@ -147,6 +148,7 @@ impl ClaudeEdgeHub { pub(crate) fn new(broadcast_tx: Arc>) -> Self { Self { broadcast_tx, + ledger: Arc::default(), observer: Arc::new(RwLock::new(None)), turn_events: broadcast::channel(TURN_EVENTS_CAPACITY).0, } @@ -162,7 +164,26 @@ impl ClaudeEdgeHub { self.turn_events.subscribe() } - fn observe(&self, notify: impl FnOnce(&dyn AttentionObserver)) { + pub(crate) fn attention_marks(&self) -> Vec { + self.ledger + .snapshot() + .into_iter() + .map(|mark| freshell_protocol::FreshAgentAttentionMark { + provider: PROVIDER.to_string(), + session_id: mark.session_id, + session_type: mark.session_type, + last_turn_complete_at: mark.last_turn_complete_at, + last_waiting_at: mark.last_waiting_at, + }) + .collect() + } + + fn observe(&self, notify: impl Fn(&dyn AttentionObserver)) { + notify(self.ledger.as_ref()); + self.observe_external(notify); + } + + fn observe_external(&self, notify: impl FnOnce(&dyn AttentionObserver)) { let observer = self .observer .read() @@ -205,15 +226,19 @@ impl ClaudeEdgeHub { session_id: record.stamp.to_string(), session_type: record.session_type.to_string(), }); - if let Ok(frame) = serde_json::to_string(&frame) { - let _ = self.broadcast_tx.send(frame); - } - self.observe(|observer| match kind { + let observe_edge = |observer: &dyn AttentionObserver| match kind { EdgeKind::TurnComplete => { observer.on_turn_complete(record.stamp, record.session_type, at) } EdgeKind::Waiting => observer.on_waiting(record.stamp, record.session_type, at), - }); + }; + // Retain before publication: a socket subscribing just after this + // broadcast must already see the mark in its ready snapshot. + observe_edge(self.ledger.as_ref()); + if let Ok(frame) = serde_json::to_string(&frame) { + let _ = self.broadcast_tx.send(frame); + } + self.observe_external(observe_edge); at } @@ -286,6 +311,42 @@ mod tests { use super::*; use crate::claude_sidecar_io::test_capture::{capture, with_message}; + #[test] + fn replay_marks_are_retained_before_the_edge_wakes_a_receiver() { + use std::future::Future; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::task::{Context, Poll, Wake, Waker}; + + // broadcast::send wakes the receiver synchronously. Reading the ledger + // from that waker pins the publication order without a timing race. + struct SnapshotOnWake { + ledger: Arc, + observed: AtomicBool, + } + impl Wake for SnapshotOnWake { + fn wake(self: Arc) { + assert!(self + .ledger + .snapshot() + .iter() + .any(|mark| mark.last_waiting_at.is_some())); + self.observed.store(true, Ordering::SeqCst); + } + } + let (hub, mut rx) = hub(); + let witness = Arc::new(SnapshotOnWake { + ledger: hub.ledger.clone(), + observed: AtomicBool::new(false), + }); + let waker = Waker::from(witness.clone()); + let mut context = Context::from_waker(&waker); + let mut receive = std::pin::pin!(rx.recv()); + assert!(matches!(receive.as_mut().poll(&mut context), Poll::Pending)); + let clock = AttentionClock::default(); + hub.waiting(&record(&clock)); + assert!(witness.observed.load(Ordering::SeqCst)); + } + /// Every hook call, in order. #[derive(Default)] struct Recorder(Mutex>); @@ -370,6 +431,11 @@ mod tests { complete < waiting && waiting < death, "one clock: {complete} {waiting} {death}" ); + // Installing an extra observer never replaces replay retention. + let marks = hub.attention_marks(); + assert_eq!(marks.len(), 1); + assert_eq!(marks[0].last_turn_complete_at, Some(death)); + assert_eq!(marks[0].last_waiting_at, Some(waiting)); let frames = frames(&mut rx); for frame in &frames { diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index dff7664ca..08e7150a3 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -39,6 +39,7 @@ /// The per-session attention clock and edge builders shared by the fresh-agent /// lanes (design 2026-10-22 §3.2, §5.5). pub(crate) mod attention; +pub mod attention_ledger; pub mod claude; pub(crate) mod claude_dead_sessions; pub(crate) mod claude_edges; diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index 042de35db..c24aad32f 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -17,10 +17,9 @@ //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected //! — every dispatched line of JS is the real source, read at test time. //! -//! Observable contract (fire-and-forget interrupt, per the comment above -//! `handleInterrupt`): an interrupt for an unknown session emits an -//! `sdk.error "session not found"` frame on stdout. Pre-fix behavior was a -//! stderr `unknown request type: interrupt` log and NO stdout frame. +//! Protocol v2 answers an interrupt for an unknown session with a failed +//! `sdk.interrupt_settled` carrying the submitted Stop id. A missing dispatch +//! produces no stdout response; a generic error cannot settle the Stop latch. //! //! Requires `node` on PATH — the same requirement the crate's existing //! fake-sidecar tests already impose. @@ -168,22 +167,21 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let mut stdin = sidecar.child.stdin.take().expect("piped stdin"); stdin - .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\"}\n") + .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\",\"stopId\":\"missing-session-stop\"}\n") .expect("write interrupt frame"); stdin.flush().expect("flush interrupt frame"); - // Post-fix observable: handleInterrupt emits the session-not-found sdk.error - // frame on stdout. Pre-fix: the frame is dropped by the `default:` arm and - // stderr logs `unknown request type: interrupt` instead. - let mut error_frame: Option = None; + // The real dispatch must settle this exact Stop, even when its session is + // missing. The old default arm dropped it without answering. + let mut settled_frame: Option = None; let deadline = std::time::Instant::now() + Duration::from_secs(30); - while error_frame.is_none() { + while settled_frame.is_none() { let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else { break; }; match rx.recv_timeout(remaining) { Ok(("stdout", line)) => { - error_frame = + settled_frame = Some(serde_json::from_str(&line).expect("sidecar stdout is newline-JSON")); } Ok((_, line)) => stderr_lines.push(line), @@ -200,10 +198,17 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { "pre-fix behavior: the interrupt frame fell into the dispatch default arm and was \ dropped; stderr: {stderr_lines:?}" ); - let frame = error_frame.expect( + let frame = settled_frame.expect( "no stdout frame arrived: the interrupt request was not dispatched to handleInterrupt", ); - assert_eq!(frame["type"], "sdk.error", "frame: {frame}"); + assert_eq!(frame["type"], "sdk.interrupt_settled", "frame: {frame}"); assert_eq!(frame["sessionId"], "nope", "frame: {frame}"); - assert_eq!(frame["message"], "session not found", "frame: {frame}"); + assert_eq!(frame["stopId"], "missing-session-stop", "frame: {frame}"); + assert_eq!(frame["ok"], false, "frame: {frame}"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "a refused Stop includes its reason: {frame}" + ); } diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 3ada8213c..3d96c32fd 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -216,6 +216,9 @@ pub struct HelloCapabilities { /// managed-runtime controller is actually installed for the current boot. #[serde(skip_serializing_if = "Option::is_none")] pub managed_runtime_v1: Option, + /// Replay attention missed during a same-page disconnect, seeded on reload. + #[serde(skip_serializing_if = "Option::is_none")] + pub fresh_agent_attention_replay_v1: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 30d0a8c9e..9fd53cbe4 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1256,6 +1256,8 @@ pub struct ReadyCapabilities { /// opted in and this server boot has an installed managed controller. #[serde(skip_serializing_if = "Option::is_none")] pub managed_runtime_v1: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fresh_agent_attention_replay_v1: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -1285,6 +1287,22 @@ pub struct Ready { /// (frozen-client inertness — same rule as `boot_id`/`build_id`). #[serde(skip_serializing_if = "Option::is_none")] pub runtime_owners: Option>, + /// Negotiated attention marks. A first connection seeds them silently; + /// a reconnect replays marks newer than the page's own high-water marks. + #[serde(skip_serializing_if = "Option::is_none")] + pub fresh_agent_attention: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentAttentionMark { + pub provider: String, + pub session_id: String, + pub session_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_turn_complete_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_waiting_at: Option, } /// One `ready.runtimeOwners` replay record (kata b8ke reconnect-owner @@ -2413,6 +2431,7 @@ mod tests { // b8ke focused round-3 R3-5: every replay record carries its state // truth ("live" | "fenced") — a fenced record adds the typed reason. let msg = ServerMessage::Ready(Ready { + fresh_agent_attention: None, timestamp: "2026-09-09T00:00:00Z".into(), boot_id: Some("boot-1".into()), server_instance_id: Some("inst-1".into()), @@ -2455,6 +2474,7 @@ mod tests { let back: ServerMessage = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back, msg); let legacy = ServerMessage::Ready(Ready { + fresh_agent_attention: None, timestamp: "2026-09-09T00:00:00Z".into(), boot_id: Some("boot-1".into()), server_instance_id: Some("inst-1".into()), diff --git a/crates/freshell-protocol/tests/fresh_agent_attention.rs b/crates/freshell-protocol/tests/fresh_agent_attention.rs new file mode 100644 index 000000000..6bb2a044d --- /dev/null +++ b/crates/freshell-protocol/tests/fresh_agent_attention.rs @@ -0,0 +1,29 @@ +use freshell_protocol::{ClientMessage, ServerMessage}; +use serde_json::json; + +#[test] +fn attention_replay_capability_and_marks_survive_wire_round_trip() { + let hello_wire = json!({ + "type": "hello", "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + "capabilities": { "freshAgentAttentionReplayV1": true }, + }); + let hello: ClientMessage = serde_json::from_value(hello_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(hello).unwrap(), hello_wire); + let ready_wire = json!({ + "type": "ready", "timestamp": "now", + "capabilities": { "freshAgentAttentionReplayV1": true }, + "freshAgentAttention": [{ + "provider": "claude", "sessionId": "session", "sessionType": "kilroy", + "lastTurnCompleteAt": 100, "lastWaitingAt": 101, + }], + }); + let ready: ServerMessage = serde_json::from_value(ready_wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(ready).unwrap(), ready_wire); +} + +#[test] +fn legacy_wire_does_not_acquire_attention_replay_fields() { + let wire = json!({ "type": "ready", "timestamp": "now" }); + let ready: ServerMessage = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(ready).unwrap(), wire); +} diff --git a/crates/freshell-protocol/tests/pane_reconcile.rs b/crates/freshell-protocol/tests/pane_reconcile.rs index c85a3347f..15f1fbce2 100644 --- a/crates/freshell-protocol/tests/pane_reconcile.rs +++ b/crates/freshell-protocol/tests/pane_reconcile.rs @@ -62,6 +62,7 @@ fn ready_capabilities_field_is_omitted_when_none() { build_id: None, capabilities: None, runtime_owners: None, + fresh_agent_attention: None, }; let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes"); assert!( @@ -78,6 +79,7 @@ fn ready_capabilities_advertise_pane_reconcile_v1_when_negotiated() { server_instance_id: Some("srv-1".to_string()), build_id: None, capabilities: Some(ReadyCapabilities { + fresh_agent_attention_replay_v1: None, pane_reconcile_v1: Some(true), pane_reconcile_fresh_agent_v1: None, terminal_interest_v1: None, @@ -86,6 +88,7 @@ fn ready_capabilities_advertise_pane_reconcile_v1_when_negotiated() { managed_runtime_v1: None, }), runtime_owners: None, + fresh_agent_attention: None, }; let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes"); assert_eq!(wire["capabilities"], json!({ "paneReconcileV1": true })); diff --git a/crates/freshell-protocol/tests/terminal_lifetime_claim.rs b/crates/freshell-protocol/tests/terminal_lifetime_claim.rs index af096911b..5608e72a2 100644 --- a/crates/freshell-protocol/tests/terminal_lifetime_claim.rs +++ b/crates/freshell-protocol/tests/terminal_lifetime_claim.rs @@ -54,6 +54,7 @@ fn ready_capabilities_advertise_terminal_lifetime_claim_v1_when_negotiated() { server_instance_id: Some("srv-1".to_string()), build_id: None, capabilities: Some(ReadyCapabilities { + fresh_agent_attention_replay_v1: None, pane_reconcile_v1: None, pane_reconcile_fresh_agent_v1: None, terminal_interest_v1: Some(true), @@ -62,6 +63,7 @@ fn ready_capabilities_advertise_terminal_lifetime_claim_v1_when_negotiated() { managed_runtime_v1: None, }), runtime_owners: None, + fresh_agent_attention: None, }; let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes"); assert_eq!( @@ -78,6 +80,7 @@ fn ready_capabilities_omit_terminal_lifetime_claim_v1_when_none() { server_instance_id: Some("srv-1".to_string()), build_id: None, capabilities: Some(ReadyCapabilities { + fresh_agent_attention_replay_v1: None, pane_reconcile_v1: Some(true), pane_reconcile_fresh_agent_v1: None, terminal_interest_v1: None, @@ -86,6 +89,7 @@ fn ready_capabilities_omit_terminal_lifetime_claim_v1_when_none() { managed_runtime_v1: None, }), runtime_owners: None, + fresh_agent_attention: None, }; let wire = serde_json::to_value(ServerMessage::Ready(ready)).expect("serializes"); assert_eq!(wire["capabilities"], json!({ "paneReconcileV1": true })); diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 1e6d7c047..0e7296e75 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -613,7 +613,18 @@ pub fn stuck_window_ms_from_env() -> i64 { /// would lose scrollback). On a truly fresh boot the registry is empty, so this stays /// byte-identical to the clean-boot handshake the oracle's T0/determinism tiers pin. pub async fn build_handshake(state: &WsState) -> Vec { - build_handshake_with_capabilities(state, false, false, false, false, false, false).await + build_handshake_with_capabilities(state, HandshakeCapabilities::default()).await +} + +#[derive(Clone, Copy, Default)] +pub struct HandshakeCapabilities { + pub pane_reconcile_v1: bool, + pub pane_reconcile_fresh_agent_v1: bool, + pub terminal_interest_v1: bool, + pub paced_terminal_replay_v1: bool, + pub terminal_lifetime_claim_v1: bool, + pub managed_runtime_v1: bool, + pub fresh_agent_attention_replay_v1: bool, } /// [`build_handshake`], parameterized on the connection's negotiated @@ -633,14 +644,24 @@ pub async fn build_handshake(state: &WsState) -> Vec { /// the NEXT connection. pub async fn build_handshake_with_capabilities( state: &WsState, - pane_reconcile_v1: bool, - pane_reconcile_fresh_agent_v1: bool, - terminal_interest_v1: bool, - paced_terminal_replay_v1: bool, - terminal_lifetime_claim_v1: bool, - managed_runtime_v1: bool, + capabilities: HandshakeCapabilities, ) -> Vec { + let HandshakeCapabilities { + pane_reconcile_v1, + pane_reconcile_fresh_agent_v1, + terminal_interest_v1, + paced_terminal_replay_v1, + terminal_lifetime_claim_v1, + managed_runtime_v1, + fresh_agent_attention_replay_v1, + } = capabilities; let boot_id = state.boot_id.as_ref().clone(); + let fresh_agent_attention = fresh_agent_attention_replay_v1 + .then(|| state.fresh_claude.attention_marks()) + .filter(|marks| !marks.is_empty()); + if let Some(marks) = fresh_agent_attention.as_ref() { + tracing::debug!(count = marks.len(), "ws.handshake.attention_marks"); + } // kata b8ke Task 4 (reconnect-owner discovery, T1 rec A3): replay current // runtime-owner state on EVERY handshake — a device that missed a // handoff broadcast (offline during handoff, lag-4008 disconnect, page @@ -685,6 +706,7 @@ pub async fn build_handshake_with_capabilities( }); let mut messages = vec![ ServerMessage::Ready(Ready { + fresh_agent_attention, runtime_owners, timestamp: now_iso(), boot_id: Some(boot_id.clone()), @@ -695,8 +717,11 @@ pub async fn build_handshake_with_capabilities( || terminal_interest_v1 || paced_terminal_replay_v1 || terminal_lifetime_claim_v1 - || managed_runtime_v1) + || managed_runtime_v1 + || fresh_agent_attention_replay_v1) .then_some(freshell_protocol::ReadyCapabilities { + fresh_agent_attention_replay_v1: fresh_agent_attention_replay_v1 + .then_some(true), pane_reconcile_v1: pane_reconcile_v1.then_some(true), pane_reconcile_fresh_agent_v1: pane_reconcile_fresh_agent_v1.then_some(true), terminal_interest_v1: terminal_interest_v1.then_some(true), @@ -952,17 +977,26 @@ async fn handle_socket( .unwrap_or(false) && state.registry.has_managed_controller(); + let fresh_agent_attention_replay_v1 = value + .get("capabilities") + .and_then(|caps| caps.get("freshAgentAttentionReplayV1")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + // Authenticated: emit the ordered handshake. CFG-12: the builder is // async + per-connection so its `settings.updated` frame resolves the // LIVE settings tree (see `build_handshake_with_capabilities`). for msg in build_handshake_with_capabilities( &state, - pane_reconcile_v1, - pane_reconcile_fresh_agent_v1, - terminal_interest_v1, - paced_terminal_replay_v1, - terminal_lifetime_claim_v1, - managed_runtime_v1, + HandshakeCapabilities { + pane_reconcile_v1, + pane_reconcile_fresh_agent_v1, + terminal_interest_v1, + paced_terminal_replay_v1, + terminal_lifetime_claim_v1, + managed_runtime_v1, + fresh_agent_attention_replay_v1, + }, ) .await { @@ -1212,8 +1246,14 @@ mod tests { #[tokio::test] async fn handshake_advertises_pane_reconcile_only_when_negotiated() { let s = state(); - let negotiated = - build_handshake_with_capabilities(&s, true, false, false, false, false, false).await; + let negotiated = build_handshake_with_capabilities( + &s, + HandshakeCapabilities { + pane_reconcile_v1: true, + ..Default::default() + }, + ) + .await; let ready = serde_json::to_value(&negotiated[0]).unwrap(); assert_eq!( ready["capabilities"], @@ -1228,7 +1268,7 @@ mod tests { ); // Same shape as an explicit `false` negotiation. let unnegotiated = - build_handshake_with_capabilities(&s, false, false, false, false, false, false).await; + build_handshake_with_capabilities(&s, HandshakeCapabilities::default()).await; let ready2 = serde_json::to_value(&unnegotiated[0]).unwrap(); assert!(ready2.get("capabilities").is_none()); } @@ -1240,8 +1280,14 @@ mod tests { #[tokio::test] async fn handshake_advertises_paced_terminal_replay_only_when_negotiated() { let s = state(); - let negotiated = - build_handshake_with_capabilities(&s, false, false, false, true, false, false).await; + let negotiated = build_handshake_with_capabilities( + &s, + HandshakeCapabilities { + paced_terminal_replay_v1: true, + ..Default::default() + }, + ) + .await; let ready = serde_json::to_value(&negotiated[0]).unwrap(); assert_eq!( ready["capabilities"], @@ -1250,8 +1296,14 @@ mod tests { // Non-paced negotiations keep the capabilities object byte-identical // to today's output — no paced key is invented. - let pane_only = - build_handshake_with_capabilities(&s, true, false, false, false, false, false).await; + let pane_only = build_handshake_with_capabilities( + &s, + HandshakeCapabilities { + pane_reconcile_v1: true, + ..Default::default() + }, + ) + .await; let ready = serde_json::to_value(&pane_only[0]).unwrap(); assert_eq!( ready["capabilities"], @@ -1275,8 +1327,15 @@ mod tests { #[tokio::test] async fn handshake_advertises_terminal_lifetime_claim_only_when_negotiated() { let s = state(); - let negotiated = - build_handshake_with_capabilities(&s, false, false, true, false, true, false).await; + let negotiated = build_handshake_with_capabilities( + &s, + HandshakeCapabilities { + terminal_interest_v1: true, + terminal_lifetime_claim_v1: true, + ..Default::default() + }, + ) + .await; let ready = serde_json::to_value(&negotiated[0]).unwrap(); assert_eq!( ready["capabilities"], @@ -1284,8 +1343,14 @@ mod tests { ); // A claim-less negotiation must not invent the key. - let interest_only = - build_handshake_with_capabilities(&s, false, false, true, false, false, false).await; + let interest_only = build_handshake_with_capabilities( + &s, + HandshakeCapabilities { + terminal_interest_v1: true, + ..Default::default() + }, + ) + .await; let ready = serde_json::to_value(&interest_only[0]).unwrap(); assert_eq!( ready["capabilities"], diff --git a/crates/freshell-ws/tests/freshagent_claude_attention_replay.rs b/crates/freshell-ws/tests/freshagent_claude_attention_replay.rs new file mode 100644 index 000000000..95c7c5de8 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_attention_replay.rs @@ -0,0 +1,136 @@ +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RIG_FRAME_BUDGET}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio_tungstenite::tungstenite::Message; + +async fn ready(rig: &ClaudeRig, replay: bool) -> Value { + let (mut socket, _) = tokio_tungstenite::connect_async(&rig.url).await.unwrap(); + socket + .send(Message::Text( + json!({ + "type": "hello", "token": common::AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + "capabilities": { "freshAgentAttentionReplayV1": replay }, + }) + .to_string(), + )) + .await + .unwrap(); + let frame = tokio::time::timeout(RIG_FRAME_BUDGET, socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let Message::Text(frame) = frame else { + panic!("the first frame is ready"); + }; + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!( + value["type"], "ready", + "the snapshot precedes live frames: {value}" + ); + value +} + +fn mark<'a>(ready: &'a Value, session_id: &str) -> &'a Value { + ready["freshAgentAttention"] + .as_array() + .unwrap_or_else(|| panic!("attention snapshot missing: {ready}")) + .iter() + .find(|mark| mark["sessionId"] == session_id) + .unwrap_or_else(|| panic!("session {session_id} missing: {ready}")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ready_retains_created_and_completed_sessions_only_for_opted_in_clients() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut first = rig.connect().await; + let session_id = first.create("create-1").await; + let created = ready(&rig, true).await; + assert!(mark(&created, &session_id) + .get("lastTurnCompleteAt") + .is_none()); + first.send_text(&session_id, "send-1", "hello").await; + let edge = first.wait_for_event("freshAgent.turn.complete").await; + let reconnect = ready(&rig, true).await; + assert_eq!( + mark(&reconnect, &session_id)["lastTurnCompleteAt"], + edge["event"]["at"] + ); + assert_eq!(mark(&reconnect, &session_id)["provider"], "claude"); + assert!(ready(&rig, false) + .await + .get("freshAgentAttention") + .is_none()); + first.kill(&session_id).await; + assert!(ready(&rig, true).await.get("freshAgentAttention").is_none()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ready_replays_waiting_only_until_the_last_control_is_answered() { + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "ask" }, + "emit": [{ "kind": "approval", "data": { "tool": "Bash", "input": { "command": "ls" } } }], + }] }) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]).await; + let mut first = rig.connect().await; + let created = first + .request_create("create-approval", json!({ "permissionMode": "default" })) + .await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + first.send_text(&session_id, "send-approval", "ask").await; + let request = first.wait_for_event("freshAgent.permission.request").await; + let waiting = first.wait_for_event("freshAgent.turn.waiting").await; + let reconnect = ready(&rig, true).await; + assert_eq!( + mark(&reconnect, &session_id)["lastWaitingAt"], + waiting["event"]["at"] + ); + first.send(json!({ + "type": "freshAgent.approval.respond", "provider": "claude", "sessionType": "freshclaude", + "sessionId": session_id, "requestId": request["event"]["requestId"], + "decision": { "behavior": "allow", "updatedInput": { "command": "ls" } }, + })).await; + let complete = first.wait_for_event("freshAgent.turn.complete").await; + let reconnect = ready(&rig, true).await; + assert!(mark(&reconnect, &session_id).get("lastWaitingAt").is_none()); + assert_eq!( + mark(&reconnect, &session_id)["lastTurnCompleteAt"], + complete["event"]["at"] + ); + first.kill(&session_id).await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ready_retains_a_crashed_turn_but_not_its_dead_approval() { + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "ask" }, + "emit": [{ "kind": "approval", "data": { "tool": "Bash", "input": { "command": "ls" } } }], + }] }) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]).await; + let mut first = rig.connect().await; + let created = first + .request_create("create-crash", json!({ "permissionMode": "default" })) + .await; + let session_id = created["sessionId"].as_str().unwrap().to_string(); + first.send_text(&session_id, "send-crash", "ask").await; + first.wait_for_event("freshAgent.turn.waiting").await; + assert!(mark(&ready(&rig, true).await, &session_id) + .get("lastWaitingAt") + .is_some()); + rig.kill_first_sidecar(&mut first).await; + let edge = first.wait_for_event("freshAgent.turn.complete").await; + let reconnect = ready(&rig, true).await; + assert_eq!( + mark(&reconnect, &session_id)["lastTurnCompleteAt"], + edge["event"]["at"] + ); + assert!(mark(&reconnect, &session_id).get("lastWaitingAt").is_none()); +} diff --git a/crates/freshell-ws/tests/hello_capabilities.rs b/crates/freshell-ws/tests/hello_capabilities.rs index a8b382d69..2ccd6301b 100644 --- a/crates/freshell-ws/tests/hello_capabilities.rs +++ b/crates/freshell-ws/tests/hello_capabilities.rs @@ -157,6 +157,34 @@ async fn hello_ready(ws: &mut WsClient, capabilities: serde_json::Value) -> serd serde_json::from_str(&text).expect("ready is JSON") } +#[tokio::test] +async fn attention_replay_is_echoed_only_when_the_client_opts_in() { + let url = spawn_server().await; + for (capabilities, opted_in) in [ + ( + serde_json::json!({ "freshAgentAttentionReplayV1": true }), + true, + ), + (serde_json::json!({}), false), + ( + serde_json::json!({ "freshAgentAttentionReplayV1": false }), + false, + ), + ] { + let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); + let ready = hello_ready(&mut ws, capabilities).await; + assert_eq!( + ready["capabilities"]["freshAgentAttentionReplayV1"].as_bool(), + opted_in.then_some(true), + "{ready}" + ); + assert!( + ready.get("freshAgentAttention").is_none(), + "empty marks are omitted: {ready}" + ); + } +} + /// A hello WITH `pacedTerminalReplayV1: true` gets the echo advertised back in /// `ready.capabilities` — the full negotiation rail (raw-JSON extraction → /// handshake builder gate) at the real socket. diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 9eb375a16..c1633b8ff 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -436,6 +436,7 @@ export const HelloSchema = z.object({ // STRIP unknown keys, so without this the capability would silently no-op. paneReconcileV1: z.literal(true).optional(), paneReconcileFreshAgentV1: z.literal(true).optional(), + freshAgentAttentionReplayV1: z.literal(true).optional(), // Paced terminal restore (responsive-terminal-restore Workstream 1): the // client understands bounded, ascending paced replay batches with // continuation credit. Additive optional — declared, not just sent (same @@ -1202,6 +1203,7 @@ export const ReadyCapabilitiesSchema = z // client may send `terminal.interest.claimedTerminalIds`. terminalLifetimeClaimV1: z.literal(true).optional(), managedRuntimeV1: z.literal(true).optional(), + freshAgentAttentionReplayV1: z.literal(true).optional(), }) .optional() @@ -1307,6 +1309,14 @@ export type ReadyMessage = { buildId?: string /** Present iff the client's hello opted in via capabilities.paneReconcileV1. */ capabilities?: ReadyCapabilities + /** Last known attention marks, negotiated by freshAgentAttentionReplayV1. */ + freshAgentAttention?: Array<{ + provider: string + sessionId: string + sessionType?: string + lastTurnCompleteAt?: number + lastWaitingAt?: number + }> /** kata b8ke: current runtime-owner state for every recorded * (provider, sessionId) — replayed so a device that missed a handoff * broadcast (offline, lag-4008, reload) learns the authoritative owner diff --git a/src/App.tsx b/src/App.tsx index bdd0c870f..12452b97a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useCallback, useEffect, useRef, useState, type TouchEve import { useAppDispatch, useAppSelector, useAppStore } from '@/store/hooks' import { setStatus, setError, setErrorCode, setServerInstanceId, setBootId, setServerRestarted, setLiveTerminalIds, setPlatform, setAvailableClis, setFeatureFlags } from '@/store/connectionSlice' import { resetCompletionDedupeBaselines } from '@/store/turnCompletionSlice' +import { foldReadyFreshAgentAttention } from '@/store/turnCompletionThunks' import { setLocalSettings, setServerConfigDir, setServerSettings, localSettingsPlatformDefaults } from '@/store/settingsSlice' import { markWsSnapshotReceived, @@ -1367,6 +1368,7 @@ export default function App() { // per connection lifetime; a restarted server's newer generations // must never be ignored in favor of stale pre-reconnect records. foldReadyRuntimeOwners(dispatch, ready.data.runtimeOwners) + dispatch(foldReadyFreshAgentAttention(ready.data.freshAgentAttention)) // pane.reconcile adoption: capability re-captured per connection, // and the request re-sent on EVERY ready — a result is not // guaranteed (deferral, drop, error frame), so reconnect covers diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index ade49f51d..d5c9db212 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -7,7 +7,7 @@ import { consumeCancelledCreate, consumeCreateRoute, rememberCreateRoute } from import { flushPersistedLayoutNow } from '@/store/persistControl' import { KILL_FAILED_MESSAGE } from '@/lib/kill-ack' import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice' -import { applyFreshAgentCompletion, applyFreshAgentWaiting } from '@/store/turnCompletionThunks' +import { applyFreshAgentCompletion, applyFreshAgentWaiting, observeFreshAgentSession, rekeyFreshAgentAttention } from '@/store/turnCompletionThunks' import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' @@ -200,6 +200,7 @@ export function handleFreshAgentMessage( } return true } + if (provider) dispatch(observeFreshAgentSession({ provider, sessionId: created.sessionId })) dispatch(sessionCreated({ requestId: created.requestId, sessionId: created.sessionId, @@ -232,6 +233,7 @@ export function handleFreshAgentMessage( } case 'freshAgent.session.materialized': { const materialized = msg as FreshAgentSessionMaterializedMessage + dispatch(rekeyFreshAgentAttention(materialized)) // Stream events queued under the old id land before the re-key moves them. streamBatcherFor(dispatch).flush({ sessionId: materialized.previousSessionId, @@ -466,6 +468,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh switch (event.type) { case 'freshAgent.session.snapshot': + dispatch(observeFreshAgentSession(locator)) dispatch(sessionSnapshotReceived({ ...locator, latestTurnId: (event.latestTurnId as string | null | undefined) ?? null, diff --git a/src/lib/ready-message-schema.ts b/src/lib/ready-message-schema.ts index 60854d0aa..7a7969c98 100644 --- a/src/lib/ready-message-schema.ts +++ b/src/lib/ready-message-schema.ts @@ -25,6 +25,13 @@ export const ReadyMessageSchema = z.object({ // loose record: an unexpected capabilities shape must never fail the WHOLE // ready frame and silently disable restart detection. capabilities: z.record(z.string(), z.unknown()).optional(), + freshAgentAttention: z.array(z.object({ + provider: z.string().min(1), + sessionId: z.string().min(1), + sessionType: z.string().optional(), + lastTurnCompleteAt: z.number().finite().optional(), + lastWaitingAt: z.number().finite().optional(), + })).optional().catch(undefined), // kata b8ke: the handshake's runtime-owner replay (additive/optional — // old servers omit it). Same ready-frame doctrine: `.catch(undefined)` // degrades a malformed replay to "no owners folded" (the reconcile gate diff --git a/src/lib/ws-client.ts b/src/lib/ws-client.ts index 7a30826ca..05c989c52 100644 --- a/src/lib/ws-client.ts +++ b/src/lib/ws-client.ts @@ -556,6 +556,7 @@ export class WsClient { terminalInterestV1: true, paneReconcileV1: true, paneReconcileFreshAgentV1: true, + freshAgentAttentionReplayV1: true, pacedTerminalReplayV1: true, terminalLifetimeClaimV1: true, managedRuntimeV1: true, diff --git a/src/store/turnCompletionSlice.ts b/src/store/turnCompletionSlice.ts index 3ea73be27..842efc790 100644 --- a/src/store/turnCompletionSlice.ts +++ b/src/store/turnCompletionSlice.ts @@ -77,6 +77,14 @@ const turnCompletionSlice = createSlice({ name: 'turnCompletion', initialState, reducers: { + // Observe an attention mark without recording an event. First-ready seeding + // and live edges for sessions without a local pane use the same high-water mark. + advanceCompletionBaseline(state, action: PayloadAction<{ terminalId: string; at: number }>) { + const { terminalId, at } = action.payload + if (!Number.isFinite(at)) return + const last = state.lastAtByTerminalId[terminalId] + if (last === undefined || at > last) state.lastAtByTerminalId[terminalId] = at + }, // Fresh-agent completion edge (and custom-CLI client BEL path). Terminal CLI // panes (claude/codex/opencode/amplifier) no longer flow through here — their // bell/shade edge is the server-authoritative terminal.idle (recordTerminalIdle). @@ -159,6 +167,7 @@ const turnCompletionSlice = createSlice({ }) export const { + advanceCompletionBaseline, recordTurnComplete, recordTerminalIdle, resetCompletionDedupeBaselines, diff --git a/src/store/turnCompletionThunks.ts b/src/store/turnCompletionThunks.ts index 2feccda41..47ef6a952 100644 --- a/src/store/turnCompletionThunks.ts +++ b/src/store/turnCompletionThunks.ts @@ -4,10 +4,154 @@ import { collectPaneEntries } from '@/lib/pane-utils' import { resolveFreshAgentSessionKey } from '@/lib/pane-activity' import type { FreshAgentPaneContent, PaneNode } from './paneTypes' import { selectTabPaneByTerminalId } from './selectors/paneTerminalSelectors' -import { recordTerminalIdle, recordTurnComplete } from './turnCompletionSlice' +import { advanceCompletionBaseline, recordTerminalIdle, recordTurnComplete } from './turnCompletionSlice' +import type { ReadyMessage } from '@shared/ws-protocol' +import { createLogger } from '@/lib/client-logger' import { clearTerminalStuck, recordTerminalStuck } from './terminalLifecycleSlice' import type { AppDispatch, RootState } from './store' +const log = createLogger('TurnCompletion') + +function completionDedupeKey(provider: string, sessionId: string): string { + return `${provider}:${sessionId}` +} + +function waitingDedupeKey(provider: string, sessionId: string): string { + return `${completionDedupeKey(provider, sessionId)}#waiting` +} + +type FreshAgentPaneLocation = { + tabId: string + paneId: string + sessionKeys: string[] +} + +function attentionDedupeKeys( + provider: string, + sessionId: string, + location: FreshAgentPaneLocation | undefined, + waiting: boolean, +): string[] { + const sessionKeys = new Set([ + ...(location?.sessionKeys ?? []), + completionDedupeKey(provider, sessionId), + ]) + return [...sessionKeys].map((key) => waiting ? `${key}#waiting` : key) +} + +function newestKnownAt(state: RootState, terminalIds: string[]): number | undefined { + let newest: number | undefined + for (const terminalId of terminalIds) { + const at = state.turnCompletion?.lastAtByTerminalId[terminalId] + if (at !== undefined && (newest === undefined || at > newest)) newest = at + } + return newest +} + +function advanceAttentionAliases(dispatch: AppDispatch, terminalIds: string[], at: number): void { + for (const terminalId of terminalIds) { + dispatch(advanceCompletionBaseline({ terminalId, at })) + } +} + +/** A newly registered session's first turn can end while this page is offline. */ +export function observeFreshAgentSession(payload: { provider: string; sessionId: string }) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + const location = findFreshAgentPaneBySessionKey( + getState(), completionDedupeKey(payload.provider, payload.sessionId), + ) + for (const waiting of [false, true]) { + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + for (const terminalId of terminalIds) { + dispatch(advanceCompletionBaseline({ terminalId, at: 0 })) + } + } + } +} + +/** Materialization changes the envelope, not what this page has witnessed. */ +export function rekeyFreshAgentAttention(payload: { provider: string; previousSessionId: string; sessionId: string }) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + const state = getState() + const previousKey = completionDedupeKey(payload.provider, payload.previousSessionId) + const nextKey = completionDedupeKey(payload.provider, payload.sessionId) + const location = findFreshAgentPaneBySessionKey(state, previousKey) + ?? findFreshAgentPaneBySessionKey(state, nextKey) + for (const waiting of [false, true]) { + const terminalIds = [...new Set([ + ...attentionDedupeKeys(payload.provider, payload.previousSessionId, location, waiting), + ...attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting), + ])] + const at = newestKnownAt(state, terminalIds) ?? 0 + advanceAttentionAliases(dispatch, terminalIds, at) + } + } +} + +/** First observation seeds silently; a same-page reconnect replays unseen edges. */ +export function foldReadyFreshAgentAttention(marks: ReadyMessage['freshAgentAttention']) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + for (const mark of marks ?? []) { + const payload = { provider: mark.provider, sessionId: mark.sessionId } + const location = findFreshAgentPaneBySessionKey( + getState(), completionDedupeKey(mark.provider, mark.sessionId), + ) + for (const [waiting, at, apply] of [ + [false, mark.lastTurnCompleteAt, applyFreshAgentCompletion], + [true, mark.lastWaitingAt, applyFreshAgentWaiting], + ] as const) { + const terminalIds = attentionDedupeKeys(mark.provider, mark.sessionId, location, waiting) + const known = newestKnownAt(getState(), terminalIds) + if (known === undefined) { + advanceAttentionAliases(dispatch, terminalIds, at ?? 0) + } else if (at !== undefined && at > known) { + log.info('fresh_agent_attention_replayed', { + terminalId: completionDedupeKey(mark.provider, mark.sessionId), + at, + }) + dispatch(apply({ ...payload, at })) + } else { + // A ready snapshot may introduce a second id for the same pane on a + // later reconnect. Copy the pane's high-water mark to that alias so + // the same edge cannot be replayed once per id. + advanceAttentionAliases(dispatch, terminalIds, known) + } + } + } + } +} + +function applyFreshAgentAttentionEdge( + payload: ApplyFreshAgentCompletionPayload, + waiting: boolean, +) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + dispatch(observeFreshAgentSession(payload)) + const state = getState() + const sessionKey = completionDedupeKey(payload.provider, payload.sessionId) + const location = findFreshAgentPaneBySessionKey(state, sessionKey) + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + const known = newestKnownAt(state, terminalIds) + + if (!location) { + advanceAttentionAliases(dispatch, terminalIds, Math.max(known ?? 0, payload.at)) + return + } + if (known !== undefined && payload.at <= known) { + advanceAttentionAliases(dispatch, terminalIds, known) + return + } + + dispatch(recordTurnComplete({ + tabId: location.tabId, + paneId: location.paneId, + terminalId: waiting ? waitingDedupeKey(payload.provider, payload.sessionId) : sessionKey, + at: payload.at, + })) + advanceAttentionAliases(dispatch, terminalIds, payload.at) + } +} + export type ApplyServerIdlePayload = { terminalId: string at: number @@ -90,26 +234,12 @@ export type ApplyFreshAgentCompletionPayload = { * derives green/sound from the busy level or the turn's outcome. We resolve the * owning tab/pane from the `provider:sessionId` session key and fold the event into * the GREEN/SOUND pipeline via the `at`-monotonic dedupe regime (no completionSeq). - * The discrete edge is never replayed from a snapshot, so a reconnect cannot - * re-green, and a stale/older `at` is dropped. Across a real server restart the - * client clears the per-terminal `at` baselines (resetCompletionDedupeBaselines), - * so a resumed durable session whose fresh process stamps a lower wall-clock `at` - * is not swallowed. + * A reconnect's ready marks replay only edges this page missed. First connect, + * reload and server restart seed without replaying history. Equal or older `at` + * values are dropped by the same baseline as live edges. */ export function applyFreshAgentCompletion(payload: ApplyFreshAgentCompletionPayload) { - return (dispatch: AppDispatch, getState: () => RootState): void => { - const state = getState() - const sessionKey = `${payload.provider}:${payload.sessionId}` - const location = findFreshAgentPaneBySessionKey(state, sessionKey) - if (!location) return - - dispatch(recordTurnComplete({ - tabId: location.tabId, - paneId: location.paneId, - terminalId: sessionKey, - at: payload.at, - })) - } + return applyFreshAgentAttentionEdge(payload, false) } export type ApplyFreshAgentWaitingPayload = { @@ -125,39 +255,24 @@ export type ApplyFreshAgentWaitingPayload = { * bucket via the monotonic `at` guard. Claude/kilroy and codex controls emit * this edge on their own 0→≥1 pending approval/question transitions. * - * Like the completion edge, the server buffers and replays this only to the FIRST - * subscriber of a session (so a create-then-attach gap still greens once); a - * reconnecting client gets NO replay and rehydrates pending state from the session - * snapshot, which carries no waiting edge — so a still-pending approval does not - * spuriously re-green on reconnect (matching the deleted hook's first-observation - * suppression). + * Ready replay includes a waiting mark only while a control is still pending. + * The separate high-water mark prevents a completed turn from swallowing a + * waiting edge, and prevents a reconnect from ringing the same approval twice. */ export function applyFreshAgentWaiting(payload: ApplyFreshAgentWaitingPayload) { - return (dispatch: AppDispatch, getState: () => RootState): void => { - const state = getState() - const sessionKey = `${payload.provider}:${payload.sessionId}` - const location = findFreshAgentPaneBySessionKey(state, sessionKey) - if (!location) return - - dispatch(recordTurnComplete({ - tabId: location.tabId, - paneId: location.paneId, - terminalId: `${sessionKey}#waiting`, - at: payload.at, - })) - } + return applyFreshAgentAttentionEdge(payload, true) } function findFreshAgentPaneBySessionKey( state: RootState, sessionKey: string, -): { tabId: string; paneId: string } | undefined { +): FreshAgentPaneLocation | undefined { const layouts = state.panes?.layouts if (!layouts) return undefined const sessions = state.freshAgent?.sessions ?? {} const activeTabId = state.tabs?.activeTabId - const scan = (tabId: string, layout: PaneNode): { tabId: string; paneId: string } | undefined => { + const scan = (tabId: string, layout: PaneNode): FreshAgentPaneLocation | undefined => { for (const entry of collectPaneEntries(layout)) { if (entry.content.kind !== 'fresh-agent') continue const content = entry.content as FreshAgentPaneContent @@ -174,8 +289,13 @@ function findFreshAgentPaneBySessionKey( // resolveFreshAgentSessionKey prefers — so we must match the runtime handle too, // or restored Claude sessions would silently drop every chime. const runtimeKey = content.sessionId ? `${content.provider}:${content.sessionId}` : undefined - if (runtimeKey === sessionKey || resolveFreshAgentSessionKey(content, session) === sessionKey) { - return { tabId, paneId: entry.paneId } + const resolvedKey = resolveFreshAgentSessionKey(content, session) + if (runtimeKey === sessionKey || resolvedKey === sessionKey) { + return { + tabId, + paneId: entry.paneId, + sessionKeys: [...new Set([runtimeKey, resolvedKey, sessionKey].filter((key): key is string => Boolean(key)))], + } } } return undefined diff --git a/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts new file mode 100644 index 000000000..c61a695dc --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts @@ -0,0 +1,187 @@ +/** Attention missed by this page replays once on reconnect, never on reload. + * The production sidecar runs over the CLI simulator. A second socket drives + * a turn only after the browser socket is offline, so no timer races decide + * whether the browser witnessed the ending. */ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import WebSocket from 'ws' +import type { WebSocketRoute } from '@playwright/test' +import { test, expect } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' +import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol.js' + +const directory = path.dirname(fileURLToPath(import.meta.url)) +const sidecar = path.resolve(directory, '../../../crates/freshell-claude-sidecar/index.mjs') +const simulator = path.resolve(directory, '../../fixtures/claude-cli-sim/query.mjs') + +function freshLeaf(node: any): any { + if (node?.type === 'leaf' && node.content?.kind === 'fresh-agent') return node + for (const child of node?.children ?? []) { + const leaf = freshLeaf(child) + if (leaf) return leaf + } + return undefined +} + +async function sendFromAnotherConnection(baseUrl: string, token: string, sessionId: string, sessionType: string): Promise { + const socket = new WebSocket(`${baseUrl.replace(/^http/, 'ws')}/ws`) + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('The second connection never received the turn completion')), 30_000) + const finish = (error?: Error) => { + clearTimeout(timer) + if (error) reject(error) + else resolve() + } + socket.on('error', finish) + socket.on('open', () => socket.send(JSON.stringify({ type: 'hello', token, protocolVersion: WS_PROTOCOL_VERSION }))) + socket.on('message', (raw) => { + const frame = JSON.parse(raw.toString()) + if (frame.type === 'ready') socket.send(JSON.stringify({ + type: 'freshAgent.send', provider: 'claude', sessionType, sessionId, + requestId: 'offline-turn', text: 'finish while the browser is offline', + })) + if (frame.type === 'error' && frame.requestId === 'offline-turn') finish(new Error(JSON.stringify(frame))) + if (frame.type === 'freshAgent.event' && frame.sessionId === sessionId) { + if (frame.event?.type === 'freshAgent.error') finish(new Error(JSON.stringify(frame))) + if (frame.event?.type === 'freshAgent.turn.complete') finish() + } + }) + }) + } finally { + socket.close() + } +} + +for (const flavour of ['freshclaude', 'kilroy'] as const) { + test(`${flavour}: reconnect replays missed attention once and reload seeds silently`, async ({ page }) => { + test.setTimeout(120_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-attention-reconnect-')) + const project = path.join(root, 'project') + await fs.mkdir(project) + const server = new RustServer({ + env: { + FRESHELL_CLAUDE_SIDECAR: sidecar, + FRESHELL_CLAUDE_NODE: process.execPath, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: simulator, + // Kilroy is hidden unless its server-side feature flag is enabled. + ...(flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), + }, + setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell', 'config.json'), JSON.stringify({ + version: 1, settings: { codingCli: { enabledProviders: ['claude'] }, freshAgent: { enabled: true } }, + })) + }, + }) + let blocked = false + let readyFrames = 0 + const liveSockets: WebSocketRoute[] = [] + const blockedSockets: WebSocketRoute[] = [] + await page.context().routeWebSocket((url) => url.pathname === '/ws', (client) => { + if (blocked) { + blockedSockets.push(client) + return + } + const upstream = client.connectToServer() + liveSockets.push(client) + client.onMessage((message) => upstream.send(message)) + upstream.onMessage((message) => { + if (typeof message === 'string' && JSON.parse(message).type === 'ready') readyFrames += 1 + client.send(message) + }) + client.onClose((code, reason) => void upstream.close({ code, reason }).catch(() => {})) + upstream.onClose((code, reason) => void client.close({ code, reason }).catch(() => {})) + }) + try { + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + const shellPicker = page.getByRole('toolbar', { name: /pane type picker/i }).last() + if (await shellPicker.isVisible().catch(() => false)) { + await shellPicker.getByRole('button', { name: /^(Shell|Bash|WSL|CMD|PowerShell)$/i }).first().click() + } + // The boot picker remains mounted while it fades out. Wait for the + // selected shell to render before openPanePicker looks for an existing + // picker, or it can return the fading boot picker and miss the click. + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + await page.evaluate(() => (window as any).__FRESHELL_TEST_HARNESS__.dispatch({ + type: 'connection/setAvailableClis', payload: { claude: true }, + })) + const tabId = (await harness.getActiveTabId())! + const picker = await openPanePicker(page) + const label = flavour === 'kilroy' ? 'Kilroy' : 'Freshclaude' + await picker.getByRole('button', { name: new RegExp(`^${label}$`) }).click({ force: true }) + const cwd = page.getByLabel(new RegExp(`^Starting directory for ${label}$`, 'i')) + await cwd.fill(project) + await cwd.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await expect(pane).toBeVisible() + await pane.getByRole('textbox', { name: 'Chat message input' }).fill('establish the conversation') + await pane.getByRole('button', { name: 'Send' }).click() + const completion = async () => (await harness.getState()).turnCompletion + await expect.poll(async () => (await completion()).seq, { timeout: 30_000 }).toBe(1) + const leaf = freshLeaf(await harness.getPaneLayout(tabId)) + const { sessionId, sessionRef } = leaf.content + expect(sessionId).toBeTruthy() + expect(sessionRef?.sessionId).toBeTruthy() + await page.getByRole('button', { name: 'New shell tab' }).click() + await harness.waitForTabCount(2) + expect(await harness.getActiveTabId()).not.toBe(tabId) + + blocked = true + for (const socket of liveSockets.splice(0)) await socket.close({ code: 1012, reason: 'Test offline' }) + await expect.poll(async () => (await harness.getState()).connection.status).not.toBe('ready') + await sendFromAnotherConnection(info.baseUrl, info.token, sessionId, flavour) + expect((await completion()).seq).toBe(1) + expect((await completion()).attentionByTab[tabId]).toBeUndefined() + + const beforeReady = readyFrames + blocked = false + for (const socket of blockedSockets.splice(0)) await socket.close({ code: 1012, reason: 'Test reconnect' }) + await expect.poll(() => readyFrames, { timeout: 30_000 }).toBeGreaterThan(beforeReady) + await harness.waitForConnection() + await expect.poll(async () => (await completion()).seq).toBe(2) + expect((await completion()).attentionByTab[tabId]).toBe(true) + expect((await completion()).attentionByPane[leaf.id]).toBe(true) + await expect(page.locator(`[data-context="tab"][data-tab-id="${tabId}"]`)).toHaveClass(/bg-emerald-100/) + await expect(page.getByTestId('sidebar-session-list').locator(`[data-session-id="${sessionRef.sessionId}"][data-provider="claude"]`)).toHaveClass(/bg-emerald-50|border-l-emerald-500/) + + await harness.forceDisconnect() + await harness.waitForConnection() + const reconnectState = await completion() + const received = await harness.getReceivedWsMessages() as Array> + const evidence = { + seq: reconnectState.seq, + freshAgentBaselines: reconnectState.lastAtByTerminalId, + terminalIdleBaselines: reconnectState.lastIdleAtByTerminalId, + pendingEvents: reconnectState.pendingEvents, + turnEdges: received + .filter((message) => message.type === 'freshAgent.event' + && message.event?.type === 'freshAgent.turn.complete') + .map((message) => ({ sessionId: message.sessionId, event: message.event })), + readyAttention: received + .filter((message) => message.type === 'ready') + .map((message) => message.freshAgentAttention), + terminalIdle: received + .filter((message) => message.type === 'terminal.idle') + .map((message) => ({ terminalId: message.terminalId, at: message.at, reason: message.reason })), + } + expect(reconnectState.seq, JSON.stringify(evidence)).toBe(2) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + expect((await completion()).seq).toBe(0) + expect((await completion()).attentionByTab[tabId]).toBeUndefined() + } finally { + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } + }) +} diff --git a/test/unit/client/components/App.restart-signals.test.tsx b/test/unit/client/components/App.restart-signals.test.tsx index 923180a59..a7f3981c7 100644 --- a/test/unit/client/components/App.restart-signals.test.tsx +++ b/test/unit/client/components/App.restart-signals.test.tsx @@ -285,8 +285,8 @@ async function renderApp(store: ReturnType) { const READY_BASE = { type: 'ready', timestamp: '2026-07-25T00:00:00.000Z' } -function sendReady(extra: Record) { - act(() => { messageHandler?.({ ...READY_BASE, ...extra }) }) +function sendReady(extra: Record, handler = messageHandler) { + act(() => { handler?.({ ...READY_BASE, ...extra }) }) } function baselines(store: any) { @@ -435,6 +435,43 @@ describe('App restart signals (bootId + serverInstanceId fallback)', () => { expect(baselines(store)['codex:ses-resumed']).toBe(1_000) }) + it('replays missed Claude attention on reconnect, while first connect and restart seed silently', async () => { + const store = createStore({ + tabs: [{ id: 'tab-1', mode: 'shell' }, { id: 'tab-claude', mode: 'shell' }], + activeTabId: 'tab-1', + panes: { + layouts: { 'tab-claude': { type: 'leaf', id: 'pane-claude', content: { + kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', + sessionId: 'session-claude', createRequestId: 'create-claude', status: 'connected', + } } }, + activePane: { 'tab-claude': 'pane-claude' }, + }, + }) + await renderApp(store) + // Open-pane reassertions subscribe for their own acknowledgements. Keep + // driving App's registered listener rather than that temporary subscriber. + const appHandler = messageHandler + const attention = (at: number) => [{ provider: 'claude', sessionId: 'session-claude', lastTurnCompleteAt: at }] + sendReady({ serverInstanceId: 'srv-1', bootId: 'boot-1', freshAgentAttention: attention(100) }, appHandler) + expect(store.getState().panes.layouts['tab-claude']).toMatchObject({ content: { sessionId: 'session-claude' } }) + expect(baselines(store)['claude:session-claude']).toBe(100) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(store.getState().turnCompletion.attentionByTab['tab-claude']).toBeUndefined() + + sendReady({ serverInstanceId: 'srv-1', bootId: 'boot-1', freshAgentAttention: attention(101) }, appHandler) + expect(store.getState().connection).toMatchObject({ serverInstanceId: 'srv-1', bootId: 'boot-1', serverRestarted: false }) + expect(store.getState().panes.layouts['tab-claude']).toMatchObject({ content: { sessionId: 'session-claude' } }) + expect(baselines(store)['claude:session-claude']).toBe(101) + expect(store.getState().turnCompletion.seq).toBe(1) + expect(store.getState().turnCompletion.attentionByTab['tab-claude']).toBe(true) + sendReady({ serverInstanceId: 'srv-1', bootId: 'boot-1', freshAgentAttention: attention(101) }, appHandler) + expect(store.getState().turnCompletion.seq).toBe(1) + + sendReady({ serverInstanceId: 'srv-1', bootId: 'boot-2', freshAgentAttention: attention(102) }, appHandler) + expect(store.getState().turnCompletion.seq).toBe(1) + expect(baselines(store)['claude:session-claude']).toBe(102) + }) + it('a malformed ready frame neither wipes identity nor fakes a restart', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const store = createStore() diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 7fe259c08..b092f6aa4 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -609,7 +609,7 @@ describe('fresh-agent-ws', () => { it('folds freshAgent.status(stuck) with no attention action; a companion turn.complete edge folds attention independently', () => { // Wedged-sidecar deadman fold: the STATUS frame itself dispatches no // attention — it must land (and stop the busy-driving streaming flag) - // with zero turnCompletion/* actions. The deadman's companion + // without recording an attention event. The deadman's companion // freshAgent.turn.complete edge is a SEPARATE frame: when it follows for // the same session, it folds attention independently (the unified // contract — the deadman rings deliberately). The pane layout below is @@ -650,6 +650,9 @@ describe('fresh-agent-ws', () => { }, })).toBe(true) expect(store.getState().freshAgent.sessions[`freshcodex:codex:${sessionId}`].streamingActive).toBe(true) + // The snapshot observes the session for reconnect dedupe; only actions + // caused by the following status frame belong to this assertion. + actionTypes.length = 0 expect(handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', @@ -662,12 +665,12 @@ describe('fresh-agent-ws', () => { const session = store.getState().freshAgent.sessions[`freshcodex:codex:${sessionId}`] expect(session.status).toBe('stuck') expect(session.streamingActive).toBe(false) - expect(actionTypes.filter((type) => type.startsWith('turnCompletion/'))).toHaveLength(0) + expect(actionTypes.filter((type) => type.startsWith('turnCompletion/') && type !== 'turnCompletion/advanceCompletionBaseline')).toHaveLength(0) // Companion edge: the deadman's discrete freshAgent.turn.complete for the // same session dispatches recordTurnComplete — attention folds - // independently of the status frame (exactly one turnCompletion action, - // and it is the record, not anything the status fold minted). + // independently of the status frame (exactly one attention record; + // observing its reconnect baselines does not record an event). expect(handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', sessionId, @@ -676,7 +679,7 @@ describe('fresh-agent-ws', () => { event: { type: 'freshAgent.turn.complete', sessionId, at: 1_700_000_000_000 }, })).toBe(true) expect(actionTypes.filter((type) => type === 'turnCompletion/recordTurnComplete')).toHaveLength(1) - expect(actionTypes.filter((type) => type.startsWith('turnCompletion/'))).toEqual([ + expect(actionTypes.filter((type) => type.startsWith('turnCompletion/') && type !== 'turnCompletion/advanceCompletionBaseline')).toEqual([ 'turnCompletion/recordTurnComplete', ]) // The status fold itself stays untouched by the companion: still 'stuck', diff --git a/test/unit/client/lib/ws-client.test.ts b/test/unit/client/lib/ws-client.test.ts index 93343841f..12c529ae0 100644 --- a/test/unit/client/lib/ws-client.test.ts +++ b/test/unit/client/lib/ws-client.test.ts @@ -95,6 +95,7 @@ describe('WsClient.connect', () => { terminalInterestV1: true, paneReconcileV1: true, paneReconcileFreshAgentV1: true, + freshAgentAttentionReplayV1: true, pacedTerminalReplayV1: true, terminalLifetimeClaimV1: true, managedRuntimeV1: true, diff --git a/test/unit/client/store/turnCompletion.replay.test.ts b/test/unit/client/store/turnCompletion.replay.test.ts new file mode 100644 index 000000000..1c4cff1f2 --- /dev/null +++ b/test/unit/client/store/turnCompletion.replay.test.ts @@ -0,0 +1,256 @@ +import { configureStore } from '@reduxjs/toolkit' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import reducer, { advanceCompletionBaseline, resetCompletionDedupeBaselines } from '@/store/turnCompletionSlice' +import { + applyFreshAgentCompletion, + applyFreshAgentWaiting, + foldReadyFreshAgentAttention, + observeFreshAgentSession, +} from '@/store/turnCompletionThunks' +import { turnCompletionReceiptMiddleware } from '@/store/turnCompletionReceipt' +import { ReadyMessageSchema } from '@/lib/ready-message-schema' +import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' +import type { AppDispatch } from '@/store/store' + +const { focus } = vi.hoisted(() => ({ focus: { watched: false } })) +vi.mock('@/lib/window-focus', () => ({ isWindowFocused: () => focus.watched })) + +type PaneIdentity = { sessionId: string; sessionRef?: { provider: string; sessionId: string } } + +function createStore(options: Partial & { additionalPanes?: PaneIdentity[] } = {}) { + const sessionId = options.sessionId ?? 'session-1' + const paneIdentities = [{ sessionId, sessionRef: options.sessionRef }, ...(options.additionalPanes ?? [])] + const panes = paneIdentities.map((identity, index) => ({ + type: 'leaf' as const, + id: `pane-${index + 1}`, + content: { + kind: 'fresh-agent' as const, + sessionType: 'freshclaude' as const, + provider: 'claude' as const, + sessionId: identity.sessionId, + ...(identity.sessionRef ? { sessionRef: identity.sessionRef } : {}), + }, + })) + const layout = panes.length === 1 ? panes[0] : { + type: 'split' as const, + id: 'split-1', + direction: 'horizontal' as const, + children: [panes[0], panes[1]] as [typeof panes[number], typeof panes[number]], + sizes: [50, 50] as [number, number], + } + const store = configureStore({ + reducer: { + turnCompletion: reducer, + tabs: (state = { activeTabId: 'tab-1' }) => state, + panes: (state = { layouts: { 'tab-1': layout } }) => state, + freshAgent: (state = { sessions: {} }) => state, + }, + middleware: (getDefault) => getDefault().concat(turnCompletionReceiptMiddleware), + }) + return { store, dispatch: store.dispatch as AppDispatch } +} + +const mark = (at?: number, waiting?: number) => [{ + provider: 'claude', sessionId: 'session-1', sessionType: 'freshclaude', + ...(at === undefined ? {} : { lastTurnCompleteAt: at }), + ...(waiting === undefined ? {} : { lastWaitingAt: waiting }), +}] + +describe('fresh-agent reconnect attention', () => { + beforeEach(() => { focus.watched = false }) + + it('advances baselines without lowering them or queueing an event', () => { + let state = reducer(undefined, advanceCompletionBaseline({ terminalId: 'claude:session-1', at: 300 })) + state = reducer(state, advanceCompletionBaseline({ terminalId: 'claude:session-1', at: 200 })) + expect(state.lastAtByTerminalId['claude:session-1']).toBe(300) + expect(state.seq).toBe(0) + expect(state.pendingEvents).toEqual([]) + }) + + it('seeds both edge kinds on first observation and replays only newer marks once', () => { + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention(mark(100, 110))) + expect(store.getState().turnCompletion.seq).toBe(0) + dispatch(foldReadyFreshAgentAttention(mark(101, 111))) + dispatch(foldReadyFreshAgentAttention(mark(101, 111))) + dispatch(foldReadyFreshAgentAttention(mark(99, 109))) + expect(store.getState().turnCompletion.pendingEvents).toEqual([ + expect.objectContaining({ terminalId: 'claude:session-1', at: 101, watched: false }), + expect.objectContaining({ terminalId: 'claude:session-1#waiting', at: 111, watched: false }), + ]) + }) + + it('classifies replay at receipt using the current focus and active tab', () => { + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention(mark(100))) + focus.watched = true + dispatch(foldReadyFreshAgentAttention(mark(101))) + expect(store.getState().turnCompletion.pendingEvents[0]).toMatchObject({ watched: true }) + }) + + it('seeds again after a restart baseline reset, without queueing an old completion', () => { + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention(mark(500))) + dispatch(resetCompletionDedupeBaselines()) + dispatch(foldReadyFreshAgentAttention(mark(100))) + expect(store.getState().turnCompletion.seq).toBe(0) + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: 'session-1', at: 101 })) + expect(store.getState().turnCompletion.seq).toBe(1) + }) + + it('observes a newly created session so a missed first completion replays', () => { + const { store, dispatch } = createStore() + handleFreshAgentMessage(dispatch, { + type: 'freshAgent.created', requestId: 'create-1', sessionId: 'session-1', + sessionType: 'freshclaude', provider: 'claude', + }) + dispatch(foldReadyFreshAgentAttention(mark(100))) + expect(store.getState().turnCompletion.seq).toBe(1) + }) + + it('observes an attached session without suppressing its first live completion', () => { + const { store, dispatch } = createStore() + handleFreshAgentMessage(dispatch, { + type: 'freshAgent.event', sessionId: 'session-1', sessionType: 'freshclaude', provider: 'claude', + event: { type: 'freshAgent.session.snapshot', status: 'idle' }, + }) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1']).toBe(0) + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: 'session-1', at: 100 })) + expect(store.getState().turnCompletion.seq).toBe(1) + }) + + it('does not lower an observed session baseline', () => { + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention(mark(100, 200))) + dispatch(observeFreshAgentSession({ provider: 'claude', sessionId: 'session-1' })) + expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ + 'claude:session-1': 100, 'claude:session-1#waiting': 200, + }) + }) + + it('observes both edge kinds when a live edge first reveals a session', () => { + const { store, dispatch } = createStore() + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: 'session-1', at: 100 })) + dispatch(foldReadyFreshAgentAttention(mark(100, 110))) + expect(store.getState().turnCompletion.seq).toBe(2) + expect(store.getState().turnCompletion.pendingEvents[1]).toMatchObject({ terminalId: 'claude:session-1#waiting', at: 110 }) + }) + + it('carries observed baselines through a materialized id before replaying new edges', () => { + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention([{ ...mark(100, 110)[0], sessionId: 'old-id' }])) + handleFreshAgentMessage(dispatch, { + type: 'freshAgent.session.materialized', previousSessionId: 'old-id', sessionId: 'session-1', + sessionType: 'freshclaude', provider: 'claude', + }) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1']).toBe(100) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1#waiting']).toBe(110) + dispatch(foldReadyFreshAgentAttention(mark(100, 110))) + expect(store.getState().turnCompletion.seq).toBe(0) + dispatch(foldReadyFreshAgentAttention(mark(101, 111))) + expect(store.getState().turnCompletion.seq).toBe(2) + }) + + it('does not replay one missed edge again when a durable alias appears on a later ready', () => { + const runtimeId = 'runtime-id' + const durableId = 'durable-id' + const { store, dispatch } = createStore({ + sessionId: runtimeId, + sessionRef: { provider: 'claude', sessionId: durableId }, + }) + const attentionMark = (sessionId: string, at: number, waitingAt?: number) => ({ + provider: 'claude', sessionId, sessionType: 'freshclaude', lastTurnCompleteAt: at, + ...(waitingAt === undefined ? {} : { lastWaitingAt: waitingAt }), + }) + + // Both aliases are known to the pane, but the client first reconnects + // before the server's ready snapshot contains its durable ledger entry. + dispatch(observeFreshAgentSession({ provider: 'claude', sessionId: runtimeId })) + dispatch(observeFreshAgentSession({ provider: 'claude', sessionId: durableId })) + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: runtimeId, at: 100 })) + dispatch(applyFreshAgentWaiting({ provider: 'claude', sessionId: runtimeId, at: 110 })) + expect(store.getState().turnCompletion.seq).toBe(2) + + // The waiting timestamp is lower than the completion timestamp; the + // `#waiting` namespace must still replay its independent unseen edge. + dispatch(foldReadyFreshAgentAttention([attentionMark(runtimeId, 200, 150)])) + expect(store.getState().turnCompletion.seq).toBe(4) + + dispatch(foldReadyFreshAgentAttention([ + attentionMark(durableId, 200, 150), + attentionMark(runtimeId, 200, 150), + ])) + dispatch(foldReadyFreshAgentAttention([ + attentionMark(runtimeId, 200, 150), + attentionMark(durableId, 200, 150), + ])) + expect(store.getState().turnCompletion.seq).toBe(4) + }) + + it('replays an unknown runtime alias when its pane has an observed durable baseline', () => { + const runtimeId = 'runtime-id' + const durableId = 'durable-id' + const { store, dispatch } = createStore({ + sessionId: runtimeId, + sessionRef: { provider: 'claude', sessionId: durableId }, + }) + const mark = (sessionId: string) => ({ + provider: 'claude', sessionId, sessionType: 'freshclaude', lastTurnCompleteAt: 200, + }) + + dispatch(advanceCompletionBaseline({ terminalId: `claude:${durableId}`, at: 0 })) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:durable-id']).toBe(0) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:runtime-id']).toBeUndefined() + dispatch(foldReadyFreshAgentAttention([mark(runtimeId)])) + expect(store.getState().turnCompletion.seq).toBe(1) + expect(store.getState().turnCompletion.pendingEvents).toMatchObject([ + { terminalId: 'claude:runtime-id', at: 200 }, + ]) + + dispatch(foldReadyFreshAgentAttention([mark(durableId)])) + expect(store.getState().turnCompletion.seq).toBe(1) + expect(store.getState().turnCompletion.pendingEvents).toHaveLength(1) + }) + + it('keeps equal-time completions independent across different pane identities', () => { + const { store, dispatch } = createStore({ + sessionId: 'runtime-one', + sessionRef: { provider: 'claude', sessionId: 'durable-one' }, + additionalPanes: [{ + sessionId: 'runtime-two', + sessionRef: { provider: 'claude', sessionId: 'durable-two' }, + }], + }) + for (const sessionId of ['runtime-one', 'durable-one', 'runtime-two', 'durable-two']) { + dispatch(observeFreshAgentSession({ provider: 'claude', sessionId })) + } + + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: 'runtime-one', at: 200 })) + dispatch(applyFreshAgentCompletion({ provider: 'claude', sessionId: 'runtime-two', at: 200 })) + + expect(store.getState().turnCompletion.seq).toBe(2) + }) + + it('records live edges without a local pane, preventing later replay', () => { + const { store, dispatch } = createStore() + const payload = { provider: 'claude', sessionId: 'remote-only', at: 100 } + dispatch(applyFreshAgentCompletion(payload)) + dispatch(applyFreshAgentWaiting({ ...payload, at: 110 })) + dispatch(foldReadyFreshAgentAttention([{ ...mark(100, 110)[0], sessionId: 'remote-only' }])) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ + 'claude:remote-only': 100, 'claude:remote-only#waiting': 110, + }) + }) + + it('keeps a malformed replay from disabling the ready restart identity', () => { + const valid = { type: 'ready', timestamp: 'now', bootId: 'boot-1', serverInstanceId: 'server-1' } + const parsed = ReadyMessageSchema.parse({ ...valid, freshAgentAttention: mark(Number.NaN) }) + expect(parsed.bootId).toBe('boot-1') + expect(parsed.freshAgentAttention).toBeUndefined() + const { store, dispatch } = createStore() + dispatch(foldReadyFreshAgentAttention(parsed.freshAgentAttention)) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(ReadyMessageSchema.parse({ ...valid, freshAgentAttention: mark(100) }).freshAgentAttention).toEqual(mark(100)) + }) +})