From 96b6a8a361b5606ad17dcee734b868897885e473 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 10:49:09 +0700 Subject: [PATCH 01/96] chore: start orchestration v2 branch From dcd91671ebf38b74e2984898609c496877c88b3f Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 11:36:33 +0700 Subject: [PATCH 02/96] feat(engine): budget the fork transcript behind a coverage header A forked side chat's first prompt pasted the entire source transcript as raw JSON with no size limit (t3-orchestration-v2 ticket 01, spec story 58): long chats burned the new session's context before it started. One reusable engine piece, crates/engine/src/transcript_budget.rs, now builds what a fresh provider session receives instead: - contents: user and assistant text, command runs (input, exit code, output), errors and file-change names; reasoning and attachments stay out; continuation fragments join into one message; - selection: the latest user, latest assistant and first user messages, then the newest remainder, newest first, within 16k tokens counted at one token per UTF-8 byte; a transcript that fits whole rides complete with no note; long items are truncated with a visible marker; - a coverage header names the omitted count and points at the read_chat MCP tool with the source chat's id; - the new user message is never counted, never shortened. fork_history_prompt (sessions.rs) routes through it for both the fresh bootstrap and the owed-history warm/steer/resume paths; the native-command gate is untouched. Tickets 09, 17-20, 22 and 24 reuse the same builder. Tests: unit suite in the module (selection, truncation, contents policy, budget) plus side-chat engine integration tests for a long fork, a short fork, an oversized single message and the omission header. side_chats was not run by any CI workflow; ui-tests.yml now includes it. Not run locally per the verification policy; CI is the signal. No wire or persisted-name changes (ADR 0005): the builder reads the doc and writes only the delivered prompt. --- .github/workflows/ui-tests.yml | 2 +- crates/engine/src/lib.rs | 1 + crates/engine/src/sessions.rs | 82 +-- crates/engine/src/transcript_budget.rs | 713 +++++++++++++++++++++++++ crates/engine/tests/side_chats.rs | 248 ++++++++- 5 files changed, 1003 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/transcript_budget.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 07482cfffb..325ac22036 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 241c82a562..c3eb56ae44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -43,6 +43,7 @@ pub mod tailcat; pub mod tailcat_relay; pub mod terminals; pub mod titles; +pub mod transcript_budget; mod transcript_history; pub mod uploads; mod web; diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fc7d4c441b..19279d88e2 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1602,11 +1602,13 @@ fn note_fork_history_session(doc: &SessionDoc, carried: bool, session_id: &str) } impl Inner { - /// `prompt` with the conversation a side chat's provider session lacks in + /// `prompt` with the transcript a side chat's provider session lacks in /// front of it, or `None` when it lacks nothing. A fresh session gets the - /// whole transcript (`current` excluded); a continued one only the fork's - /// copied history before the seam, until that went out to that session. - /// Native commands are never wrapped: providers route a leading `/name`. + /// budgeted transcript (`current` excluded); a continued one only the + /// fork's copied transcript before the seam, until that went out to that + /// session. The budget and the selection rules live in + /// [`crate::transcript_budget`]. Native commands are never wrapped: + /// providers route a leading `/name`. fn fork_history_prompt( &self, chat_id: &str, @@ -1628,7 +1630,13 @@ impl Inner { .read_entries() .unwrap_or_default() .into_iter() - .filter(|entry| Some(entry.id.as_str()) != current) + // `current` — and any continuation fragment of it — belongs to + // the turn being sent, never to the transcript in front of it. + .filter(|entry| { + !current.is_some_and(|current| { + entry.id == current || entry.continuation_of.as_deref() == Some(current) + }) + }) .collect(); let end = match provider { ProviderSession::Fresh => entries.len(), @@ -1637,46 +1645,42 @@ impl Inner { if session.is_some() && delivered.as_deref() == session { return None; } - entries.iter().position(|entry| { - entry - .parts - .iter() - .any(|part| matches!(part, roboco_doc::MessagePart::Fork { .. })) - })? + entries.iter().position(is_fork_seam)? } }; - let history: Vec<_> = entries[..end] + // The chat the omitted messages are readable in: the newest fork + // seam names the chat this transcript was copied from (its + // transcript holds everything above the seam); a seam-less side + // chat points at itself. + let source = entries .iter() - .map(|entry| { - let text = entry - .parts - .iter() - .filter_map(|part| match part { - roboco_doc::MessagePart::Text { text, .. } => Some(text.clone()), - roboco_doc::MessagePart::Tool { call, output, .. } => Some(format!( - "Tool: {}\n{}", - serde_json::to_string(call).unwrap_or_default(), - output.clone().unwrap_or_default() - )), - _ => None, - }) - .collect::>() - .join("\n"); - (entry.role, text) - }) - .filter(|(_, text)| !text.is_empty()) - .map(|(role, text)| serde_json::json!({ "role": role, "text": text })) - .collect(); - (!history.is_empty()).then(|| { - format!( - "Continue this side conversation using the following prior conversation as context.\n\n{}\n\n\n{}", - serde_json::to_string(&history).unwrap_or_default(), - prompt - ) - }) + .rev() + .find_map(fork_seam_source) + .unwrap_or_else(|| chat_id.to_owned()); + crate::transcript_budget::budgeted_transcript_prompt(&entries[..end], Some(&source), prompt) } } +/// A fork seam entry: the transcript divider above which messages were +/// copied from another chat. +fn is_fork_seam(entry: &SessionMessageEntry) -> bool { + entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::Fork { .. })) +} + +/// The chat a fork seam copied its transcript from. +fn fork_seam_source(entry: &SessionMessageEntry) -> Option { + entry + .parts + .iter() + .find_map(|part| match part { + MessagePart::Fork { source_chat_id, .. } => Some(source_chat_id.clone()), + _ => None, + }) +} + /// Whether the provider routes this prompt as a native command: its delivered /// text leads with `/name` (Codex `command_request`, OpenCode commands, Claude /// slash commands). Anything put in front of it would make it model input. diff --git a/crates/engine/src/transcript_budget.rs b/crates/engine/src/transcript_budget.rs new file mode 100644 index 0000000000..e5aba64233 --- /dev/null +++ b/crates/engine/src/transcript_budget.rs @@ -0,0 +1,713 @@ +//! Budgeted transcript selection: the messages delivered in front of a +//! prompt that starts a provider session lacking the chat's prior context. +//! +//! A forked side chat's first prompt used to paste the entire source +//! transcript — every tool call included — as raw JSON in front of the new +//! message, with no size limit: long chats burned the new session's context +//! before it started (t3-orchestration-v2 spec story 58, ticket 01). The +//! transcript in front of the message is now a budgeted selection: +//! +//! 1. the latest user message, the latest assistant message and the first +//! user message; +//! 2. then the newest remaining messages, newest first, until the budget is +//! spent. +//! +//! **Contents:** user and assistant text, command runs (input, exit code, +//! output), errors and file-change names. Reasoning (the model's private +//! scratchpad) and attachments are left out. Long items are truncated. +//! +//! **Budget:** 16k tokens, counted at one token per UTF-8 byte — a +//! deliberately pessimistic proxy, not a tokenizer. It bounds everything the +//! wrapper puts in front of the new user message; the new message itself is +//! never counted and never shortened. +//! +//! A transcript that fits the budget whole is delivered complete, with no +//! note. One that does not carries a **coverage header** naming how many +//! messages were omitted and how to read them back with the `read_chat` MCP +//! tool using the source chat's id. +//! +//! One engine piece on purpose: every path that must hand a chat's prior +//! transcript to a session that lacks it reuses +//! [`budgeted_transcript_prompt`] — the fork bootstrap (ticket 01), the +//! account-failover fallback (ticket 09), the native-fork fallbacks +//! (17–20), forks into another harness (22) and the one-way harness switch +//! (24). + +use roboco_doc::{join_continuation_entries, MessagePart, MessageRole, SessionMessageEntry}; +use roboco_proto::{ToolCall, ToolDiff}; + +/// The transcript budget: 16k "tokens" at one token per UTF-8 byte, bounding +/// everything delivered in front of the new user message (wrapper, coverage +/// header and transcript — never the new message itself). +pub const TRANSCRIPT_BUDGET_BYTES: usize = 16 * 1024; + +/// Cap for any single delivered message once the transcript must be +/// selected: no one message may eat more than a quarter of the budget, so +/// the three priority messages always fit together with room for recent +/// context. A transcript that fits whole is never truncated, whatever this +/// cap says. +const ITEM_CAP_BYTES: usize = TRANSCRIPT_BUDGET_BYTES / 4; + +/// Smallest truncated delivery worth its bytes: below this an item is +/// omitted rather than shipped as a meaningless stub. +const MIN_ITEM_BYTES: usize = 64; + +const LEAD: &str = "Continue this side chat using the following prior transcript as context."; +const TRANSCRIPT_OPEN: &str = "\n"; +const TRANSCRIPT_CLOSE: &str = "\n\n\n"; +const TRUNCATION_MARKER: &str = " …[truncated]"; + +/// One transcript entry rendered for handoff: its role plus the parts the +/// contents policy keeps. +struct TranscriptItem { + role: MessageRole, + text: String, +} + +/// The delivered prompt: the budgeted transcript of `entries` in front of +/// `prompt`, or `None` when the entries carry no deliverable messages (the +/// caller then sends `prompt` as is). +/// +/// `source_chat_id` is the chat whose transcript the omitted messages are +/// readable in — the fork's source for the fork bootstrap, the chat itself +/// for harness switches and account failovers. +pub fn budgeted_transcript_prompt( + entries: &[SessionMessageEntry], + source_chat_id: Option<&str>, + prompt: &str, +) -> Option { + let items = transcript_items(entries); + let lines: Vec = items + .iter() + .map(|item| format!("{}: {}", role_label(item.role), item.text)) + .collect(); + if lines.is_empty() { + return None; + } + let wrapper_fixed = LEAD.len() + 1 + TRANSCRIPT_OPEN.len() + TRANSCRIPT_CLOSE.len(); + let transcript = lines.join("\n"); + if wrapper_fixed + transcript.len() <= TRANSCRIPT_BUDGET_BYTES { + // The whole transcript fits: complete, verbatim, no coverage note. + return Some(wrap(None, &lines, prompt)); + } + + // Selection mode. The header's length depends on the omitted count, so + // reserve its worst case up front (the largest count is all items); + // intermediate selections then cannot grow past the budget. + let header_max = coverage_header_max(items.len(), source_chat_id); + let mut remaining = TRANSCRIPT_BUDGET_BYTES + .saturating_sub(wrapper_fixed) + .saturating_sub(header_max); + let mut delivered: Vec> = vec![None; lines.len()]; + + // Priorities, in order: the latest user message, the latest assistant + // message, the first user message. Never dropped while any room remains + // — an oversized one is truncated to the smaller of the per-item cap and + // what is left. + let last_user = items.iter().rposition(|item| item.role == MessageRole::User); + let last_assistant = items + .iter() + .rposition(|item| item.role == MessageRole::Assistant); + let first_user = items.iter().position(|item| item.role == MessageRole::User); + for idx in [last_user, last_assistant, first_user].into_iter().flatten() { + if delivered[idx].is_some() { + continue; + } + let cap = ITEM_CAP_BYTES.min(remaining.saturating_sub(1)); + if cap < MIN_ITEM_BYTES { + break; // nothing meaningful fits any more + } + let line = truncate_line(&lines[idx], cap); + remaining -= line.len() + 1; + delivered[idx] = Some(line); + } + + // Fill with the newest remaining messages, newest first. An item that + // does not fit (after its per-item cap) is skipped whole — counted as + // omitted — and older items still get their chance. + for idx in (0..lines.len()).rev() { + if delivered[idx].is_some() { + continue; + } + let Some(line) = fill_line(&lines[idx], remaining) else { + continue; + }; + remaining -= line.len() + 1; + delivered[idx] = Some(line); + } + + let omitted = delivered.iter().filter(|slot| slot.is_none()).count(); + let selected: Vec = delivered.into_iter().flatten().collect(); + if selected.is_empty() { + // Unreachable with the shipped budget (three capped priorities + // always fit); degrade to the verbatim transcript rather than send + // an empty block. + return Some(wrap(None, &lines, prompt)); + } + let header = coverage_header(omitted, source_chat_id); + Some(wrap(Some(header), &selected, prompt)) +} + +/// Render transcript entries into handoff items. Continuation entries are +/// fragments of one logical message (entries split at `MSG_INLINE_MAX`), so +/// they are joined first: the budget and the omission count must see +/// messages, not fragments. System entries (fork seams, system notices) are +/// structure, not transcript, and never render. +fn transcript_items(entries: &[SessionMessageEntry]) -> Vec { + let joined = join_continuation_entries(entries.to_vec()); + let mut out = Vec::with_capacity(joined.len()); + for entry in joined { + if entry.role == MessageRole::System { + continue; + } + let text = render_parts(&entry.parts); + if text.trim().is_empty() { + continue; + } + out.push(TranscriptItem { + role: entry.role, + text, + }); + } + out +} + +/// The delivered label for a role. +fn role_label(role: MessageRole) -> &'static str { + match role { + MessageRole::User => "user", + MessageRole::Assistant => "assistant", + // Unreachable: system entries never become items. + MessageRole::System => "system", + } +} + +/// The parts of one entry, joined: the contents policy in code. +fn render_parts(parts: &[MessagePart]) -> String { + let mut blocks: Vec = Vec::new(); + for part in parts { + let block = match part { + MessagePart::Text { text, .. } => { + if text.trim().is_empty() { + continue; + } + text.clone() + } + MessagePart::Error { message, .. } => format!("Error: {message}"), + MessagePart::Tool { .. } => render_command_run(part), + // Reasoning is the model's private scratchpad; images are + // attachments; input requests and fork seams are structure — + // none of them belong in a budgeted summary. + MessagePart::Reasoning { .. } + | MessagePart::Image { .. } + | MessagePart::Input { .. } + | MessagePart::Fork { .. } => continue, + }; + blocks.push(block); + } + blocks.join("\n") +} + +/// One command run: the input, the exit code, the output, the file changes. +/// +/// The doc records no numeric exit status — the fold keeps the failure flag, +/// which carries the same signal for a handoff (`0` ok, `1` failed, +/// `unknown` while the call is unresolved). `output` is the persisted +/// one-line summary; full outputs live outside the doc. `diff_stats` (and +/// the legacy inline `diff`) name the files the run changed. +fn render_command_run(part: &MessagePart) -> String { + let MessagePart::Tool { + call, + is_error, + resolved, + output, + diff, + diff_stats, + .. + } = part + else { + return String::new(); + }; + let mut lines = vec![format!("Command: {}", describe_call(call))]; + let exit = match (*resolved, *is_error) { + (true, false) => "0", + (true, true) => "1", + (false, _) => "unknown", + }; + lines.push(format!("Exit code: {exit}")); + if let Some(output) = output.as_deref().filter(|output| !output.trim().is_empty()) { + lines.push(output.to_owned()); + } + let mut paths: Vec<&str> = Vec::new(); + if let Some(stats) = diff_stats { + paths.extend(stats.iter().map(|stat| stat.path.as_str())); + } + if let Some(diff) = diff { + paths.push(diff.path.as_str()); // pre-strip docs kept inline diffs + } + if !paths.is_empty() { + lines.push(format!("File changes: {}", paths.join(", "))); + } + lines.join("\n") +} + +/// Compact rendering of a (already privacy-sanitized) tool call — the input +/// line of a command run. Heavy inputs never reach the doc (the render +/// policy strips them), so this stays short by construction. +fn describe_call(call: &ToolCall) -> String { + match call { + ToolCall::Exec { command } => command.clone(), + ToolCall::ReadFile { path } => format!("read {path}"), + ToolCall::WriteFile { path, .. } => format!("write {path}"), + ToolCall::EditFile { path, .. } => format!("edit {path}"), + ToolCall::ApplyPatch { path } => match path { + Some(path) => format!("patch {path}"), + None => "patch".to_owned(), + }, + ToolCall::Search { pattern, .. } => format!("search {pattern}"), + ToolCall::Glob { pattern } => format!("glob {pattern}"), + ToolCall::WebFetch { url, .. } => format!("fetch {url}"), + ToolCall::WebSearch { query } => format!("search web {query}"), + ToolCall::Todo { items } => format!("todo ({} items)", items.len()), + ToolCall::Mcp { server, tool, .. } => format!("{server}/{tool}"), + ToolCall::Unknown { name, .. } => name.clone(), + } +} + +/// A fill candidate: its per-item-capped form, included only when it fits +/// the remaining budget — otherwise the message is omitted whole. +fn fill_line(line: &str, remaining: usize) -> Option { + if remaining <= MIN_ITEM_BYTES { + return None; + } + let capped = truncate_line(line, ITEM_CAP_BYTES); + (capped.len() + 1 <= remaining).then_some(capped) +} + +/// Truncate to at most `cap` bytes (marker included), at a UTF-8 char +/// boundary, marking the cut so the reader knows there was more. +fn truncate_line(line: &str, cap: usize) -> String { + if line.len() <= cap { + return line.to_owned(); + } + let keep = cap.saturating_sub(TRUNCATION_MARKER.len()); + let mut end = keep.min(line.len()); + while end > 0 && !line.is_char_boundary(end) { + end -= 1; + } + let mut out = line[..end].to_owned(); + out.push_str(TRUNCATION_MARKER); + out +} + +/// The coverage header for a partial transcript: how many messages were +/// omitted, and how to read them back. A selection that omitted nothing but +/// truncated something still points at `read_chat` — the cut text is just +/// as recoverable as the omitted messages. +fn coverage_header(omitted: usize, source_chat_id: Option<&str>) -> String { + let lead = if omitted > 0 { + format!("This transcript is partial: {omitted} messages were omitted.") + } else { + "This transcript is partial: long messages were truncated.".to_owned() + }; + match source_chat_id { + Some(chat_id) => format!( + "{lead} Read the full transcript with the `read_chat` MCP tool using chat id \"{chat_id}\"." + ), + None => lead, + } +} + +/// The worst-case header length for a given item count: the largest omitted +/// count is all items (widest counter), plus the count-free truncation +/// variant, plus the trailing newline. +fn coverage_header_max(items: usize, source_chat_id: Option<&str>) -> usize { + coverage_header(items, source_chat_id) + .len() + .max(coverage_header(0, source_chat_id).len()) + + 1 +} + +/// Assemble the delivered prompt: lead, optional coverage header, the +/// transcript block, then the new user message — whole, always last. +fn wrap(header: Option, lines: &[String], prompt: &str) -> String { + let mut out = + String::with_capacity(LEAD.len() + TRANSCRIPT_OPEN.len() + TRANSCRIPT_CLOSE.len() + 256); + out.push_str(LEAD); + out.push('\n'); + if let Some(header) = header { + out.push_str(&header); + out.push('\n'); + } + out.push_str(TRANSCRIPT_OPEN); + out.push_str(&lines.join("\n")); + out.push_str(TRANSCRIPT_CLOSE); + out.push_str(prompt); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use roboco_doc::{MessageStatus, ToolDiffStat}; + + fn entry(id: &str, role: MessageRole, parts: Vec) -> SessionMessageEntry { + SessionMessageEntry { + duration_ms: None, + id: id.into(), + role, + parts, + created_at: 1, + device_id: "device".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + } + } + + fn user(id: &str, body: &str) -> SessionMessageEntry { + entry( + id, + MessageRole::User, + vec![MessagePart::Text { + id: format!("{id}-t"), + text: body.into(), + }], + ) + } + + fn assistant(id: &str, body: &str) -> SessionMessageEntry { + entry( + id, + MessageRole::Assistant, + vec![MessagePart::Text { + id: format!("{id}-t"), + text: body.into(), + }], + ) + } + + fn tool_part(id: &str, command: &str) -> MessagePart { + MessagePart::Tool { + id: id.into(), + call: ToolCall::Exec { + command: command.into(), + }, + is_error: false, + resolved: true, + output: None, + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: None, + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + } + } + + const NEW_MESSAGE: &str = "NEW: what next?"; + + /// Everything the wrapper puts in front of the new message (the + /// budgeted part). + fn delivered_prefix(prompt: &str) -> &str { + &prompt[..prompt.len() - NEW_MESSAGE.len()] + } + + /// The count inside "N messages were omitted". + fn omitted_count(prompt: &str) -> usize { + let at = prompt + .find(" messages were omitted") + .expect("coverage header"); + let start = prompt[..at] + .char_indices() + .rev() + .find(|(_, c)| !c.is_ascii_digit()) + .map(|(i, _)| i + 1) + .unwrap_or(0); + prompt[start..at].parse().expect("omitted count") + } + + #[test] + fn a_fitting_transcript_rides_complete_with_no_note() { + let entries = vec![ + user("u1", "Remember PINEAPPLE"), + assistant("a1", "I remember PINEAPPLE"), + user("u2", "And the code?"), + ]; + let prompt = budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).unwrap(); + assert!(prompt.ends_with(NEW_MESSAGE)); + for body in ["Remember PINEAPPLE", "I remember PINEAPPLE", "And the code?"] { + assert!(prompt.contains(body), "missing {body}: {prompt}"); + } + // Complete means complete: no omission note, no read pointer, no cuts. + assert!(!prompt.contains("omitted"), "{prompt}"); + assert!(!prompt.contains("read_chat"), "{prompt}"); + assert!(!prompt.contains(TRUNCATION_MARKER), "{prompt}"); + assert!(prompt.contains(LEAD), "{prompt}"); + assert!(prompt.contains(""), "{prompt}"); + // Delivered in transcript order. + assert!( + prompt.find("Remember PINEAPPLE").unwrap() < prompt.find("And the code?").unwrap() + ); + } + + #[test] + fn selection_keeps_priorities_then_fills_newest_first_within_the_budget() { + let mut entries = vec![ + user("u0", "ORIGINAL: build the parser"), + assistant("a0", "on it"), + ]; + for i in 1..=20 { + entries.push(user(&format!("u{i}"), &format!("fill-{i}: {}", "x".repeat(900)))); + entries.push(assistant(&format!("a{i}"), &format!("note-{i}: {}", "y".repeat(90)))); + } + entries.push(user("u99", "LATEST-Q: ship it")); + entries.push(assistant("a99", "LATEST-A: done")); + let prompt = budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).unwrap(); + // The three priorities survive every budget. + for body in ["ORIGINAL: build the parser", "LATEST-Q: ship it", "LATEST-A: done"] { + assert!(prompt.contains(body), "missing priority {body}: {prompt}"); + } + // Recent context is preferred over the middle of the transcript. + assert!(prompt.contains("fill-20:"), "{prompt}"); + assert!(prompt.contains("note-20:"), "{prompt}"); + assert!(!prompt.contains("fill-2:"), "{prompt}"); + // The coverage header names the omitted count and the source chat. + assert!(omitted_count(&prompt) >= 1); + assert!(prompt.contains("read_chat"), "{prompt}"); + assert!(prompt.contains("chat id \"main\""), "{prompt}"); + // Everything in front of the new message fits the budget. + assert!( + delivered_prefix(&prompt).len() <= TRANSCRIPT_BUDGET_BYTES, + "delivered {} bytes", + delivered_prefix(&prompt).len() + ); + assert!(prompt.ends_with(NEW_MESSAGE)); + } + + #[test] + fn an_oversized_message_is_truncated_not_dropped() { + let entries = vec![ + user("u0", "FIRST: the original request"), + assistant("a0", "acknowledged"), + user("u1", &format!("BIG {}", "z".repeat(30_000))), + assistant("a1", "done"), + ]; + let prompt = budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).unwrap(); + assert!(prompt.contains("BIG "), "{prompt}"); + assert!(prompt.contains(TRUNCATION_MARKER), "{prompt}"); + // The 30k body was cut to a bounded prefix: most of it is gone, and + // nothing close to the full body survived. + assert!(prompt.contains(&"z".repeat(4000)), "{prompt}"); + assert!(!prompt.contains(&"z".repeat(5000)), "{prompt}"); + // The other messages still ride along. + assert!(prompt.contains("FIRST: the original request"), "{prompt}"); + assert!(prompt.contains("done"), "{prompt}"); + assert!( + delivered_prefix(&prompt).len() <= TRANSCRIPT_BUDGET_BYTES, + "delivered {} bytes", + delivered_prefix(&prompt).len() + ); + // Truncated-but-nothing-omitted still points at read_chat. + assert!(!prompt.contains("messages were omitted"), "{prompt}"); + assert!(prompt.contains("read_chat"), "{prompt}"); + } + + #[test] + fn the_new_user_message_is_never_shortened() { + let entries = vec![ + user("u0", "ORIGINAL: build the parser"), + assistant("a0", "on it"), + user("u1", &format!("BIG {}", "z".repeat(30_000))), + assistant("a1", "done"), + ]; + let new_message = format!("{} {}", "NEW:", "n".repeat(60_000)); + let prompt = budgeted_transcript_prompt(&entries, Some("main"), &new_message).unwrap(); + assert!(prompt.ends_with(&new_message)); + assert!(prompt.contains(&new_message)); + // The oversized new message never ate the transcript's budget. + assert!(prompt.contains("ORIGINAL: build the parser"), "{prompt}"); + assert!( + prompt.len() - new_message.len() <= TRANSCRIPT_BUDGET_BYTES, + "delivered {} bytes", + prompt.len() - new_message.len() + ); + } + + #[test] + fn contents_policy_keeps_text_runs_errors_and_file_changes_only() { + let entries = vec![ + entry( + "u1", + MessageRole::User, + vec![ + MessagePart::Text { + id: "u1-t".into(), + text: "look at this".into(), + }, + MessagePart::Reasoning { + id: "u1-r".into(), + text: "PRIVATE SCRATCHPAD".into(), + }, + MessagePart::Image { + id: "u1-i".into(), + path: "/uploads/p.png".into(), + name: "p.png".into(), + mime_type: "image/png".into(), + }, + MessagePart::Input { + id: "u1-in".into(), + request_id: "req-1".into(), + questions: vec![], + resolved: true, + }, + ], + ), + entry( + "a1", + MessageRole::Assistant, + vec![ + MessagePart::Tool { + id: "a1-tool".into(), + call: ToolCall::Exec { + command: "cargo test".into(), + }, + is_error: true, + resolved: true, + output: Some("3 failed".into()), + diff: None, + output_ref: None, + output_bytes: None, + diff_ref: None, + diff_stats: Some(vec![ToolDiffStat { + path: "/w/a.rs".into(), + additions: 2, + deletions: 1, + }]), + subagent_ref: None, + subagent_status: None, + subagent_tail: None, + }, + MessagePart::Error { + id: "a1-e".into(), + message: "boom".into(), + }, + MessagePart::Text { + id: "a1-t".into(), + text: "here is what happened".into(), + }, + ], + ), + // System entries (the fork seam) are structure, not transcript. + entry("seam", MessageRole::System, vec![MessagePart::Fork { + id: "fork:x".into(), + source_chat_id: "main".into(), + source_title: "Main".into(), + }]), + // An entry that renders blank (reasoning only) is no message. + entry( + "a2", + MessageRole::Assistant, + vec![MessagePart::Reasoning { + id: "a2-r".into(), + text: "thinking only".into(), + }], + ), + ]; + let prompt = budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).unwrap(); + assert!(prompt.contains("look at this"), "{prompt}"); + assert!(prompt.contains("Command: cargo test"), "{prompt}"); + assert!(prompt.contains("Exit code: 1"), "{prompt}"); + assert!(prompt.contains("3 failed"), "{prompt}"); + assert!(prompt.contains("File changes: /w/a.rs"), "{prompt}"); + assert!(prompt.contains("Error: boom"), "{prompt}"); + assert!(prompt.contains("here is what happened"), "{prompt}"); + assert!(prompt.contains("user: look at this"), "{prompt}"); + assert!(prompt.contains("assistant: Command: cargo test"), "{prompt}"); + assert!(!prompt.contains("PRIVATE SCRATCHPAD"), "{prompt}"); + assert!(!prompt.contains("p.png"), "{prompt}"); + assert!(!prompt.contains("req-1"), "{prompt}"); + assert!(!prompt.contains("fork:x"), "{prompt}"); + assert!(!prompt.contains("Main"), "{prompt}"); + assert!(!prompt.contains("thinking only"), "{prompt}"); + } + + #[test] + fn unresolved_calls_report_an_unknown_exit_code() { + let mut part = tool_part("t", "ls"); + if let MessagePart::Tool { + resolved, is_error, .. + } = &mut part + { + *resolved = false; + *is_error = false; + } + let entries = vec![entry( + "a1", + MessageRole::Assistant, + vec![part, tool_part("t2", "pwd")], + )]; + let prompt = budgeted_transcript_prompt(&entries, None, NEW_MESSAGE).unwrap(); + assert!(prompt.contains("Command: ls"), "{prompt}"); + assert!(prompt.contains("Exit code: unknown"), "{prompt}"); + assert!(prompt.contains("Command: pwd"), "{prompt}"); + assert!(prompt.contains("Exit code: 0"), "{prompt}"); + } + + #[test] + fn truncation_cuts_at_char_boundaries() { + let line = format!("user: {}", "é".repeat(6_000)); + let cut = truncate_line(&line, ITEM_CAP_BYTES); + assert!(cut.len() <= ITEM_CAP_BYTES, "{}", cut.len()); + assert!(cut.ends_with(TRUNCATION_MARKER)); + // Slicing at a char boundary keeps the string valid UTF-8 whole. + let reassembled: String = cut.chars().collect(); + assert_eq!(reassembled, cut); + // A line under the cap is untouched. + assert_eq!(truncate_line("short", ITEM_CAP_BYTES), "short"); + } + + #[test] + fn continuation_fragments_join_into_one_message() { + let mut root = user("m1", "first half"); + root.parts.push(MessagePart::Text { + id: "m1-t".into(), + text: "second half".into(), + }); + let tail = entry( + "m1#c1", + MessageRole::User, + vec![MessagePart::Text { + id: "m1-t2".into(), + text: "third half".into(), + }], + ); + let tail = SessionMessageEntry { + continuation_of: Some("m1".into()), + ..tail + }; + let prompt = budgeted_transcript_prompt(&[root, tail], Some("main"), NEW_MESSAGE).unwrap(); + // One message, delivered once, parts in order. + assert_eq!(prompt.matches("user:").count(), 1, "{prompt}"); + assert!(prompt.contains("first half\nsecond half\nthird half"), "{prompt}"); + } + + #[test] + fn no_deliverable_transcript_returns_none() { + let entries = vec![ + entry( + "seam", + MessageRole::System, + vec![MessagePart::Fork { + id: "fork:x".into(), + source_chat_id: "main".into(), + source_title: "Main".into(), + }], + ), + user("u1", " "), + ]; + assert!(budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).is_none()); + assert!(budgeted_transcript_prompt(&[], Some("main"), NEW_MESSAGE).is_none()); + } +} diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 9f68aa5320..ee922fd7b7 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -9,8 +9,10 @@ //! with main chats, and resets provider-session continuity so the copy //! never resumes the parent's conversation; //! - a side chat's first send bootstraps the provider session with the -//! conversation it lacks (the copied history), once per provider session, -//! never in front of a native command; +//! transcript it lacks — budgeted (t3-orchestration-v2 ticket 01): a +//! 16k-byte selection of the copied transcript with a coverage header +//! naming the omitted messages and the `read_chat` source chat — once per +//! provider session, never in front of a native command; //! - a fork whose first turn was a native command owes its copied history to //! the first ordinary send — warm dispatch, steer, or a cold resume — //! exactly once, recorded durably per provider session. @@ -20,7 +22,7 @@ use futures::StreamExt; use futures::stream::BoxStream; use std::sync::{Arc, Mutex}; use roboco_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry}; -use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_engine::{transcript_budget::TRANSCRIPT_BUDGET_BYTES, EngineCore, HarnessRegistry}; use roboco_harness::{Harness, HarnessError, RunControls}; use roboco_proto::{ AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SandboxLevel, @@ -883,3 +885,243 @@ async fn orphaned_history_steer_still_owes_the_history() { ); core.shutdown().await; } + +// ── budgeted fork transcript (t3-orchestration-v2 ticket 01) ───────────────── + +/// The count inside the coverage header's "N messages were omitted". +fn omitted_count(prompt: &str) -> usize { + let at = prompt + .find(" messages were omitted") + .expect("coverage header"); + let start = prompt[..at] + .char_indices() + .rev() + .find(|(_, c)| !c.is_ascii_digit()) + .map(|(i, _)| i + 1) + .unwrap_or(0); + prompt[start..at].parse().expect("omitted count") +} + +/// A fork of a long chat no longer pastes the whole transcript: what goes +/// in front of the new message is a 16k-byte selection that keeps the first +/// and latest user messages and the latest assistant message, fills with +/// the newest remainder, and carries a coverage header with the omitted +/// count and the `read_chat` pointer to the source chat. The new user +/// message rides whole at the end. +#[tokio::test] +async fn a_long_fork_sends_budgeted_transcript_with_a_coverage_header() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + for (id, role, text) in [ + ( + "u0", + MessageRole::User, + "FIRSTASK: build the budgeted fork transcript", + ), + ("a0", MessageRole::Assistant, "FIRSTANS: on it"), + ] { + source + .doc() + .push_message(&message(id, role, text, MessageStatus::Complete)) + .unwrap(); + } + for i in 1..=30 { + source + .doc() + .push_message(&message( + &format!("u{i}"), + MessageRole::User, + &format!("FILLER-{i}: {}", "x".repeat(900)), + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&message( + &format!("a{i}"), + MessageRole::Assistant, + &format!("FILLERA-{i}: {}", "y".repeat(500)), + MessageStatus::Complete, + )) + .unwrap(); + } + for (id, role, text) in [ + ("u99", MessageRole::User, "LASTASK: ship it"), + ("a99", MessageRole::Assistant, "LASTANS: done"), + ] { + source + .doc() + .push_message(&message(id, role, text, MessageStatus::Complete)) + .unwrap(); + } + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "fork", "sourceChatId": "main" }), + ) + .await + .unwrap(); + let new_message = "What did I ask you to do first?"; + let request = side_turn(&core, &requests, "fork", new_message, "f1").await; + let prompt = request.prompt; + // The new user message is never shortened and always last. + assert!(prompt.ends_with(new_message), "{prompt}"); + // The priorities survive every budget. + for marker in ["FIRSTASK", "LASTASK", "LASTANS"] { + assert!(prompt.contains(marker), "missing {marker}: {prompt}"); + } + // Recent context is preferred over the middle of the transcript. + assert!(prompt.contains("FILLER-30:"), "{prompt}"); + assert!(!prompt.contains("FILLER-2:"), "{prompt}"); + // The coverage header: omitted count, read_chat, the source chat id. + assert!(omitted_count(&prompt) >= 1); + assert!(prompt.contains("read_chat"), "{prompt}"); + assert!(prompt.contains("chat id \"main\""), "{prompt}"); + // Everything in front of the new message fits the budget. + let delivered = &prompt[..prompt.len() - new_message.len()]; + assert!( + delivered.len() <= TRANSCRIPT_BUDGET_BYTES, + "delivered {} bytes", + delivered.len() + ); + core.shutdown().await; +} + +/// A fork of a chat whose whole transcript fits the budget sends it complete +/// — every message verbatim, no omission note, no truncation. +#[tokio::test] +async fn a_short_fork_sends_its_whole_transcript_without_an_omission_note() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + for (id, role, text) in [ + ("u1", MessageRole::User, "Remember PINEAPPLE"), + ("a1", MessageRole::Assistant, "I remember PINEAPPLE"), + ("u2", MessageRole::User, "And the code?"), + ("a2", MessageRole::Assistant, "Checked in"), + ] { + source + .doc() + .push_message(&message(id, role, text, MessageStatus::Complete)) + .unwrap(); + } + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "fork", "sourceChatId": "main" }), + ) + .await + .unwrap(); + let new_message = "What did I ask you to remember?"; + let request = side_turn(&core, &requests, "fork", new_message, "f1").await; + let prompt = request.prompt; + assert!(prompt.ends_with(new_message), "{prompt}"); + assert!(prompt.contains(""), "{prompt}"); + // Complete means complete. + for text in [ + "Remember PINEAPPLE", + "I remember PINEAPPLE", + "And the code?", + "Checked in", + ] { + assert!(prompt.contains(text), "missing {text}: {prompt}"); + } + assert!(!prompt.contains("omitted"), "{prompt}"); + assert!(!prompt.contains("read_chat"), "{prompt}"); + assert!(!prompt.contains("…[truncated]"), "{prompt}"); + core.shutdown().await; +} + +/// A single message larger than the whole budget is truncated — present, +/// marked, bounded — while the rest of the transcript and the new user +/// message ride along whole. +#[tokio::test] +async fn an_oversized_fork_message_is_truncated_not_dropped() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + let big = format!("BIGMARK {}", "z".repeat(30_000)); + for (id, role, text) in [ + ("u0", MessageRole::User, "FIRST: the original request"), + ("a0", MessageRole::Assistant, "acknowledged"), + ("u1", MessageRole::User, big.as_str()), + ("a1", MessageRole::Assistant, "done"), + ] { + source + .doc() + .push_message(&message(id, role, text, MessageStatus::Complete)) + .unwrap(); + } + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "fork", "sourceChatId": "main" }), + ) + .await + .unwrap(); + let new_message = "Summarize what I asked for."; + let request = side_turn(&core, &requests, "fork", new_message, "f1").await; + let prompt = request.prompt; + // The oversized message is present but cut, visibly. + assert!(prompt.contains("BIGMARK"), "{prompt}"); + assert!(prompt.contains("…[truncated]"), "{prompt}"); + assert!(prompt.contains(&"z".repeat(4000)), "{prompt}"); + assert!(!prompt.contains(&"z".repeat(5000)), "{prompt}"); + // The other messages still ride along. + assert!(prompt.contains("FIRST: the original request"), "{prompt}"); + assert!(prompt.contains("done"), "{prompt}"); + // Nothing was omitted, but the truncation still points at read_chat. + assert!(!prompt.contains("messages were omitted"), "{prompt}"); + assert!(prompt.contains("read_chat"), "{prompt}"); + assert!(prompt.contains("chat id \"main\""), "{prompt}"); + // Bounded, and the new message whole at the end. + let delivered = &prompt[..prompt.len() - new_message.len()]; + assert!( + delivered.len() <= TRANSCRIPT_BUDGET_BYTES, + "delivered {} bytes", + delivered.len() + ); + assert!(prompt.ends_with(new_message), "{prompt}"); + core.shutdown().await; +} + From 6bd593233b7c78a9a28751b9831888018883a710 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 12:15:32 +0700 Subject: [PATCH 03/96] feat(harness): classify usage limits and sign-outs as structured error kinds AgentEvent::Error gains an optional structured kind (ADR 0005, additive on the wire): usageLimit {window, resetsAt} or signedOut. The error message stays the rendering source; older payloads and clients are unchanged. - Claude Code: the blocking rate_limit_event now reads the CLI's resetsAt (unix millis) and classifies as a usage limit with the window label; the terse assistant rate_limit code classifies as a usage limit and authentication_failed as signed out (parent and subagent paths). Overage spending keeps its existing not-a-limit handling. - Codex: turn-level usageLimitExceeded/rateLimitExceeded (turn/failed, turn/completed with an error, the bare error notification, and child threads) classify as a usage limit; unauthorized classifies as signed out. The reset time comes from the latest account/rateLimits/updated snapshot, which the driver now tracks (exhausted window, later reset binds). A classified turn failure moves its text onto the Error event so the transcript keeps exactly one error part while the kind reaches the engine; unclassified failures keep their historical Done-only shape. - Mock harness's scripted usage-limit error carries the kind too, so engine-side Limited state can be tested without a real CLI. - Wiregen: AgentErrorKind joins the export manifest; the generated web types are regenerated by hand to match ts-rs byte-for-byte (no local builds per the ticket override; the CI gate verifies). - Tests: proto wire-shape pin (additivity both directions), Claude and Codex normalize unit tests, fake-CLI scenarios (usage-limit, rate-limit-completed, auth) and harness-seam assertions for every ticket criterion, including no-kind/no-text-change for other errors. --- crates/doc/src/parts.rs | 2 +- crates/engine/src/sessions.rs | 2 + crates/engine/src/titles.rs | 2 +- .../harness/examples/cursor_steering_probe.rs | 2 +- .../harness/examples/opencode_turn_probe.rs | 2 +- crates/harness/src/acp/mod.rs | 4 +- crates/harness/src/claude/normalize.rs | 119 +++++++- crates/harness/src/claude/wire.rs | 4 + crates/harness/src/codex/mod.rs | 60 ++++- crates/harness/src/codex/normalize.rs | 255 +++++++++++++++++- crates/harness/src/mock.rs | 11 +- crates/harness/src/opencode/mod.rs | 14 +- crates/harness/src/opencode/tests.rs | 4 +- crates/harness/src/pi/mod.rs | 5 +- crates/harness/tests/acp_stall.rs | 2 +- crates/harness/tests/claude.rs | 57 +++- crates/harness/tests/codex.rs | 124 ++++++++- crates/harness/tests/fixtures/fake-claude.sh | 12 +- crates/harness/tests/fixtures/fake-codex.sh | 26 ++ crates/harness/tests/opencode.rs | 6 +- crates/harness/tests/pi.rs | 6 +- crates/proto/src/agent.rs | 88 ++++++ crates/wiregen/src/main.rs | 1 + .../proto/src/generated/AgentErrorKind.ts | 16 ++ .../proto/src/generated/AgentEvent.ts | 9 +- web/packages/proto/src/generated/index.ts | 1 + 26 files changed, 792 insertions(+), 42 deletions(-) create mode 100644 web/packages/proto/src/generated/AgentErrorKind.ts diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index d6c49073a9..3d7c1ef223 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -424,7 +424,7 @@ pub fn fold_event_into_parts(out: &mut Vec, event: &AgentEvent) { } } } - AgentEvent::Error { message } => { + AgentEvent::Error { message, .. } => { let id = format!("e{}", out.len()); out.push(MessagePart::Error { id, diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fc7d4c441b..af3744eba9 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1266,6 +1266,7 @@ impl Inner { }, AgentEvent::Error { message: "Generated image unavailable".into(), + kind: None, }, ] } @@ -2186,6 +2187,7 @@ async fn drive_run( &chat_id, &AgentEvent::Error { message: message.clone(), + kind: None, }, ); inner.publish( diff --git a/crates/engine/src/titles.rs b/crates/engine/src/titles.rs index 99984d5b69..b5237771ff 100644 --- a/crates/engine/src/titles.rs +++ b/crates/engine/src/titles.rs @@ -298,7 +298,7 @@ async fn collect_text( "title generation attempted to use a tool".into(), )); } - AgentEvent::Error { message } => { + AgentEvent::Error { message, .. } => { return Err(EngineError::Other(format!("titling run error: {message}"))); } AgentEvent::Done { status, error, .. } => { diff --git a/crates/harness/examples/cursor_steering_probe.rs b/crates/harness/examples/cursor_steering_probe.rs index 12560cbf15..1927ca8fa8 100644 --- a/crates/harness/examples/cursor_steering_probe.rs +++ b/crates/harness/examples/cursor_steering_probe.rs @@ -77,7 +77,7 @@ async fn main() -> anyhow::Result<()> { break; } } - AgentEvent::Error { message } => eprintln!("{message}"), + AgentEvent::Error { message, .. } => eprintln!("{message}"), _ => {} } } diff --git a/crates/harness/examples/opencode_turn_probe.rs b/crates/harness/examples/opencode_turn_probe.rs index 7c2f2fa7b9..a312233ccf 100644 --- a/crates/harness/examples/opencode_turn_probe.rs +++ b/crates/harness/examples/opencode_turn_probe.rs @@ -87,7 +87,7 @@ async fn main() { }) => { eprintln!("USAGE {input_tokens}/{output_tokens}"); } - Ok(AgentEvent::Error { message }) => eprintln!("CHIP {message}"), + Ok(AgentEvent::Error { message, .. }) => eprintln!("CHIP {message}"), Ok(other) => eprintln!("EV {other:?}"), Err(e) => eprintln!("ERR {e}"), } diff --git a/crates/harness/src/acp/mod.rs b/crates/harness/src/acp/mod.rs index b78263488a..79e20be495 100644 --- a/crates/harness/src/acp/mod.rs +++ b/crates/harness/src/acp/mod.rs @@ -3676,6 +3676,7 @@ async fn run_session(session: Session) { &event_tx, AgentEvent::Error { message: format!("{agent_name} could not restore session {resume}; starting a new session without the previous context: {e}"), + kind: None, }, ) .await; @@ -4639,7 +4640,7 @@ async fn run_session(session: Session) { assistant_message_id: Some(prev), next_assistant_message_id: Some(next), }).await - || !send(&event_tx, AgentEvent::Error { message: message.clone() }).await + || !send(&event_tx, AgentEvent::Error { message: message.clone(), kind: None }).await || !send(&event_tx, AgentEvent::Done { status: DoneStatus::Errored, result: None, @@ -4807,6 +4808,7 @@ async fn run_session(session: Session) { prompt_stall.map(|d| d.as_secs()).unwrap_or(0), stall_hint, ), + kind: None, }, ) .await; diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index 29f7d48116..9647dc3dfb 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -1,7 +1,9 @@ //! Frame → [`AgentEvent`] normalization (init dedupe, subagent tagging, tool //! decoding, error-code mapping). -use roboco_proto::{AgentEvent, DoneStatus, HarnessId, TodoItem, TodoStatus, ToolCall}; +use roboco_proto::{ + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, TodoItem, TodoStatus, ToolCall, +}; use serde_json::Value; use super::wire::{ContentBlock, Frame}; @@ -38,6 +40,22 @@ fn rate_window_label(kind: &str) -> &'static str { } } +/// Structured classification for an assistant-level error code: the CLI's +/// terse `rate_limit` says a plan window blocked the turn but not which one +/// (the accompanying `rate_limit_event` usually does, and carries the reset); +/// `authentication_failed` means the login is gone. Everything else stays +/// unclassified. +fn assistant_error_kind(code: &str) -> Option { + match code { + "rate_limit" => Some(AgentErrorKind::UsageLimit { + window: rate_window_label("").into(), + resets_at: None, + }), + "authentication_failed" => Some(AgentErrorKind::SignedOut), + _ => None, + } +} + /// Fallback wording for a `result` error whose `errors` array is empty, so the /// turn never ends with a blank (and therefore invisible) error. fn result_error_text(subtype: &str) -> &'static str { @@ -501,6 +519,7 @@ impl Normalizer { parent, AgentEvent::Error { message: assistant_error_text(code), + kind: assistant_error_kind(code), }, )); } @@ -593,6 +612,7 @@ impl Normalizer { if let Some(code) = &f.error { out.push(AgentEvent::Error { message: assistant_error_text(code), + kind: assistant_error_kind(code), }); } // The enclosing assistant frame closes the streamed message @@ -667,6 +687,10 @@ impl Normalizer { message: format!( "Claude {window} limit reached — the turn was blocked. Try again after it resets." ), + kind: Some(AgentErrorKind::UsageLimit { + window: window.into(), + resets_at: f.rate_limit_info.resets_at.map(|ms| ms as i64), + }), }] } @@ -1439,7 +1463,100 @@ mod tests { assert!(normalize_one(&raw).is_empty(), "{overage}"); } } + + #[test] + fn a_blocking_limit_event_carries_the_window_and_reset_time() { + // The blocking event yields kind usage-limit with the window label + // and the CLI's own reset time (unix millis); the message is + // unchanged, so older clients render exactly as before. + let events = normalize_one( + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400000}}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "Claude 5-hour limit reached — the turn was blocked. Try again after it resets.".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + }] + ); + // No reset time reported: the kind still lands, without one. + let events = normalize_one( + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"seven_day"}}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "Claude weekly limit reached — the turn was blocked. Try again after it resets.".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "weekly".into(), + resets_at: None, + }), + }] + ); + } + + #[test] + fn assistant_error_codes_classify_limits_and_auth() { + // The terse `rate_limit` code: a usage limit, window unknown (the + // accompanying rate_limit_event usually carries the details). + let events = normalize_one( + r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "Claude usage limit reached — try again after the limit resets.".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + }] + ); + // Authentication failed: signed out, distinct from a limit. + let events = normalize_one( + r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"authentication_failed"}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "Authentication failed — sign in to Claude again.".into(), + kind: Some(AgentErrorKind::SignedOut), + }] + ); + // Other codes stay unclassified. + let events = normalize_one( + r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"billing_error"}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "Billing error — check your Claude plan or payment method.".into(), + kind: None, + }] + ); + } + + #[test] + fn subagent_error_codes_carry_the_classification_too() { + let events = normalize_one( + r#"{"type":"assistant","parent_tool_use_id":"toolu_sub","message":{"content":[]},"error":"authentication_failed"}"#, + ); + assert_eq!( + events, + vec![AgentEvent::Subagent { + parent_tool_use_id: "toolu_sub".into(), + event: Box::new(AgentEvent::Error { + message: "Authentication failed — sign in to Claude again.".into(), + kind: Some(AgentErrorKind::SignedOut), + }), + }] + ); + } } + #[cfg(test)] mod context_tests { use super::*; diff --git a/crates/harness/src/claude/wire.rs b/crates/harness/src/claude/wire.rs index abf14adbd8..827e88a9d2 100644 --- a/crates/harness/src/claude/wire.rs +++ b/crates/harness/src/claude/wire.rs @@ -152,6 +152,10 @@ pub(crate) struct RateLimitInfo { pub status: String, #[serde(rename = "rateLimitType", default)] pub rate_limit_type: Option, + /// Unix milliseconds when the rejected window resets — the CLI reports it + /// on the blocking event; absent when unknown. + #[serde(rename = "resetsAt", default)] + pub resets_at: Option, /// `allowed`/`allowed_warning` when the account may spend provisioned /// overage past a rejected window. #[serde(rename = "overageStatus", default)] diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index ce4bebb636..4783387e99 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -70,8 +70,9 @@ use crate::process::{Child, Command, Stdio}; use crate::{Harness, HarnessError, RunControls}; use catalog::{REASONING_LEVELS, sandbox_mode, sandbox_policy_value, static_models, to_effort}; use normalize::{ - ChildRoute, Phase, ReasoningStream, delta_text, item_id, item_type, notification_thread_id, - route_child_notification, turn_error_message, turn_id, usage_event, + ChildRoute, Phase, RateLimitReset, ReasoningStream, delta_text, item_id, item_type, + notification_thread_id, rate_limit_reset, route_child_notification, turn_error_kind, + turn_error_message, turn_id, usage_event, }; /// Locate the device's installed Codex CLI: `CODEX_EXECUTABLE`, then our own @@ -1264,6 +1265,10 @@ async fn run_session(session: Session) { let mut accepted_steers: VecDeque = VecDeque::new(); // A turn stop waiting for the app-server to announce the turn. let mut stop_when_announced = false; + // The latest `account/rateLimits/updated` snapshot reduced to the window + // it reports exhausted. Turn-level usage-limit errors attach this reset + // time; the notifications themselves stay otherwise unconsumed. + let mut limit_reset: Option = None; // Commands the parent thread started and the app-server has not reported // finished, with their process id and the turn that started them. One // still open after its turn is background work (a dev server, a @@ -1487,6 +1492,14 @@ async fn run_session(session: Session) { } } + // The account's usage meters, pushed on token-count + // events. Nothing is surfaced directly: the snapshot is + // held so a turn-level usage-limit error can carry the + // reset time the error itself omits. + "account/rateLimits/updated" => { + limit_reset = rate_limit_reset(¶ms); + } + // Codex's `update_plan` tool: the whole checklist, replaced // on every call, with an in-progress step. "turn/plan/updated" => { @@ -1539,6 +1552,22 @@ async fn run_session(session: Session) { == Some("failed")) .then(|| "Codex turn failed".to_owned()) }); + // A classified failure (usage limit, signed out) + // surfaces as an Error event ahead of the Done so the + // kind reaches engine and clients; its text moves off + // the Done so the transcript keeps exactly ONE error + // part. Unclassified failures keep their historical + // Done-only shape. + let kind = turn_error_kind(¶ms, limit_reset.as_ref()); + if let (Some(message), Some(kind)) = (&error, &kind) { + let classified = AgentEvent::Error { + message: message.clone(), + kind: Some(kind.clone()), + }; + if !send(&event_tx, classified).await { + break 'main; + } + } let status = if interrupted || stopping { DoneStatus::Interrupted } else if error.is_some() { @@ -1560,7 +1589,7 @@ async fn run_session(session: Session) { AgentEvent::Done { status, result: None, - error, + error: if kind.is_some() { None } else { error }, session_id: Some(thread_id.clone()), }, ) @@ -1634,6 +1663,22 @@ async fn run_session(session: Session) { if interrupted { done_after_interrupt = true; } + let error = + turn_error_message(¶ms).unwrap_or_else(|| "Codex turn failed".into()); + // Same classified-failure shape as turn/completed: the + // kind rides an Error event ahead of the Done, which + // then carries no error text of its own — one rendered + // error part either way. + let kind = turn_error_kind(¶ms, limit_reset.as_ref()); + if let Some(kind) = &kind { + let classified = AgentEvent::Error { + message: error.clone(), + kind: Some(kind.clone()), + }; + if !send(&event_tx, classified).await { + break 'main; + } + } let _ = send( &event_tx, AgentEvent::Done { @@ -1643,10 +1688,7 @@ async fn run_session(session: Session) { DoneStatus::Errored }, result: None, - error: Some( - turn_error_message(¶ms) - .unwrap_or_else(|| "Codex turn failed".into()), - ), + error: if kind.is_none() { Some(error) } else { None }, session_id: Some(thread_id.clone()), }, ) @@ -1707,7 +1749,8 @@ async fn run_session(session: Session) { .or_else(|| params.get("message").and_then(Value::as_str)) .unwrap_or("Codex error") .to_owned(); - if !send(&event_tx, AgentEvent::Error { message }).await { + let kind = turn_error_kind(¶ms, limit_reset.as_ref()); + if !send(&event_tx, AgentEvent::Error { message, kind }).await { break 'main; } } @@ -2007,6 +2050,7 @@ async fn steer_as_new_turn( event_tx, AgentEvent::Error { message: format!("Steering failed: {e}"), + kind: None, }, ) .await; diff --git a/crates/harness/src/codex/normalize.rs b/crates/harness/src/codex/normalize.rs index 5cb7c437d0..2c951fe84e 100644 --- a/crates/harness/src/codex/normalize.rs +++ b/crates/harness/src/codex/normalize.rs @@ -5,7 +5,7 @@ //! (`delta`/`textDelta`, `exitCode`/`exit_code`, camelCase/snake_case item //! types) are accepted, and unknown item types map to nothing. -use roboco_proto::{AgentEvent, DoneStatus, TodoItem, TodoStatus, ToolCall}; +use roboco_proto::{AgentErrorKind, AgentEvent, DoneStatus, TodoItem, TodoStatus, ToolCall}; use serde_json::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -122,6 +122,114 @@ pub(crate) fn turn_error_message(params: &Value) -> Option { }) } +/// The error object a turn-level error notification carries: `turn.error` +/// on turn/completed|failed, the bare `error` on the top-level `error` +/// notification. +fn turn_error_object(params: &Value) -> Option<&Value> { + params + .get("turn") + .and_then(|t| t.get("error")) + .filter(|e| !e.is_null()) + .or_else(|| params.get("error").filter(|e| !e.is_null())) +} + +/// The app-server's structured turn-error code (`codexErrorInfo`, both +/// spellings it has shipped): `"usageLimitExceeded"`, `"rateLimitExceeded"`, +/// `"unauthorized"`, … — absent on older builds and plain failures. +pub(crate) fn turn_error_code(params: &Value) -> Option<&str> { + let error = turn_error_object(params)?; + field(error, &["codexErrorInfo", "codex_error_info"]).and_then(Value::as_str) +} + +/// The latest `account/rateLimits/updated` snapshot reduced to the window it +/// reports as exhausted: `primary` is the ~5-hour window, `secondary` the +/// weekly one (the snapshot's own `windowDurationMins` picks the label). +/// When both are exhausted the LATER reset is the binding constraint. +/// `resets_at` arrives as unix seconds; the kind carries epoch millis. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RateLimitReset { + pub window: String, + /// Epoch millis. + pub resets_at: i64, +} + +/// Which claude.ai-style window label a Codex window duration gets, so both +/// drivers speak the same window vocabulary. +fn window_label(minutes: i64) -> &'static str { + match minutes { + 300 => "5-hour", + 10_080 => "weekly", + _ => "usage", + } +} + +/// `params.rateLimits` → the exhausted window's label and reset, when the +/// snapshot says one is exhausted. Both camelCase and snake_case field +/// spellings, and missing windows, are tolerated. +pub(crate) fn rate_limit_reset(params: &Value) -> Option { + let limits = field(params, &["rateLimits", "rate_limits"])?; + let mut binding: Option = None; + for (name, default_minutes) in [("primary", 300i64), ("secondary", 10_080)] { + let Some(window) = field(limits, &[name]) else { continue }; + let exhausted = field(window, &["usedPercent", "used_percent"]) + .and_then(Value::as_f64) + .unwrap_or(0.0) + >= 100.0; + if !exhausted { + continue; + } + let Some(resets_at) = field(window, &["resetsAt", "resets_at"]).and_then(Value::as_i64) + else { + continue; + }; + let minutes = field(window, &["windowDurationMins", "window_duration_mins"]) + .and_then(Value::as_i64) + .unwrap_or(default_minutes); + let candidate = RateLimitReset { + window: window_label(minutes).into(), + resets_at: resets_at.saturating_mul(1000), + }; + if binding + .as_ref() + .map_or(true, |b| candidate.resets_at > b.resets_at) + { + binding = Some(candidate); + } + } + binding +} + +/// Map a structured turn-error code onto the structured error kind: usage +/// limits (turn-level `usageLimitExceeded`/`rateLimitExceeded`) become a +/// usage limit carrying the latest known reset; auth failures +/// (`unauthorized`) become signed out. Everything else stays unclassified. +pub(crate) fn error_kind(code: &str, limit: Option<&RateLimitReset>) -> Option { + match code { + "usageLimitExceeded" | "usage_limit_exceeded" | "rateLimitExceeded" + | "rate_limit_exceeded" => Some(match limit { + Some(reset) => AgentErrorKind::UsageLimit { + window: reset.window.clone(), + resets_at: Some(reset.resets_at), + }, + None => AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }, + }), + "unauthorized" => Some(AgentErrorKind::SignedOut), + _ => None, + } +} + +/// The classification for a turn-level error notification: the code it +/// carries, mapped with the latest rate-limit snapshot when one arrived. +pub(crate) fn turn_error_kind( + params: &Value, + limit: Option<&RateLimitReset>, +) -> Option { + turn_error_code(params).and_then(|code| error_kind(code, limit)) +} + /// `thread/tokenUsage/updated` → a [`AgentEvent::Usage`] snapshot of the LAST /// turn's tokens (held by the session loop, emitted before `Done`). pub(crate) fn usage_event(params: &Value) -> Option { @@ -309,6 +417,9 @@ pub(crate) fn map_item(phase: Phase, item: &Value) -> Vec { if let Some(message) = error { events.push(AgentEvent::Error { message: message.into(), + // Image-generation quota is item-scoped, not a turn-level + // plan limit: stays unclassified. + kind: None, }); } else { let name = std::path::Path::new(&path) @@ -419,6 +530,8 @@ pub(crate) fn map_item(phase: Phase, item: &Value) -> Vec { } "error" => vec![AgentEvent::Error { message: str_field(item, &["message"]), + // Item-scoped transcript error: not a turn-level plan limit. + kind: None, }], "collabAgentToolCall" | "collab_agent_tool_call" => { let tool = str_field(item, &["tool"]); @@ -643,6 +756,10 @@ impl ChildStream { .or_else(|| params.get("message").and_then(Value::as_str)) .unwrap_or("Codex subagent error") .to_owned(), + // A subagent's failure carries the same code shape, but the + // account-wide rate-limit snapshot lives in the driver loop — + // classified here without a reset time. + kind: turn_error_kind(params, None), }], _ => Vec::new(), } @@ -1081,6 +1198,139 @@ mod tests { assert_eq!(turn_error_message(&json!({"turn": {"id": "t"}})), None); assert_eq!(turn_error_message(&json!({"turn": {"error": null}})), None); } + + #[test] + fn turn_error_code_reads_turn_and_bare_notification_shapes() { + // turn/failed|completed: under `turn.error`, both spellings. + assert_eq!( + turn_error_code(&json!({"turn": {"error": { + "message": "usage limit", "codexErrorInfo": "usageLimitExceeded" + }}})), + Some("usageLimitExceeded") + ); + assert_eq!( + turn_error_code(&json!({"turn": {"error": { + "message": "usage limit", "codex_error_info": "rate_limit_exceeded" + }}})), + Some("rate_limit_exceeded") + ); + // The bare `error` notification: under `error`. + assert_eq!( + turn_error_code(&json!({"error": { + "message": "unauthorized", "codexErrorInfo": "unauthorized" + }})), + Some("unauthorized") + ); + // Plain failures and older builds carry no code. + assert_eq!(turn_error_code(&json!({"turn": {"error": {"message": "boom"}}})), None); + assert_eq!(turn_error_code(&json!({"message": "flat"})), None); + } + + #[test] + fn error_codes_classify_limits_and_auth() { + let snapshot = RateLimitReset { + window: "weekly".into(), + resets_at: 1_762_675_400_000, + }; + // Both limit codes, both spellings, with and without a snapshot. + for code in [ + "usageLimitExceeded", + "usage_limit_exceeded", + "rateLimitExceeded", + "rate_limit_exceeded", + ] { + assert_eq!( + error_kind(code, Some(&snapshot)), + Some(AgentErrorKind::UsageLimit { + window: "weekly".into(), + resets_at: Some(1_762_675_400_000), + }), + "{code}" + ); + assert_eq!( + error_kind(code, None), + Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + "{code} without a snapshot" + ); + } + assert_eq!(error_kind("unauthorized", None), Some(AgentErrorKind::SignedOut)); + assert_eq!(error_kind("unauthorized", Some(&snapshot)), Some(AgentErrorKind::SignedOut)); + // Everything else stays unclassified. + for code in ["serverOverloaded", "streamError", ""] { + assert_eq!(error_kind(code, Some(&snapshot)), None, "{code}"); + } + } + + #[test] + fn rate_limit_reset_picks_the_exhausted_windows_binding_reset() { + // Primary exhausted: the 5-hour window, seconds → millis. + assert_eq!( + rate_limit_reset(&json!({"rateLimits": { + "primary": {"usedPercent": 100, "windowDurationMins": 300, "resetsAt": 1735689600}, + "secondary": {"usedPercent": 61, "windowDurationMins": 10080, "resetsAt": 1736208000} + }})), + Some(RateLimitReset { window: "5-hour".into(), resets_at: 1_735_689_600_000 }) + ); + // Both exhausted: the later reset binds (the weekly window). + assert_eq!( + rate_limit_reset(&json!({"rateLimits": { + "primary": {"usedPercent": 100, "windowDurationMins": 300, "resetsAt": 1735689600}, + "secondary": {"usedPercent": 100, "windowDurationMins": 10080, "resetsAt": 1736208000} + }})), + Some(RateLimitReset { window: "weekly".into(), resets_at: 1_736_208_000_000 }) + ); + // Only the weekly window is reported/exhausted; the label follows + // the snapshot's own duration, and snake_case is tolerated. + assert_eq!( + rate_limit_reset(&json!({"rate_limits": { + "secondary": {"used_percent": 100, "window_duration_mins": 10080, "resets_at": 1736208000} + }})), + Some(RateLimitReset { window: "weekly".into(), resets_at: 1_736_208_000_000 }) + ); + // Nothing exhausted, or no snapshot at all: no reset to report. + assert_eq!( + rate_limit_reset(&json!({"rateLimits": { + "primary": {"usedPercent": 42, "windowDurationMins": 300, "resetsAt": 1735689600}, + "secondary": {"usedPercent": 61, "windowDurationMins": 10080, "resetsAt": 1736208000} + }})), + None + ); + assert_eq!(rate_limit_reset(&json!({"message": "x"})), None); + // An exhausted window whose reset the snapshot omits is skipped, not + // fatal; an unknown duration falls back to the usage label. + assert_eq!( + rate_limit_reset(&json!({"rateLimits": { + "primary": {"usedPercent": 100}, + "secondary": {"usedPercent": 100, "resetsAt": 1736208000} + }})), + Some(RateLimitReset { window: "usage".into(), resets_at: 1_736_208_000_000 }) + ); + } + + #[test] + fn subagent_error_notifications_carry_the_classification() { + let mut feed = ChildStream::default(); + let events = feed.map( + "child", + "error", + &json!({"threadId": "child", "error": { + "message": "You've hit your usage limit", "codexErrorInfo": "usageLimitExceeded" + }}), + ); + assert_eq!( + events, + vec![AgentEvent::Error { + message: "You've hit your usage limit".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + }] + ); + } } #[cfg(test)] @@ -1194,7 +1444,8 @@ mod generated_image_tests { assert_eq!( events[2], AgentEvent::Error { - message: expected.into() + message: expected.into(), + kind: None } ); } diff --git a/crates/harness/src/mock.rs b/crates/harness/src/mock.rs index c86c55ea29..7f1b991a38 100644 --- a/crates/harness/src/mock.rs +++ b/crates/harness/src/mock.rs @@ -5,8 +5,8 @@ use futures::StreamExt; use futures::stream::BoxStream; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SteeringMode, - UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, + SteeringMode, UserInputQuestion, }; use crate::{Harness, HarnessError, RunControls}; @@ -192,6 +192,13 @@ impl Harness for MockHarness { let (body, tail) = self.script.split_at(done_ix); let error_event = mock_error.then(|| AgentEvent::Error { message: "Claude usage limit reached — try again after the limit resets.".into(), + // Same classification the Claude driver attaches to its + // `rate_limit` error code, so engine tests can drive Limited + // state without a real CLI. + kind: Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), }); // Dev/testing knob: `ROBOCO_MOCK_CODE=1` appends rust + ts code blocks // (keywords, strings, numbers, comments) plus inline code — for diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index d4f3965b91..ba0e4d902b 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -1708,6 +1708,7 @@ async fn run_session(session: Session) { &event_tx, AgentEvent::Error { message: e.to_string(), + kind: None, }, ) .await; @@ -1852,6 +1853,7 @@ async fn run_session(session: Session) { Err(e) => { let _ = send(&event_tx, AgentEvent::Error { message: e.to_string(), + kind: None, }).await; turn.error = Some(e.to_string()); turn.aborted_for_retry = true; @@ -2031,6 +2033,7 @@ async fn run_session(session: Session) { &event_tx, AgentEvent::Error { message: failure.message.clone(), + kind: None, }, ) .await; @@ -2108,6 +2111,7 @@ async fn run_session(session: Session) { &event_tx, AgentEvent::Error { message: message.clone(), + kind: None, }, ) .await; @@ -2161,7 +2165,7 @@ async fn run_session(session: Session) { "opencode made no progress for {}s after the prompt. {STALL_HINT}", stall.unwrap_or(DEFAULT_STALL_BOUND).as_secs() ); - let _ = send(&event_tx, AgentEvent::Error { message: message.clone() }).await; + let _ = send(&event_tx, AgentEvent::Error { message: message.clone(), kind: None }).await; let _ = server.abort_session(&session_id, dir).await; settle_children(&mut children, &event_tx, DoneStatus::Interrupted).await; let _ = send(&event_tx, AgentEvent::Done { @@ -2223,7 +2227,7 @@ async fn run_session(session: Session) { &server.stderr_tail, ); if turn.active { - let _ = send(&event_tx, AgentEvent::Error { message: message.clone() }).await; + let _ = send(&event_tx, AgentEvent::Error { message: message.clone(), kind: None }).await; } settle_children(&mut children, &event_tx, DoneStatus::Interrupted).await; let _ = send(&event_tx, AgentEvent::Done { @@ -2890,7 +2894,7 @@ async fn handle_bus_event(ctx: BusCtx<'_>) -> BusOutcome { let msg = format!( "Giving up after {attempt} provider retries: {message}. {STALL_HINT}" ); - if !send(event_tx, AgentEvent::Error { message: msg }).await { + if !send(event_tx, AgentEvent::Error { message: msg, kind: None }).await { return BusOutcome::ConsumerGone; } let _ = server.abort_session(session_id, dir).await; @@ -2900,7 +2904,7 @@ async fn handle_bus_event(ctx: BusCtx<'_>) -> BusOutcome { "The provider is failing and opencode is retrying (attempt \ {attempt}): {message}" ); - if !send(event_tx, AgentEvent::Error { message: msg }).await { + if !send(event_tx, AgentEvent::Error { message: msg, kind: None }).await { return BusOutcome::ConsumerGone; } } @@ -2945,7 +2949,7 @@ async fn handle_bus_event(ctx: BusCtx<'_>) -> BusOutcome { if kind == "session.error" { turn.error = Some(message.clone()); } - if !duplicate && !send(event_tx, AgentEvent::Error { message }).await { + if !duplicate && !send(event_tx, AgentEvent::Error { message, kind: None }).await { return BusOutcome::ConsumerGone; } BusOutcome::Continue diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index 4025389658..477d18ed4e 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -474,7 +474,7 @@ async fn native_command_http_failures_settle_the_current_turn() { let mut surfaced = None; loop { match wire.events.recv().await.unwrap().unwrap() { - AgentEvent::Error { message } => surfaced = Some(message), + AgentEvent::Error { message, .. } => surfaced = Some(message), AgentEvent::Done { status, error, .. } => { return (status, error.or(surfaced)); } @@ -759,7 +759,7 @@ async fn v2_execution_failure_and_interrupt_settle_the_turn() { let mut error = None; loop { match wire.events.recv().await.unwrap().unwrap() { - AgentEvent::Error { message } => error = Some(message), + AgentEvent::Error { message, .. } => error = Some(message), AgentEvent::Done { status, error: e, .. } => return (status, e.or(error)), diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index e6ff760a69..aab22b17ea 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -871,6 +871,7 @@ async fn run_session(session: Session) { without the previous context", request.resume.as_deref().unwrap_or_default() ), + kind: None, }, ) .await; @@ -1062,7 +1063,7 @@ async fn run_session(session: Session) { Err(e) => { let _ = send( &event_tx, - AgentEvent::Error { message: format!("Steering failed: {e}") }, + AgentEvent::Error { message: format!("Steering failed: {e}"), kind: None }, ) .await; } @@ -1128,6 +1129,7 @@ async fn run_session(session: Session) { if let Some(error) = message_end_error(&event) { let _ = send(&event_tx, AgentEvent::Error { message: error.clone(), + kind: None, }) .await; turn_error = Some(error); @@ -1206,6 +1208,7 @@ async fn run_session(session: Session) { "pi hit a transient error and is retrying \ (attempt {attempt}/{max}): {message}" ), + kind: None, }, ) .await; diff --git a/crates/harness/tests/acp_stall.rs b/crates/harness/tests/acp_stall.rs index b9526c1037..a6b13c2a81 100644 --- a/crates/harness/tests/acp_stall.rs +++ b/crates/harness/tests/acp_stall.rs @@ -73,7 +73,7 @@ async fn silent_agent_errors_via_the_prompt_stall_watchdog() { let error = events .iter() .find_map(|e| match e { - AgentEvent::Error { message } => Some(message.clone()), + AgentEvent::Error { message, .. } => Some(message.clone()), _ => None, }) .expect("visible error chip"); diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index c60ee893d2..51ea162952 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -14,8 +14,8 @@ use tokio::sync::{mpsc, oneshot}; use roboco_harness::{ CancellationToken, ClaudeHarness, Harness, HarnessError, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, RunRequest, SandboxLevel, ToolCall, UserInputAnswer, - UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, SandboxLevel, ToolCall, + UserInputAnswer, UserInputQuestion, }; fn fixture_path() -> PathBuf { @@ -484,7 +484,7 @@ async fn error_codes_map_to_readable_messages() { let errors: Vec<&str> = events .iter() .filter_map(|e| match e { - AgentEvent::Error { message } => Some(message.as_str()), + AgentEvent::Error { message, .. } => Some(message.as_str()), _ => None, }) .collect(); @@ -499,6 +499,30 @@ async fn error_codes_map_to_readable_messages() { "rejected rate_limit_event not mapped: {errors:?}" ); + // Both limit surfaces carry kind usage-limit: the blocking event with + // the window and the CLI's own reset time, the terse assistant code + // without either. + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, kind: Some(AgentErrorKind::UsageLimit { window, resets_at }) } + if message == "Claude 5-hour limit reached — the turn was blocked. Try again after it resets." + && window == "5-hour" + && *resets_at == Some(1_762_675_400_000) + )), + "blocking limit event not classified: {events:?}" + ); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, kind: Some(AgentErrorKind::UsageLimit { window, resets_at }) } + if message == "Claude usage limit reached — try again after the limit resets." + && window == "usage" + && resets_at.is_none() + )), + "assistant rate_limit code not classified: {events:?}" + ); + // Empty `errors` array on the result falls back to subtype wording. assert_eq!( events.last(), @@ -511,6 +535,33 @@ async fn error_codes_map_to_readable_messages() { ); } +#[tokio::test] +async fn an_authentication_failure_is_signed_out() { + let (controls, _steer, _token) = controls("A"); + let events = run_to_end(&harness(), request("scenario:auth"), controls).await; + + // Exactly one error: the message stays human-readable, and the kind says + // sign in again — distinct from waiting out a usage limit. + assert_eq!( + events + .iter() + .filter_map(|e| match e { + AgentEvent::Error { message, kind } => Some((message.clone(), kind.clone())), + _ => None, + }) + .collect::>(), + vec![( + "Authentication failed — sign in to Claude again.".to_owned(), + Some(AgentErrorKind::SignedOut), + )], + "exactly one error, and it is signed-out: {events:?}" + ); + assert!(matches!( + events.last(), + Some(AgentEvent::Done { status: DoneStatus::Errored, .. }) + )); +} + #[tokio::test] async fn missing_binary_is_not_installed() { let harness = ClaudeHarness::new().with_executable("/nonexistent/claude-nowhere"); diff --git a/crates/harness/tests/codex.rs b/crates/harness/tests/codex.rs index df7885409f..6487196ce7 100644 --- a/crates/harness/tests/codex.rs +++ b/crates/harness/tests/codex.rs @@ -13,8 +13,8 @@ use tokio::sync::{mpsc, oneshot}; use roboco_harness::{ CancellationToken, CodexHarness, Harness, HarnessError, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, TodoItem, - TodoStatus, ToolCall, UserInputAnswer, UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, ReasoningLevel, RunRequest, SandboxLevel, + TodoItem, TodoStatus, ToolCall, UserInputAnswer, UserInputQuestion, }; fn fixture_path() -> PathBuf { @@ -615,6 +615,124 @@ async fn turn_failed_maps_to_errored_done() { session_id: Some("th-1".into()), }) ); + // An unclassified failure keeps its historical shape: the text rides + // the Done and no Error event (and no kind) is minted for it. + assert!( + !events.iter().any(|e| matches!(e, AgentEvent::Error { .. })), + "unclassified failure minted an error event: {events:?}" + ); +} + +#[tokio::test] +async fn a_usage_limited_turn_carries_the_reset_from_the_latest_notification() { + let (controls, _steer, _token) = controls("Yes"); + let events = run_to_end(&harness(), request("scenario:usage-limit"), controls).await; + + // The turn-level usage-limit error classifies as a usage limit and + // carries the reset time of the exhausted window from the rate-limit + // notification that preceded it (seconds on the wire, millis in the + // kind). + assert_eq!( + events + .iter() + .filter_map(|e| match e { + AgentEvent::Error { message, kind } => Some((message.clone(), kind.clone())), + _ => None, + }) + .collect::>(), + vec![ + ( + "You have exceeded your usage limit".to_owned(), + Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_735_689_600_000), + }), + ) + ], + "one classified error: {events:?}" + ); + // The failure text rides the Error event; the Done keeps the errored + // status without duplicating the text. + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: None, + session_id: Some("th-1".into()), + }) + ); +} + +#[tokio::test] +async fn a_rate_limited_turn_completing_with_the_error_also_classifies() { + let (controls, _steer, _token) = controls("Yes"); + let events = run_to_end(&harness(), request("scenario:rate-limit-completed"), controls).await; + + // turn/completed (rather than turn/failed) carrying a rate-limit error: + // the weekly window is the exhausted one, so its reset is reported. + assert_eq!( + events + .iter() + .filter_map(|e| match e { + AgentEvent::Error { message, kind } => Some((message.clone(), kind.clone())), + _ => None, + }) + .collect::>(), + vec![ + ( + "rate limited, retry later".to_owned(), + Some(AgentErrorKind::UsageLimit { + window: "weekly".into(), + resets_at: Some(1_736_208_000_000), + }), + ) + ], + "one classified error: {events:?}" + ); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: None, + session_id: Some("th-1".into()), + }) + ); +} + +#[tokio::test] +async fn an_auth_failure_is_signed_out() { + let (controls, _steer, _token) = controls("Yes"); + let events = run_to_end(&harness(), request("scenario:auth-fail"), controls).await; + + // A dead login is signed out — distinct from a usage limit, so the user + // knows to sign in rather than wait for a reset. + assert_eq!( + events + .iter() + .filter_map(|e| match e { + AgentEvent::Error { message, kind } => Some((message.clone(), kind.clone())), + _ => None, + }) + .collect::>(), + vec![ + ( + "Token is expired or invalid".to_owned(), + Some(AgentErrorKind::SignedOut), + ) + ], + "one classified error: {events:?}" + ); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: None, + session_id: Some("th-1".into()), + }) + ); } #[tokio::test] @@ -1567,7 +1685,7 @@ async fn ordinary_followup_cannot_overtake_a_queued_native_command() { assert_eq!(status, DoneStatus::Completed, "{error:?}"); events.push("done".into()); } - AgentEvent::Error { message } => panic!("{message}"), + AgentEvent::Error { message, .. } => panic!("{message}"), _ => {} } } diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 8a85d74821..f7dc25e769 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -357,12 +357,20 @@ case "$first" in emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-err"}' # Terse assistant-level error code with no content. emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}' - # Hard-rejected claude.ai usage window. - emit '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour"}}' + # Hard-rejected claude.ai usage window, with the CLI's own reset time + # (unix millis). + emit '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400000}}' # Result error with an EMPTY errors array: needs fallback wording. emit '{"type":"result","subtype":"error_max_turns","errors":[],"usage":{"input_tokens":1,"output_tokens":2},"session_id":"sess-err"}' ;; +*scenario:auth*) + # A turn killed by an expired login: signed out, not a limit. + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-auth"}' + emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"authentication_failed"}' + emit '{"type":"result","subtype":"error_during_execution","errors":[],"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-auth"}' + ;; + *) emit '{"type":"result","subtype":"error_during_execution","errors":["unknown scenario"],"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-x"}' ;; diff --git a/crates/harness/tests/fixtures/fake-codex.sh b/crates/harness/tests/fixtures/fake-codex.sh index d61a9fc88b..0ec96c0ab8 100755 --- a/crates/harness/tests/fixtures/fake-codex.sh +++ b/crates/harness/tests/fixtures/fake-codex.sh @@ -513,6 +513,32 @@ case "$turnline" in emit '{"method":"turn/failed","params":{"turn":{"id":"t-1","error":{"message":"boom"}}}}' ;; +*scenario:usage-limit*) + # A turn killed by the account's usage limit. The rate-limit snapshot + # (pushed before the failure) carries the reset the error itself omits; + # the 5-hour window is exhausted, the weekly one is not. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' + emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"primary":{"usedPercent":100,"windowDurationMins":300,"resetsAt":1735689600},"secondary":{"usedPercent":61,"windowDurationMins":10080,"resetsAt":1736208000}}}}' + emit '{"method":"turn/failed","params":{"turn":{"id":"t-1","error":{"message":"You have exceeded your usage limit","codexErrorInfo":"usageLimitExceeded"}}}}' + ;; + +*scenario:rate-limit-completed*) + # Same classification on turn/completed (status failed + error), with the + # weekly window as the exhausted one and no primary window reported. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' + emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"secondary":{"usedPercent":100,"windowDurationMins":10080,"resetsAt":1736208000}}}}' + emit '{"method":"turn/completed","params":{"turn":{"id":"t-1","status":"failed","error":{"message":"rate limited, retry later","codexErrorInfo":"rateLimitExceeded"}}}}' + ;; + +*scenario:auth-fail*) + # A turn killed by an expired login: signed out, not a limit. + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" + emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' + emit '{"method":"turn/failed","params":{"turn":{"id":"t-1","error":{"message":"Token is expired or invalid","codexErrorInfo":"unauthorized"}}}}' + ;; + *scenario:resumed-child*) emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-2\"}}}" emit '{"method":"turn/started","params":{"threadId":"child-alpha","turn":{"id":"alpha-resumed"}}}' diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index 2c229736a2..348ecd9d3a 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -921,7 +921,7 @@ async fn provider_retries_surface_and_cap_out() { fake.emit(retry(1)); fake.emit(retry(3)); let ev = next_event(&mut stream).await; - let AgentEvent::Error { message } = &ev else { + let AgentEvent::Error { message, .. } = &ev else { panic!("expected a retry error chip, got {ev:?}"); }; assert!( @@ -932,7 +932,7 @@ async fn provider_retries_surface_and_cap_out() { fake.emit(retry(8)); let ev = next_event(&mut stream).await; - let AgentEvent::Error { message } = &ev else { + let AgentEvent::Error { message, .. } = &ev else { panic!("expected the give-up chip, got {ev:?}"); }; assert!(message.contains("Giving up"), "{message}"); @@ -971,7 +971,7 @@ async fn session_error_with_no_content_settles_errored() { let ev = next_event(&mut stream).await; assert!(matches!( &ev, - AgentEvent::Error { message } if message.contains("no credentials") + AgentEvent::Error { message, .. } if message.contains("no credentials") )); // opencode re-emits the same failure with an exception-name prefix and a // stack — that must NOT mint a second chip (field report: every failure diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 4cb5332944..7efd1e6693 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -496,7 +496,7 @@ async fn errored_assistant_message_rejects_the_prompt() { // The failed message also surfaces as a visible error chip. assert!(events.iter().any(|e| matches!( e, - AgentEvent::Error { message } if message == "provider returned 529" + AgentEvent::Error { message, .. } if message == "provider returned 529" ))); } @@ -515,7 +515,7 @@ async fn exhausted_auto_retry_rejects_the_prompt_and_recovery_completes() { // Retry status surfaces as a visible notice. assert!(events.iter().any(|e| matches!( e, - AgentEvent::Error { message } if message.contains("attempt 1/3") + AgentEvent::Error { message, .. } if message.contains("attempt 1/3") ))); let (controls, _steer, _token) = controls(); @@ -592,7 +592,7 @@ async fn lost_resume_pointer_announces_fresh_context() { assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)]); assert!(events.iter().any(|e| matches!( e, - AgentEvent::Error { message } if message.contains("without the previous context") + AgentEvent::Error { message, .. } if message.contains("without the previous context") ))); assert_eq!(text_of(&events), "reply:fresh"); } diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index b743f02728..fce877633e 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -545,6 +545,31 @@ pub enum DoneStatus { Errored, } +/// Structured classification for an [`AgentEvent::Error`], so engines and +/// clients can act on usage limits and sign-outs instead of parsing the +/// error text. The `message` stays the rendering source of truth; a `None` +/// kind means the emitting harness could not classify the failure. +/// +/// Wire names are frozen (ADR 0005): this whole type is additive — older +/// clients ignore the `kind` field and keep rendering the message. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum AgentErrorKind { + /// A harness account's plan usage limit blocked the turn. The window is + /// the harness's own label ("5-hour", "weekly", …); `resets_at` is the + /// epoch-millis instant the harness reported, when it reported one. + #[serde(rename_all = "camelCase")] + UsageLimit { + window: String, + /// Epoch millis when the window resets, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + resets_at: Option, + }, + /// The harness's login is gone — the user must sign in again, not wait + /// for a limit to reset. + SignedOut, +} + /// The normalized streaming event every harness emits. /// /// Mirrors roboco's `AgentEvent` tagged enum. @@ -620,6 +645,11 @@ pub enum AgentEvent { }, Error { message: String, + /// Structured classification (usage limit, signed out). Additive on + /// the wire (ADR 0005): `None` (and a missing key) for every + /// unclassified error, so older payloads and clients are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + kind: Option, }, #[serde(rename_all = "camelCase")] InputRequested { @@ -740,6 +770,64 @@ mod tests { assert_eq!(serde_json::from_str::(&json).unwrap(), ev); } + /// The structured error kind is additive on the wire (ADR 0005): new + /// payloads carry it under stable camelCase names, unclassified errors + /// serialize exactly as before, and legacy payloads without it parse. + #[test] + fn error_kind_is_additive_on_the_wire() { + let limited = AgentEvent::Error { + message: "Claude 5-hour limit reached — the turn was blocked. Try again after it resets.".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + }; + let json = serde_json::to_value(&limited).unwrap(); + assert_eq!(json["type"], "error"); + assert_eq!(json["kind"]["type"], "usageLimit"); + assert_eq!(json["kind"]["window"], "5-hour"); + assert_eq!(json["kind"]["resetsAt"], 1_762_675_400_000); + assert_eq!(serde_json::from_value::(json).unwrap(), limited); + + let signed_out = AgentEvent::Error { + message: "Authentication failed — sign in to Claude again.".into(), + kind: Some(AgentErrorKind::SignedOut), + }; + let json = serde_json::to_value(&signed_out).unwrap(); + assert_eq!(json["kind"], serde_json::json!({"type": "signedOut"})); + assert_eq!(serde_json::from_value::(json).unwrap(), signed_out); + + // An unknown reset time is omitted, not null. + let unknown_reset = AgentEvent::Error { + message: "limit".into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + }; + let json = serde_json::to_value(&unknown_reset).unwrap(); + assert!(json["kind"].get("resetsAt").is_none()); + + // Unclassified errors serialize byte-identically to the old shape … + let plain = serde_json::to_value(AgentEvent::Error { + message: "boom".into(), + kind: None, + }) + .unwrap(); + assert_eq!(plain, serde_json::json!({"type": "error", "message": "boom"})); + // … and payloads from before the field exist still parse. + let legacy = + serde_json::from_value::(serde_json::json!({"type": "error", "message": "boom"})) + .unwrap(); + assert_eq!( + legacy, + AgentEvent::Error { + message: "boom".into(), + kind: None + } + ); + } + /// Drivers spell the key differently; the chip must not care which one /// spawned the child. Non-spawns never answer, whatever they carry. #[test] diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index dfab1ab9fb..ea080aee6d 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -233,6 +233,7 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::UserInputQuestion, roboco_proto::UserInputAnswer, roboco_proto::DoneStatus, + roboco_proto::AgentErrorKind, roboco_proto::AgentEvent, roboco_proto::ContextUsage, // invocation.rs diff --git a/web/packages/proto/src/generated/AgentErrorKind.ts b/web/packages/proto/src/generated/AgentErrorKind.ts new file mode 100644 index 0000000000..2d79078234 --- /dev/null +++ b/web/packages/proto/src/generated/AgentErrorKind.ts @@ -0,0 +1,16 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * Structured classification for an [`AgentEvent::Error`], so engines and + * clients can act on usage limits and sign-outs instead of parsing the + * error text. The `message` stays the rendering source of truth; a `None` + * kind means the emitting harness could not classify the failure. + * + * Wire names are frozen (ADR 0005): this whole type is additive — older + * clients ignore the `kind` field and keep rendering the message. + */ +export type AgentErrorKind = { "type": "usageLimit", window: string, +/** + * Epoch millis when the window resets, when known. + */ +resetsAt?: number | null, } | { "type": "signedOut" }; diff --git a/web/packages/proto/src/generated/AgentEvent.ts b/web/packages/proto/src/generated/AgentEvent.ts index 03804ee644..15106b1ff7 100644 --- a/web/packages/proto/src/generated/AgentEvent.ts +++ b/web/packages/proto/src/generated/AgentEvent.ts @@ -1,5 +1,6 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +import type { AgentErrorKind } from "./AgentErrorKind"; import type { DoneStatus } from "./DoneStatus"; import type { HarnessId } from "./HarnessId"; import type { SlashCommand } from "./SlashCommand"; @@ -26,4 +27,10 @@ output?: string | null, /** * Inline file diff for edit-shaped tools (ACP `Diff` content). */ -diff?: ToolDiff | null, } | { "type": "contextUsage", tokens: number | null, window: number | null, } | { "type": "usage", inputTokens: number, outputTokens: number, } | { "type": "availableCommands", commands: Array, } | { "type": "error", message: string, } | { "type": "inputRequested", requestId: string, questions: Array, } | { "type": "inputResolved", requestId: string, } | { "type": "steered", assistantMessageId: string | null, nextAssistantMessageId: string | null, } | { "type": "done", status: DoneStatus, result: string | null, error: string | null, sessionId: string | null, } | { "type": "userMessage", text: string, } | { "type": "subagent", parentToolUseId: string, event: AgentEvent, }; +diff?: ToolDiff | null, } | { "type": "contextUsage", tokens: number | null, window: number | null, } | { "type": "usage", inputTokens: number, outputTokens: number, } | { "type": "availableCommands", commands: Array, } | { "type": "error", message: string, +/** + * Structured classification (usage limit, signed out). Additive on + * the wire (ADR 0005): `None` (and a missing key) for every + * unclassified error, so older payloads and clients are unchanged. + */ +kind?: AgentErrorKind | null, } | { "type": "inputRequested", requestId: string, questions: Array, } | { "type": "inputResolved", requestId: string, } | { "type": "steered", assistantMessageId: string | null, nextAssistantMessageId: string | null, } | { "type": "done", status: DoneStatus, result: string | null, error: string | null, sessionId: string | null, } | { "type": "userMessage", text: string, } | { "type": "subagent", parentToolUseId: string, event: AgentEvent, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 1774ec3248..df36e68277 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -4,6 +4,7 @@ export * from "./AgentAccount"; export * from "./AgentAccountWarning"; export * from "./AgentAccountsSnapshot"; export * from "./AgentAuthKind"; +export * from "./AgentErrorKind"; export * from "./AgentEvent"; export * from "./AgentLoginMode"; export * from "./AgentLoginPoll"; From c16093d60e7f446bc84763b6e534828f52731cf3 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 11:58:53 +0700 Subject: [PATCH 04/96] feat(pi): deliver prompts safely - dispositions, rejections, settle probe Orchestration v2 ticket 02 (.scratch/t3-orchestration-v2/issues/02): make the Pi harness prompt path robust. Pi stays a turn-boundary harness here; ticket 03 flips that. - Every prompt - turn starts and mid-run steers alike - goes out as `prompt` with streamingBehavior "steer": pi starts a run when idle and queues the prompt when an extension run is streaming, atomically against settlement. The bare `steer` command and bare `prompt` are no longer used. - The acknowledgement is read by disposition: started (a run began), queued (delivered at its user message_start echo - the consumed signal), handled (no run; the turn ends once pi reports it is not streaming, and any pending delivery record for it is retired). pi older than 0.99 reports none: inferred from whether a run starts (the settle probe). - A message counts as delivered only after pi accepts it: a rejected prompt never ends the session. The message returns to the driver queue (the engine at-least-once ledger keeps its entry until the Steered boundary), the rejection surfaces, and the message re-delivers once the turn ends - with a bounded retry rate (one immediate at-rest re-delivery per acceptance cycle, then the probe path and its re-arm delay). - No fixed acceptance timeout kills the run: pi preflight (extensions, the Roboco MCP bridge, compaction) may take arbitrarily long; Stop still interrupts it (one Interrupted Done, never an error-then-interrupted pair). - Turn end is agent_settled, then a state probe requiring not streaming, not compacting and zero pending messages, guarded by a generation counter bumped by agent_start, compaction_start, every prompt or steer (send and acknowledgement) and every settle. The probe re-arms after every prompt acknowledgement racing it and after any busy report; a stranded steer is recovered through clear_queue; a probe that keeps failing ends the turn errored - never a silent kill, with the failure counter reset for the next turn (T3 bugs 16846/19983 not copied). Tests (fake-pi-rpc.sh prior art): handled, rejected-while-streaming, slow preflight (31 s), stop-interrupts-slow-preflight, settle race, busy probe re-arm, probe failure, old-pi dispositionless inference. Not run locally (CI policy). --- crates/harness/src/pi/mod.rs | 968 +++++++++++++------ crates/harness/tests/fixtures/fake-pi-rpc.sh | 151 ++- crates/harness/tests/pi.rs | 241 +++++ docs/PARITY.md | 2 +- 4 files changed, 1074 insertions(+), 288 deletions(-) diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index e6ff760a69..5bc78348a5 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -20,13 +20,26 @@ //! `get_state.sessionFile` (a bare id can hit pi's global session search, //! which prompts interactively on stdin — the protocol channel). The //! resume pointer rides `SessionStarted/Done.session_id`. -//! - **Prompting**: `prompt` while idle (response = preflight acceptance); -//! a mid-run steer goes through pi's `steer` queue (delivered after the -//! current assistant turn's tool calls); `abort` interrupts, waiting for -//! idle before responding. `follow_up` stays unused: a parked session's -//! next message is a fresh `prompt` in roboco's model. -//! - **Turn settlement**: `agent_settled` is the authoritative end — every -//! turn shape ends with a deterministic `Done` (the engine retires its +//! - **Prompting**: every prompt — turn starts and mid-run steers alike — +//! goes out as `prompt` with `streamingBehavior: "steer"`: pi starts a +//! run when idle and queues the prompt when an extension run is +//! streaming, atomically against settlement, so a prompt can never strand +//! against the end of a run. The acknowledgement's `disposition` reports +//! what became of it (`started` / `queued` / `handled`; pi < 0.99 reports +//! none and the driver infers from whether a run starts). The acceptance +//! is unbounded: pi's preflight (extensions, the Roboco MCP bridge, +//! compaction) may take arbitrarily long, and only Stop interrupts it. A +//! rejected prompt never ends the session — the message returns to the +//! driver queue and the rejection surfaces. `abort` interrupts, waiting +//! for idle before responding. `follow_up` stays unused: a parked +//! session's next message is a fresh prompt in roboco's model. +//! - **Turn settlement**: `agent_settled`, then a state probe: the turn +//! ends only once pi reports it is not streaming, not compacting and +//! holds no pending messages, and the settle generation is unchanged +//! (`agent_start`, `compaction_start`, every prompt or steer, and every +//! settle bump it). Busy and stale probes re-arm; a probe that keeps +//! failing ends the turn errored — never a silent kill. Every turn shape +//! still ends with a deterministic `Done` (the engine retires its //! quiesce watchdogs). Turn errors reject the prompt: a final assistant //! error (`stopReason: "error"`), exhausted `auto_retry_*`, or a failed //! `compaction_end` without `willRetry` produce `Done { Errored }`. @@ -538,8 +551,9 @@ impl Harness for PiHarness { fn supports_steering(&self) -> bool { true } - /// pi's `steer` queue delivers after the current assistant turn's tool - /// calls — turn boundaries, matching the registry descriptor. + /// pi's steer queue (reached through `prompt` + steer behavior) + /// delivers after the current assistant turn's tool calls — turn + /// boundaries, matching the registry descriptor. fn steering_mode(&self) -> SteeringMode { SteeringMode::TurnBoundary } @@ -668,11 +682,20 @@ type RequestInputFn = Box< dyn Fn(Vec) -> oneshot::Receiver> + Send + Sync, >; -/// Bound on the prompt/steer acceptance responses (pi answers at preflight — -/// fast); a hang past it is a wedged process, not a slow one. -const ACCEPT_TIMEOUT: Duration = Duration::from_secs(30); -/// Bound on the post-settle `clear_queue` + `get_session_stats` round trips. +/// Bound on the post-settle `get_session_stats` round trip (best-effort: +/// a failure skips the context meter, never the turn). const SETTLE_TIMEOUT: Duration = Duration::from_secs(15); +/// Bound on one settle-probe round (`get_state`, `clear_queue`): pi answers +/// in milliseconds; a hang past it is one failed attempt. +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// Settle-probe attempts before the turn ends errored: a failed probe is +/// retried, and after this many the turn fails visibly instead of the +/// process being killed silently. +const PROBE_ATTEMPTS: u32 = 3; +/// Delay before a re-armed probe (a busy report, or the inference after an +/// acknowledgement that carries no disposition): the probe must never +/// hot-loop while pi streams. +const PROBE_REARM_DELAY: Duration = Duration::from_millis(500); /// Drain window for frames already written when the child exits: a crash /// right after the final response must still surface that response. const EXIT_DRAIN: Duration = Duration::from_millis(200); @@ -681,46 +704,144 @@ const EXIT_DRAIN: Duration = Duration::from_millis(200); /// see EOF first. const EOF_DRAIN: Duration = Duration::from_millis(50); -/// A steer request in flight: (text, already-retried-as-prompt, response). -type SteerCall = BoxFuture<'static, (String, bool, Result)>; +/// A mid-run steer in flight: the mailbox message and its `prompt` +/// acknowledgement. +type SteerCall = BoxFuture<'static, (SteerMessage, Result)>; + +/// One armed prompt: its acceptance future and the mailbox message it +/// delivers (None = the run's own first prompt). The pair always travels +/// together — the message is owed a `Steered` boundary once pi accepts the +/// prompt, and returns to the queue if pi rejects it. +struct Armed { + acceptance: BoxFuture<'static, Result>, + message: Option, +} + +/// pi's answer to a `prompt`: what became of it. Pi ≥ 0.99 reports +/// `disposition` on the acknowledgement; older builds report nothing and +/// the driver infers (see [`Disposition::Unknown`]). +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum Disposition { + /// A run began with the prompt. + Started, + /// Accepted into pi's steer queue: delivered (consumed) when its user + /// `message_start` echo arrives. + Queued, + /// pi handled the prompt without starting a run (an extension + /// command): the turn ends once pi reports it is not streaming. + Handled, + /// pi < 0.99 reported nothing: infer from whether a run starts — the + /// settle probe ends the turn when nothing does. + Unknown, +} + +fn disposition_of(data: &Value) -> Disposition { + match data.get("disposition").and_then(Value::as_str) { + Some("started") => Disposition::Started, + Some("queued") => Disposition::Queued, + Some("handled") => Disposition::Handled, + _ => Disposition::Unknown, + } +} -/// The post-`agent_settled` sequence: recover any steers stranded in pi's -/// queue (the settle race), then fetch the session stats for the context -/// meter. Resolves with (recovered steer texts, stats data). -type SettleOutcome = (Vec, Option); +/// One settle-probe round: read pi's state and decide whether the turn may +/// end. Pi is at rest only when it is neither streaming nor compacting and +/// holds no pending messages; messages left in pi's queue while it is idle +/// (steers stranded by the settle race) are cleared and handed back — they +/// re-deliver as the next turn's prompts. +enum ProbeRound { + /// pi is streaming or compacting: the turn cannot end yet. + Busy, + /// pi is idle with messages still queued: they were cleared; the texts + /// return for re-delivery. + Recovered(Vec), + /// pi is at rest: the turn may end (stats for the context meter are + /// best-effort). + Quiescent(Option), + /// The probe itself failed (the request was rejected or timed out). + Failed(String), +} -fn settle_future(client: &PiClient, pending_steers: usize) -> BoxFuture<'static, SettleOutcome> { +/// The texts pi's `clear_queue` handed back (its steering + followUp +/// queues). +fn queue_texts(data: &Value) -> Vec { + ["steering", "followUp"] + .iter() + .flat_map(|key| { + data.get(*key) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }) + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect() +} + +/// One probe round. Bounded: a request pi never answers counts as one +/// failed attempt. +fn probe_round(client: &PiClient) -> BoxFuture<'static, ProbeRound> { let client = client.clone(); Box::pin(async move { - // A steer accepted while the run was active can land in pi's queue - // during the settle window (the queue check happened first): the - // queue would then sit undelivered while the session parks. - // `clear_queue` hands the texts back; they redeliver as prompts. - let recovered = if pending_steers > 0 { - match client.request("clear_queue", json!({})).await { - Ok(data) => ["steering", "followUp"] - .iter() - .flat_map(|key| { - data.get(*key) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - }) - .filter_map(|value| value.as_str().map(str::to_owned)) - .collect(), - Err(e) => { - tracing::debug!( - target: "roboco_harness::pi", - "clear_queue failed (stranded steers ride the next prompt): {e}" - ); - Vec::new() - } + let state = match tokio::time::timeout( + PROBE_TIMEOUT, + client.request("get_state", json!({})), + ) + .await + { + Ok(Ok(state)) => state, + Ok(Err(e)) => return ProbeRound::Failed(e.to_string()), + Err(_) => { + return ProbeRound::Failed( + "get_state did not answer within the probe budget".into(), + ) } - } else { - Vec::new() }; - let stats = client.request("get_session_stats", json!({})).await.ok(); - (recovered, stats) + let busy = |key: &str| state.get(key).and_then(Value::as_bool).unwrap_or(false); + if busy("isStreaming") || busy("isCompacting") { + return ProbeRound::Busy; + } + let pending = state + .get("pendingMessageCount") + .and_then(Value::as_u64) + .unwrap_or(0); + if pending > 0 { + return match tokio::time::timeout( + PROBE_TIMEOUT, + client.request("clear_queue", json!({})), + ) + .await + { + Ok(Ok(data)) => ProbeRound::Recovered(queue_texts(&data)), + Ok(Err(e)) => ProbeRound::Failed(e.to_string()), + Err(_) => ProbeRound::Failed( + "clear_queue did not answer within the probe budget".into(), + ), + }; + } + let stats = match tokio::time::timeout( + SETTLE_TIMEOUT, + client.request("get_session_stats", json!({})), + ) + .await + { + Ok(Ok(stats)) => Some(stats), + // Best-effort: a failed read skips the context meter, never + // the turn. + _ => None, + }; + ProbeRound::Quiescent(stats) + }) +} + +/// Arm one more probe round, optionally after the re-arm delay: a busy +/// report must not hot-loop the probe while pi streams. +fn rearm_probe(client: &PiClient, delay: bool) -> BoxFuture<'static, ProbeRound> { + let client = client.clone(); + Box::pin(async move { + if delay { + tokio::time::sleep(PROBE_REARM_DELAY).await; + } + probe_round(&client).await }) } @@ -880,17 +1001,29 @@ async fn run_session(session: Session) { // Agent activity since the last settle (extension-initiated runs deliver // output with no prompt of ours; their settle still emits a Done). let mut saw_activity = false; - // Steers accepted into pi's queue (or retried as steer-behavior prompts) - // whose delivery pi confirms with a user message. - let mut pending_steer_deliveries: usize = 0; + // Mailbox prompts accepted into pi's steer queue (disposition `queued`, + // or an acknowledgement without one on pi < 0.99) whose delivery pi + // confirms with a user message echo — the consumed signal. + let mut pending_echo_deliveries: usize = 0; let mut steer_calls: FuturesUnordered = FuturesUnordered::new(); - // Steers the driver itself owns (stranded in pi's queue at settle, or - // queued while the settle sequence runs) — delivered as fresh prompts - // once the current turn's Done is out, carrying their attachments and - // run config. + // Messages the driver itself owns — rejected by pi, stranded in pi's + // queue at settle, or queued while the settle probe winds down — + // delivered as fresh prompts once the current turn's Done is out, + // carrying their attachments and run config. let mut driver_steers: VecDeque = VecDeque::new(); - // The post-settle sequence (clear_queue + stats), then the Done. - let mut settle: Option> = None; + // The settle probe: armed at `agent_settled` (and after a handled or + // undispositioned acknowledgement, or a rejection), re-armed while pi + // reports busy. The turn ends only when a round reports pi at rest with + // an unchanged generation. + let mut probe: Option> = None; + // The generation the in-flight probe was armed at: `agent_start`, + // `compaction_start`, every prompt or steer sent, and every settle bump + // the counter; a probe result armed at a superseded generation is + // stale (a prompt acknowledgement raced it) and re-arms. + let mut probe_generation: u64 = 0; + // Consecutive failed probe rounds (any success resets it). + let mut probe_failures: u32 = 0; + let mut generation: u64 = 0; // Per-turn token usage: the latest cumulative provider usage, emitted // at each turn boundary (codex parity). let mut last_usage: Option = None; @@ -907,15 +1040,23 @@ async fn run_session(session: Session) { let mut child_exit: Option> = None; let mut exit_drain_deadline: Option = None; - // Arm the first prompt. Prompt acceptance (preflight) — set when a - // prompt is armed, cleared when its response lands (Ok = run started, - // Err = rejected). A prompt sent while idle makes pi emit its own user - // message first — the echo, not a steer delivery. A prompt-owned run is - // between acceptance and its Done. + // Arm the first prompt. The acceptance — pi's preflight — is set when a + // prompt is armed and cleared when its response lands (Ok = accepted, + // read by disposition; Err = rejected). A prompt that starts a run + // makes pi emit its own user message first — the echo, not a steer + // delivery; a prompt pi queues delivers at that echo instead. A + // prompt-owned run is between acceptance and its Done. let images = load_image_contents(&request.attachments).await; - let mut acceptance: Option>> = Some( - prompt_acceptance(&client, &request.prompt, images, None), - ); + let mut armed = Some(Armed { + acceptance: prompt_acceptance(&client, &request.prompt, images), + message: None, + }); + // Whether the current acceptance cycle has already spent its one + // immediate at-rest re-delivery of a rejected message: a pi that keeps + // rejecting falls back to the probe path (bounded by its re-arm delay) + // instead of hot-looping round trips. + let mut retried_at_rest = false; + generation += 1; // the prompt went out let mut awaiting_prompt_echo = true; let mut run_active = true; let mut done_current = false; @@ -933,138 +1074,389 @@ async fn run_session(session: Session) { exit_drain_deadline.unwrap_or_else(tokio::time::Instant::now) ), if exit_drain_deadline.is_some() => break 'main, - res = async { acceptance.as_mut().expect("guarded by if").await }, if acceptance.is_some() => { - acceptance = None; + res = async { armed.as_mut().expect("guarded by if").acceptance.await }, if armed.is_some() => { + let Armed { message: mailbox, .. } = armed.take().expect("guarded by if"); match res { - Ok(_) => { /* accepted: the run streams until agent_settled */ } - Err(e) => { - // Pre-acceptance rejection: the prompt never ran — - // the turn errors with pi's own reason (an - // interrupt in flight still wins, ACP parity). - let (status, error) = if interrupted { - (DoneStatus::Interrupted, None) - } else { - (DoneStatus::Errored, Some(e.to_string())) - }; - done_current = true; - if interrupted { - done_after_interrupt = true; + Ok(data) => { + // A prompt acknowledgement bumps the settle + // generation: a probe whose reading predates it must + // re-arm — the steer it accepted may have landed in + // pi queue after the probe read it (T3 bug 16846). + generation += 1; + // A new acceptance cycle earns a fresh immediate + // re-delivery budget. + retried_at_rest = false; + match disposition_of(&data) { + Disposition::Started => { + // A run began: it streams until + // agent_settled, where the settle probe + // takes over. A mailbox message is delivered + // at acceptance. + if mailbox.is_some() + && !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await + { + break 'main; + } + awaiting_prompt_echo = true; + run_active = true; + } + Disposition::Queued => { + // Accepted into pi steer queue: delivery + // (and the boundary) land at the user + // message echo; a strand is recovered by the + // settle probe clear_queue. + if mailbox.is_some() { + pending_echo_deliveries += 1; + } + // The run own first prompt queued behind an + // extension run: awaiting_prompt_echo + // already covers its echo. + run_active = true; + } + Disposition::Handled => { + // pi handled the prompt without a run (an + // extension command): the boundary is owed + // now, and the turn ends once pi reports it + // is not streaming. + if mailbox.is_some() + && !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await + { + break 'main; + } + awaiting_prompt_echo = false; + run_active = false; + if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, false)); + } + } + Disposition::Unknown => { + // pi < 0.99 reports no disposition: + // acceptance still confirms delivery, but + // whether a run started is inferred — the + // probe (after a grace, so a starting run + // can flip isStreaming) ends the turn if + // nothing runs. + if mailbox.is_some() + && !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await + { + break 'main; + } + awaiting_prompt_echo = true; + if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } + } } - if !send( - &event_tx, - AgentEvent::Done { - status, - result: None, - error, - session_id: Some(session_pointer.clone()), - }, - ) - .await - { - break 'main; + }, + Err(e) => match mailbox { + Some(msg) => { + // A rejected mailbox prompt never ends the + // session: the rejection surfaces, the message + // returns to the queue, and it re-delivers once + // the turn ends — immediately when the session + // is already at rest, else after the probe + // closes the turn. + let _ = send( + &event_tx, + AgentEvent::Error { + message: format!("pi rejected the message: {e}"), + }, + ) + .await; + driver_steers.push_front(msg); + run_active = false; + let at_rest = !saw_activity + && probe.is_none() + && steer_calls.is_empty(); + if at_rest && !retried_at_rest { + // One immediate re-delivery per acceptance + // cycle; a pi that keeps rejecting an idle + // prompt falls back to the probe path below, + // whose re-arm delay bounds the retry rate. + retried_at_rest = true; + if let Some(msg) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + msg, + ) + .await + { + break 'main; + } + } else if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } } - break 'main; - } + None => { + // The run's own first prompt: the turn errors + // with pi's own reason (an interrupt in flight + // still wins, ACP parity). The session itself + // parks — the consumer decides its fate. + let (status, error) = if interrupted { + (DoneStatus::Interrupted, None) + } else { + (DoneStatus::Errored, Some(e.to_string())) + }; + done_current = true; + if interrupted { + done_after_interrupt = true; + } + if !send( + &event_tx, + AgentEvent::Done { + status, + result: None, + error, + session_id: Some(session_pointer.clone()), + }, + ) + .await + { + break 'main; + } + run_active = false; + } + }, } }, - outcome = async { settle.as_mut().expect("guarded by if").await }, if settle.is_some() => { - settle = None; - let (recovered, stats) = outcome; - if !recovered.is_empty() { - let stranded = pending_steer_deliveries.saturating_sub(recovered.len()); - pending_steer_deliveries = stranded; - let mut queue: Vec = driver_steers.drain(..).collect(); - queue.extend(recovered.into_iter().map(SteerMessage::text)); - driver_steers = queue.into(); - } else { - pending_steer_deliveries = 0; - } - if let Some(stats) = stats.as_ref().and_then(|stats| context_usage_event(stats)) - && !send(&event_tx, stats).await - { - break 'main; - } - let status = if turn_error.is_some() { - DoneStatus::Errored - } else { - DoneStatus::Completed - }; - let error = turn_error.take(); - done_current = true; - if interrupted { - done_after_interrupt = true; - } - if !send( - &event_tx, - AgentEvent::Done { - status, - result: None, - error, - session_id: Some(session_pointer.clone()), - }, - ) - .await - { - break 'main; - } - if interrupted { - break 'main; - } - // Agent activity during the settle round-trip (an - // extension-initiated run) needs its own settle + Done. - if saw_activity { - saw_activity = false; - let settle_client = client.clone(); - settle = Some(Box::pin(async move { - match tokio::time::timeout(SETTLE_TIMEOUT, settle_future(&settle_client, 0)).await { - Ok(outcome) => outcome, - Err(_) => (Vec::new(), None), + round = async { probe.as_mut().expect("guarded by if").await }, if probe.is_some() => { + probe = None; + match round { + ProbeRound::Busy => { + // pi is streaming or compacting: re-arm after the + // delay (the probe must never hot-loop while pi + // streams). + probe_failures = 0; + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } + ProbeRound::Recovered(texts) => { + // pi is idle yet still held messages (steers + // stranded by the settle race): they were cleared + // and re-queue, in send order, as the next turn's + // prompts. + probe_failures = 0; + pending_echo_deliveries = + pending_echo_deliveries.saturating_sub(texts.len()); + for text in texts.into_iter().rev() { + driver_steers.push_front(SteerMessage::text(text)); } - })); - } - // A stranded steer becomes the next turn; otherwise the - // session parks — the caller owns teardown. - if settle.is_none() - && let Some(msg) = driver_steers.pop_front() - && !deliver_parked_steer( - &client, - &event_tx, - &mut assistant_message_id, - &mut acceptance, - &mut awaiting_prompt_echo, - &mut run_active, - &mut done_current, - &msg, - ) - .await - { - break 'main; + probe_generation = generation; + probe = Some(rearm_probe(&client, false)); + } + ProbeRound::Quiescent(stats) => { + if generation != probe_generation { + // A prompt, steer or settle bumped since arming: + // this reading is stale — re-probe. This is what + // re-arms the probe after a prompt + // acknowledgement that raced it. + probe_failures = 0; + probe_generation = generation; + probe = Some(rearm_probe(&client, false)); + } else { + probe_failures = 0; + if let Some(usage) = stats + .as_ref() + .and_then(|stats| context_usage_event(stats)) + && !send(&event_tx, usage).await + { + break 'main; + } + let status = if turn_error.is_some() { + DoneStatus::Errored + } else { + DoneStatus::Completed + }; + let error = turn_error.take(); + done_current = true; + if interrupted { + done_after_interrupt = true; + } + if !send( + &event_tx, + AgentEvent::Done { + status, + result: None, + error, + session_id: Some(session_pointer.clone()), + }, + ) + .await + { + break 'main; + } + if interrupted { + break 'main; + } + // A queued message becomes the next turn; + // otherwise the session parks — the caller owns + // teardown. + if let Some(msg) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + msg, + ) + .await + { + break 'main; + } + } + } + ProbeRound::Failed(reason) => { + probe_failures += 1; + if probe_failures >= PROBE_ATTEMPTS { + // The probe failed repeatedly: end the turn + // errored — visibly, with the reason — never a + // silent kill. The session parks; the queued + // messages' ledger entries survive for the + // engine's re-dispatch. The counter resets so + // the next turn's probe starts a fresh budget. + probe_failures = 0; + done_current = true; + if !send( + &event_tx, + AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some(format!( + "pi's settle probe failed after \ + {PROBE_ATTEMPTS} attempts: {reason}" + )), + session_id: Some(session_pointer.clone()), + }, + ) + .await + { + break 'main; + } + } else { + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } + } } }, res = steer_calls.next(), if !steer_calls.is_empty() => match res { - Some((text, retried, res)) => match res { - Ok(_) => { /* queued in pi; delivery shows as a user message */ } + Some((msg, res)) => match res { + Ok(data) => { + // The steer acknowledgement bumps the settle + // generation: a probe whose reading predates it must + // re-arm — the steer may have landed in pi queue + // after the probe read it (T3 bug 16846). + generation += 1; + match disposition_of(&data) { + Disposition::Started => { + // The steer raced the run end and pi started + // a run with it: delivered now, and the run + // streams to its own settle. + if !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await + { + break 'main; + } + run_active = true; + } + Disposition::Queued => { + // Accepted into pi steer queue: the boundary + // lands at the user message echo. + pending_echo_deliveries += 1; + } + Disposition::Handled => { + // pi handled it without starting a run: the + // message is delivered. + if !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await + { + break 'main; + } + } + Disposition::Unknown => { + // pi < 0.99: delivery lands at the echo, if + // one ever comes. + pending_echo_deliveries += 1; + } + } + }, Err(e) if client.is_closed() => { tracing::debug!(target: "roboco_harness::pi", "steer lost to exit: {e}"); } - Err(e) if !retried => { - // Rejection (pi errors on extension-command text in - // a steer): redeliver as a prompt with steer - // behavior — pi runs extension commands immediately. - tracing::debug!( - target: "roboco_harness::pi", - "steer rejected (retrying as a steer-behavior prompt): {e}" - ); - steer_calls.push(steer_request(&client, &text, true)); - } Err(e) => { + // Rejected: surface it and hand the message back to + // the queue — it re-delivers once the turn ends + // (immediately when the session is already at + // rest, else when the settle probe closes the + // turn). let _ = send( &event_tx, - AgentEvent::Error { message: format!("Steering failed: {e}") }, + AgentEvent::Error { + message: format!("pi rejected the steer: {e}"), + }, ) .await; + driver_steers.push_front(msg); + let at_rest = !run_active + && !saw_activity + && probe.is_none() + && armed.is_none() + && steer_calls.is_empty(); + if at_rest && !retried_at_rest { + // Same one-shot immediate re-delivery budget as a + // rejected parked prompt: bounded by the probe + // re-arm delay if pi keeps rejecting. + retried_at_rest = true; + if let Some(msg) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + msg, + ) + .await + { + break 'main; + } + } else if at_rest && probe.is_none() { + // The immediate budget is spent: the probe ends + // the turn and its Done pops the message back. + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } } }, None => {} @@ -1076,6 +1468,10 @@ async fn run_session(session: Session) { let kind = event.get("type").and_then(Value::as_str).unwrap_or(""); match kind { "agent_start" => { + // Agent activity: bumps the settle generation + // (an in-flight probe would read stale) and + // marks the turn active until its settle. + generation += 1; saw_activity = true; } "message_start" => { @@ -1083,19 +1479,17 @@ async fn run_session(session: Session) { if role == Some("assistant") { tracker.reset(); } else if role == Some("user") { - // Our idle prompt's own echo, or a steer + // Our prompt's own echo, or a queued steer's // delivery out of pi's queue. if awaiting_prompt_echo { awaiting_prompt_echo = false; - } else if pending_steer_deliveries > 0 { - pending_steer_deliveries -= 1; - let (prev, next) = rotate(&mut assistant_message_id); - if !send( + } else if pending_echo_deliveries > 0 { + // A queued steer's consumed signal: its + // user echo is what delivers it. + pending_echo_deliveries -= 1; + if !confirm_delivery( &event_tx, - AgentEvent::Steered { - assistant_message_id: Some(prev), - next_assistant_message_id: Some(next), - }, + &mut assistant_message_id, ) .await { @@ -1255,7 +1649,15 @@ async fn run_session(session: Session) { .unwrap_or_default(); tracing::debug!(target: "roboco_harness::pi", "pi extension error: {detail}"); } + "compaction_start" => { + // Compaction is agent activity too: it bumps + // the settle generation — the probe confirms + // rest only after compaction_end. + generation += 1; + } "agent_settled" => { + // Every settle bumps the generation. + generation += 1; if interrupted { // abort() landed: close out as Interrupted // without waiting for anything else. @@ -1273,21 +1675,20 @@ async fn run_session(session: Session) { .await; break 'main; } - if (run_active || saw_activity) && settle.is_none() { + if run_active || saw_activity { run_active = false; saw_activity = false; - let settle_client = client.clone(); - let pending = pending_steer_deliveries; - settle = Some(Box::pin(async move { - match tokio::time::timeout(SETTLE_TIMEOUT, settle_future(&settle_client, pending)).await { - Ok(outcome) => outcome, - Err(_) => (Vec::new(), None), - } - })); + if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, false)); + } + // A probe already in flight (armed after a + // handled or undispositioned acknowledgement, + // or a rejection): its generation check + // re-arms it for this settle. } - // A settle with no run behind it (spurious, a - // settle already in flight — its completion - // re-arms for any activity seen meanwhile — or + // A settle with no run behind it (spurious, one + // already covered by an in-flight probe, or // post-abort) is ignored. } // turn_start, queue_update, bash_execution_update @@ -1300,16 +1701,17 @@ async fn run_session(session: Session) { } } Some(PiIncoming::Eof) | None => { - // The settle sequence and the acceptance both race EOF - // through the pending map: a response that already + // The settle probe and the acceptance both race EOF + // through the pending map: a result that already // resolved must still close out the turn. Give each a // short drain before falling to crash bookkeeping. - if let Some(mut fut) = settle.take() { - if let Ok((recovered, stats)) = + if let Some(mut fut) = probe.take() { + if let Ok(ProbeRound::Quiescent(stats)) = tokio::time::timeout(EOF_DRAIN, &mut fut).await { - let _ = recovered; - if let Some(usage) = stats.as_ref().and_then(|s| context_usage_event(s)) { + if let Some(usage) = + stats.as_ref().and_then(|s| context_usage_event(s)) + { let _ = send(&event_tx, usage).await; } done_current = true; @@ -1329,18 +1731,30 @@ async fn run_session(session: Session) { .await; } } - if let Some(mut fut) = acceptance.take() { + if let Some(mut armed_prompt) = armed.take() { // A resolved REJECTION carries pi's own reason; a // resolved acceptance (or a request failed by the // EOF cleanup) falls through to the crash message. - if let Ok(Err(e)) = tokio::time::timeout(EOF_DRAIN, &mut fut).await { + // A Stop that killed pi before the answer still wins + // (ACP parity) — one Interrupted Done, never two. + if let Ok(Err(e)) = + tokio::time::timeout(EOF_DRAIN, &mut armed_prompt.acceptance).await + { done_current = true; + let (status, error) = if interrupted { + (DoneStatus::Interrupted, None) + } else { + (DoneStatus::Errored, Some(e.to_string())) + }; + if interrupted { + done_after_interrupt = true; + } let _ = send( &event_tx, AgentEvent::Done { - status: DoneStatus::Errored, + status, result: None, - error: Some(e.to_string()), + error, session_id: Some(session_pointer.clone()), }, ) @@ -1360,28 +1774,28 @@ async fn run_session(session: Session) { // current configuration runs (startup parity). let _ = configure_live(&client, next).await; } - let text = msg.prompt.clone(); - if run_active || acceptance.is_some() { - // Live turn: pi's own steer queue (delivered after - // the current assistant turn's tool calls). The + if run_active || armed.is_some() { + // Live turn: the prompt rides steer behavior — pi + // queues it while streaming (delivered after the + // current assistant turn's tool calls) and starts a + // run when idle, atomically against settlement. The // attachments' path refs ride the text; the parked // delivery inlines them as images. - steer_calls.push(steer_request(&client, &text, false)); - pending_steer_deliveries += 1; - } else if settle.is_some() || !steer_calls.is_empty() || !driver_steers.is_empty() { - // The settle sequence or a steer acknowledgement is + generation += 1; // the prompt goes out + steer_calls.push(steer_request(&client, msg)); + } else if probe.is_some() || !steer_calls.is_empty() || !driver_steers.is_empty() { + // The settle probe or a steer acknowledgement is // still winding down: FIFO behind it. driver_steers.push_back(msg); } else { if !deliver_parked_steer( &client, - &event_tx, - &mut assistant_message_id, - &mut acceptance, + &mut armed, &mut awaiting_prompt_echo, &mut run_active, &mut done_current, - &msg, + &mut generation, + msg, ) .await { @@ -1394,8 +1808,8 @@ async fn run_session(session: Session) { // finish once nothing is in flight. steering_open = false; if !run_active - && acceptance.is_none() - && settle.is_none() + && armed.is_none() + && probe.is_none() && steer_calls.is_empty() && driver_steers.is_empty() { @@ -1407,10 +1821,10 @@ async fn run_session(session: Session) { _ = interrupt.cancelled(), if !interrupt_sent => { interrupt_sent = true; interrupted = true; - if settle.is_some() { - // The turn already settled; the stats round-trip was the - // only thing in flight. Close out as interrupted. - let _ = settle.take(); + if probe.is_some() { + // The turn already settled; the probe was the only thing + // in flight. Close out as interrupted. + let _ = probe.take(); done_current = true; done_after_interrupt = true; let _ = send( @@ -1425,7 +1839,7 @@ async fn run_session(session: Session) { .await; break 'main; } - let in_flight = run_active || acceptance.is_some() || saw_activity; + let in_flight = run_active || armed.is_some() || saw_activity; if in_flight { // abort() waits for idle before responding; do not block // the loop on it — the agent_settled it triggers (or the @@ -1497,69 +1911,52 @@ async fn run_session(session: Session) { } } -/// Arm a prompt and return its acceptance future. `streaming_behavior` is -/// set only for the steer-rejection retry (a prompt arriving while pi is -/// streaming must declare its queue behavior or pi rejects it). +/// Arm a prompt and return its acceptance future. Every prompt — turn +/// starts included — goes out with `streamingBehavior: "steer"`: pi starts +/// a run when idle and queues the prompt when an extension run is +/// streaming, atomically against settlement, so a prompt can never strand +/// against the end of a run. The acceptance is unbounded on purpose: pi's +/// preflight (input handlers, `before_agent_start` hooks including the +/// Roboco MCP bridge, and pre-prompt compaction) may take arbitrarily +/// long, and only Stop interrupts a slow acceptance. fn prompt_acceptance( client: &PiClient, text: &str, images: Vec, - streaming_behavior: Option<&str>, ) -> BoxFuture<'static, Result> { let client = client.clone(); let text = text.to_owned(); - let mut params = json!({ "message": text }); + let mut params = json!({ "message": text, "streamingBehavior": "steer" }); if !images.is_empty() { params["images"] = Value::Array(images); } - if let Some(behavior) = streaming_behavior { - params["streamingBehavior"] = Value::String(behavior.to_owned()); - } - Box::pin(async move { - match tokio::time::timeout(ACCEPT_TIMEOUT, client.request("prompt", params)).await { - Ok(result) => result, - Err(_) => Err(HarnessError::Protocol( - "pi did not accept the prompt within 30s (the process is likely wedged)".into(), - )), - } - }) + Box::pin(async move { client.request("prompt", params).await }) } -/// One steer request, optionally retrying a rejected steer as a -/// steer-behavior prompt (pi rejects extension-command text in `steer` -/// but runs it immediately as a prompt). -fn steer_request(client: &PiClient, text: &str, as_prompt: bool) -> SteerCall { +/// One mid-run steer: the same `prompt` with steer behavior (pi queues it +/// while streaming and starts a run when idle). Carries the mailbox +/// message so a rejection can hand it back to the queue verbatim. +fn steer_request(client: &PiClient, msg: SteerMessage) -> SteerCall { let client = client.clone(); - let text = text.to_owned(); Box::pin(async move { - let result = if as_prompt { - let params = json!({ "message": text, "streamingBehavior": "steer" }); - client.request("prompt", params).await - } else { - client.request("steer", json!({ "message": text })).await - }; - (text, as_prompt, result) + let params = json!({ + "message": msg.prompt.clone(), + "streamingBehavior": "steer", + }); + let result = client.request("prompt", params).await; + (msg, result) }) } -/// A parked session's next message: confirm the boundary, then arm the -/// prompt (the mailbox's FIFO ledger retires on the Steered boundary) with -/// the message's attachments inlined and its run config applied. -#[allow(clippy::too_many_arguments)] -async fn deliver_parked_steer( - client: &PiClient, +/// Emit the `Steered` boundary that confirms a mailbox message's delivery: +/// the engine retires its at-least-once ledger entry here and splits the +/// assistant entry at the boundary. +async fn confirm_delivery( event_tx: &mpsc::Sender>, assistant_message_id: &mut String, - acceptance: &mut Option>>, - awaiting_prompt_echo: &mut bool, - run_active: &mut bool, - done_current: &mut bool, - msg: &SteerMessage, ) -> bool { - // Confirm the boundary FIRST (the mailbox ledger retires here), then - // arm the prompt — ACP parity for the between-turns delivery path. let (prev, next) = rotate(assistant_message_id); - if !send( + send( event_tx, AgentEvent::Steered { assistant_message_id: Some(prev), @@ -1567,17 +1964,40 @@ async fn deliver_parked_steer( }, ) .await - { - return false; - } +} + +/// A parked session's next message: apply its run config, then arm the +/// prompt with the message's attachments inlined. The delivery boundary +/// (`Steered`) is owed only once pi ACCEPTS the prompt — at the +/// acknowledgement for a run or a handled command, at the user message +/// echo for a queued steer — so a rejected prompt leaves the message +/// undelivered and recoverable. +#[allow(clippy::too_many_arguments)] +async fn deliver_parked_steer( + client: &PiClient, + armed: &mut Option, + awaiting_prompt_echo: &mut bool, + run_active: &mut bool, + done_current: &mut bool, + generation: &mut u64, + msg: SteerMessage, +) -> bool { if let Some(next) = &msg.config { + // Best-effort: a rejected switch logs inside and the current + // configuration runs (startup parity). let _ = configure_live(client, next).await; } let images = load_image_contents(&msg.attachments).await; - *acceptance = Some(prompt_acceptance(client, &msg.prompt, images, None)); - *awaiting_prompt_echo = true; + *armed = Some(Armed { + acceptance: prompt_acceptance(client, &msg.prompt, images), + message: Some(msg), + }); + // Set per disposition once pi answers: a started run echoes its own + // user message; a queued acceptance delivers at the echo instead. + *awaiting_prompt_echo = false; *run_active = true; *done_current = false; + *generation += 1; // the prompt goes out true } diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 32918a096b..9ee706cd44 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -3,7 +3,11 @@ # covering the pi first-party RPC protocol surface — id-correlated command # responses, every event type the native driver maps, stdout noise/CRLF # tolerance, turn errors, auto-retry, compaction, extension UI dialogs, and -# the steering queue. Replaces fake-pi-acp.sh for the native driver. +# the steering queue — plus the prompt-delivery hardening surface +# (orchestration v2, ticket 02): prompt dispositions (started / queued / +# handled / none for old pi), a rejection while pi streams, a slow +# preflight, the settle probe (busy re-arm, failure, the settle race) and +# clear_queue strand recovery. # NOTE: the python below is embedded in a sh single-quoted string - it must # never contain an apostrophe (a stray one splits the script into argv). exec python3 -u -c ' @@ -106,12 +110,15 @@ session_id = "pi-fixture-session" thinking_level_seen = None set_model_seen = None steering_queue = [] +# pi isStreaming flag, scripted by the scenarios that need a run in +# flight (an extension-initiated run). +streaming = False def state_data(): return { "model": {"provider": "mock", "id": "mock/model", "name": "Mock", "contextWindow": 200000}, "thinkingLevel": thinking_level_seen or "medium", - "isStreaming": False, "isCompacting": False, + "isStreaming": streaming, "isCompacting": False, "steeringMode": "one-at-a-time", "followUpMode": "one-at-a-time", "sessionFile": session_file, "sessionId": session_id, "sessionName": "fixture", "autoCompactionEnabled": True, @@ -166,18 +173,41 @@ def dialog_flow(): turn_and_settle("dialogs done") def run_prompt(req): + global streaming text = req["message"] if text == "require-resume": assert resumed_session == session_file, f"engine did not resume: {resumed_session}" if text.startswith("You generate session titles"): assert title_mode, "title instructions must only run in the title sandbox" + response(req, {"disposition": "started"}) user_message(text) turn_and_settle("Fix Login Flow") return if text == "reject": reject(req, "A prompt is already running (busy)") return - response(req) # preflight acceptance + if text == "handled": + # pi runs the extension command itself: accepted, handled — no run + # and no agent_settled ever follow. + response(req, {"disposition": "handled"}) + return + if text == "old-pi-handled": + # pi < 0.99: no disposition field, and the command needed no run. + response(req) + return + if text == "old-pi": + # pi < 0.99: no disposition field — the driver infers whether a run + # started from the settle probe. + response(req) + user_message(text) + turn_and_settle("reply:" + text) + return + if text == "slow-preflight": + # pi preflight (input handlers, before_agent_start hooks, the + # Roboco MCP bridge, compaction) runs longer than any fixed + # acceptance budget: the driver must wait, not time the run out. + time.sleep(31) + response(req, {"disposition": "started"}) # preflight acceptance if text == "crash": assistant_chunks(["partial text"]) @@ -194,11 +224,13 @@ def run_prompt(req): if text == "idle-crash": user_message(text) turn_and_settle("reply:" + text) - # Answer the driver post-settle stats poll, then exit while the + # Answer the driver post-settle probe + stats, then exit while the # session is parked: the next dispatch must resume by --session. while True: followup = read_command() - if followup["type"] == "get_session_stats": + if followup["type"] == "get_state": + response(followup, state_data()) + elif followup["type"] == "get_session_stats": response(followup, stats_data()) break time.sleep(0.1) @@ -283,30 +315,34 @@ def run_prompt(req): emit({"type": "turn_start"}) assistant_chunks(["first part "]) steer = read_command() - assert steer["type"] == "steer" and steer["message"] == "steered text", steer + # A mid-run steer is the same prompt with steer behavior: pi queues + # it while streaming. + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + assert steer["message"] == "steered text", steer steering_queue.append(steer["message"]) + # pi order: the queue_update precedes the acknowledgement. emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) - response(steer) + response(steer, {"disposition": "queued"}) emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) # pi delivers the steer at the boundary: queue drains, then the user - # message arrives, then the reply to it. + # message arrives (the consumed signal), then the reply to it. steering_queue.remove(steer["message"]) emit({"type": "queue_update", "steering": [], "followUp": []}) user_message(steer["message"]) turn_and_settle("reply-to-steer") return if text == "steer-strand": - # The steer is accepted mid-run but never delivered: the pi queue + # The steer is accepted mid-run but never delivered: pi queue # check happened before it landed, so the run settles with the - # steer stranded. The driver must recover it via clear_queue and - # deliver it as a fresh prompt after the Done. + # steer stranded. The settle probe must clear the queue and hand + # the text back for the next turn. user_message(text) emit({"type": "turn_start"}) assistant_chunks(["reply part"]) steer = read_command() - assert steer["type"] == "steer", steer + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer steering_queue.append(steer["message"]) - response(steer) + response(steer, {"disposition": "queued"}) emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) emit({"type": "agent_end", "messages": [], "willRetry": False}) emit({"type": "agent_settled"}) @@ -347,6 +383,15 @@ def run_prompt(req): # must emit a second Done for it. user_message(text) turn_and_settle("reply:" + text) + # Close the prompted turn fully (settle probe + stats) before the + # wake run starts — the fake is single-threaded and cannot answer + # the probe while a scenario runs. + probe = read_command() + assert probe["type"] == "get_state", probe + response(probe, state_data()) + stats = read_command() + assert stats["type"] == "get_session_stats", stats + response(stats, stats_data()) time.sleep(0.3) emit({"type": "agent_start"}) emit({"type": "turn_start"}) @@ -354,6 +399,86 @@ def run_prompt(req): emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) emit({"type": "agent_settled"}) return + if text == "reject-while-streaming": + # The prompted turn completes and parks; an extension-initiated run + # starts streaming; the user message sent then is delivered as a + # prompt (steer behavior) and pi REJECTS it. The driver must keep + # the session alive, surface the rejection, return the message to + # its queue, and re-deliver it once the extension run winds down. + user_message(text) + turn_and_settle("reply:" + text) + time.sleep(0.3) # the driver parks: probe + stats + first Done + streaming = True + emit({"type": "agent_start"}) + emit({"type": "turn_start"}) + assistant_chunks(["working"]) + prompt = read_command() + assert prompt["type"] == "prompt" and prompt.get("streamingBehavior") == "steer", prompt + reject(prompt, "A prompt is already running (busy)") + # The extension run winds down. + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + emit({"type": "agent_end", "messages": [], "willRetry": False}) + streaming = False + emit({"type": "agent_settled"}) + # Whatever the driver asks next (settle probe, stats) until the + # message re-delivers as the next turn prompt — the loop tolerates + # the driver interleaving its probe and the re-delivery either way + # around the settle. + while True: + command = read_command() + if command["type"] == "get_state": + response(command, state_data()) + elif command["type"] == "get_session_stats": + response(command, stats_data()) + elif command["type"] == "prompt": + assert command.get("streamingBehavior") == "steer", command + response(command, {"disposition": "started"}) + user_message(command["message"]) + turn_and_settle("reply:" + command["message"]) + break + else: + reject(command, "unexpected command: " + command["type"]) + return + if text == "settle-race": + # The settle probe is in flight when the user message arrives: the + # driver must queue it behind the turn (or join the run) — never + # strand the turn. + user_message(text) + turn_and_settle("reply:" + text) + probe = read_command() + assert probe["type"] == "get_state", probe + # Hold the probe answer open so the raced message lands in the + # window. + time.sleep(1.0) + response(probe, state_data()) + return + if text == "busy-probe": + # The settle probe answers BUSY twice (pi still streaming), then at + # rest: the driver must re-arm after each busy report and end the + # turn only once pi rests. + user_message(text) + turn_and_settle("reply:" + text) + for attempt in range(3): + probe = read_command() + assert probe["type"] == "get_state", probe + if attempt < 2: + response(probe, dict(state_data(), isStreaming=True)) + else: + response(probe, state_data()) + stats = read_command() + assert stats["type"] == "get_session_stats", stats + response(stats, stats_data()) + return + if text == "probe-fail": + # The settle probe fails every time: the turn must end ERRORED + # (visibly, with the reason) — not hang, and not a silent kill. + user_message(text) + turn_and_settle("reply:" + text) + for attempt in range(3): + probe = read_command() + assert probe["type"] == "get_state", probe + reject(probe, "wedged") + return # default happy path user_message(text) turn_and_settle("reply:" + text) diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 4cb5332944..ed3454ede3 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; use std::time::Duration; use futures::StreamExt; +use futures::stream::BoxStream; use tokio::sync::{mpsc, oneshot}; use roboco_harness::{CancellationToken, Harness, PiHarness, RunControls, SteerMessage, TurnControl}; @@ -130,6 +131,27 @@ fn text_of(events: &[AgentEvent]) -> String { .collect() } +/// Collect events through the next `Done` (bounded), keeping the stream +/// open for further turns. +async fn until_done( + stream: &mut BoxStream<'static, Result>, + events: &mut Vec, + budget: Duration, +) { + loop { + let event = tokio::time::timeout(budget, stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let done = matches!(event, AgentEvent::Done { .. }); + events.push(event); + if done { + return; + } + } +} + // --------------------------------------------------------------------------- // Descriptor + catalog surfaces // --------------------------------------------------------------------------- @@ -947,3 +969,222 @@ async fn wake_turn_after_settle_still_gets_its_done() { ); assert!(text_of(&events).contains("wake output")); } + +// --------------------------------------------------------------------------- +// Prompt delivery hardening: dispositions, rejections, the settle probe +// (orchestration v2, ticket 02) +// --------------------------------------------------------------------------- + +/// A prompt pi handles without a run (an extension command) ends the turn — +/// the settle probe confirms pi at rest — instead of sticking the chat on +/// Working forever, and the session keeps serving. +#[tokio::test] +async fn a_handled_prompt_ends_the_turn_and_parks_the_session() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("handled"), controls).await.unwrap(); + let mut events = Vec::new(); + until_done(&mut stream, &mut events, Duration::from_secs(15)).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // The session parks: the next message runs as a normal turn. + steer + .send(SteerMessage::text("after handled")) + .await + .unwrap(); + let mut second = Vec::new(); + until_done(&mut stream, &mut second, Duration::from_secs(15)).await; + assert_eq!(dones(&second), vec![(DoneStatus::Completed, None)], "{second:?}"); + assert!(second.iter().any(|e| matches!(e, AgentEvent::Steered { .. }))); + assert_eq!(text_of(&second), "reply:after handled"); +} + +/// A prompt rejected while pi is streaming surfaces the rejection, returns +/// the message to the queue, and never ends the session: the message +/// re-delivers once pi comes to rest, and the session keeps serving. +#[tokio::test] +async fn a_prompt_rejected_while_pi_streams_keeps_the_session_and_the_message() { + let (controls, steer, _token) = controls(); + let mut stream = harness() + .run(request("reject-while-streaming"), controls) + .await + .unwrap(); + let mut events = Vec::new(); + let mut sent = false; + loop { + let event = tokio::time::timeout(Duration::from_secs(15), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let is_working = matches!(&event, AgentEvent::TextDelta { text } if text == "working"); + let saw_reply = text_of(&events).contains("reply:steered while streaming"); + let is_done = matches!(&event, AgentEvent::Done { .. }); + events.push(event); + if is_working && !sent { + // The extension run is streaming: this is the window where the + // message is delivered and rejected. + sent = true; + steer + .send(SteerMessage::text("steered while streaming")) + .await + .unwrap(); + } + if is_done && saw_reply { + break; + } + } + // The prompted turn, the extension run, and the re-delivered message: + // three completed turns — the session never died, the turn never + // stranded. + assert_eq!( + dones(&events), + vec![(DoneStatus::Completed, None); 3], + "{events:?}" + ); + // The rejection surfaced... + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message } if message.contains("busy") + )), + "{events:?}" + ); + // ...and the message survived the rejection and ran. + assert!( + text_of(&events).contains("reply:steered while streaming"), + "{events:?}" + ); + // The session is alive: a follow-up still runs. + steer.send(SteerMessage::text("still alive")).await.unwrap(); + let mut tail = Vec::new(); + until_done(&mut stream, &mut tail, Duration::from_secs(15)).await; + assert_eq!(dones(&tail), vec![(DoneStatus::Completed, None)], "{tail:?}"); + assert_eq!(text_of(&tail), "reply:still alive"); +} + +/// A slow pi preflight (extensions, the Roboco MCP bridge, compaction) is +/// allowed: no fixed acceptance budget kills the run — only Stop interrupts +/// a slow acceptance. +#[tokio::test] +async fn a_slow_pi_preflight_completes_the_run() { + let (controls, steer, _token) = controls(); + drop(steer); + let started = std::time::Instant::now(); + let mut stream = harness().run(request("slow-preflight"), controls).await.unwrap(); + let mut events = Vec::new(); + until_done(&mut stream, &mut events, Duration::from_secs(60)).await; + assert!(started.elapsed() >= Duration::from_secs(31)); + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert_eq!(text_of(&events), "reply:slow-preflight"); +} + +/// Stop interrupts a slow acceptance: no fixed timeout kills the run, but +/// Stop still ends it — exactly one `Interrupted` Done, never an +/// error-then-interrupted pair from the unanswered acceptance. +#[tokio::test] +async fn stop_interrupts_a_slow_pi_preflight() { + let (controls, steer, token) = controls(); + drop(steer); + let mut stream = harness().run(request("slow-preflight"), controls).await.unwrap(); + // The acceptance is in flight (the fake holds its answer for 31 s). + tokio::time::sleep(Duration::from_millis(500)).await; + token.cancel(); + let mut events = Vec::new(); + while let Some(event) = + tokio::time::timeout(Duration::from_secs(10), stream.next()).await.expect("progress") + { + let event = event.expect("stream event"); + let done = matches!(event, AgentEvent::Done { .. }); + events.push(event); + if done { + break; + } + } + assert_eq!(dones(&events), vec![(DoneStatus::Interrupted, None)], "{events:?}"); + // The run never started: nothing streamed before the Stop. + assert!(text_of(&events).is_empty(), "{events:?}"); +} + +/// A prompt racing `agent_settled` either joins the run or starts the next +/// turn: it is delivered exactly once and the turn never strands. +#[tokio::test] +async fn a_prompt_racing_agent_settled_never_strands_the_turn() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("settle-race"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while dones(&events).len() < 2 { + let event = tokio::time::timeout(Duration::from_secs(15), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let race_now = matches!(&event, AgentEvent::TextDelta { text } if text == "reply:settle-race") + && !sent; + events.push(event); + if race_now { + // The settle probe is in flight (the fixture holds its answer + // open): the message lands behind the turn. + sent = true; + tokio::time::sleep(Duration::from_millis(150)).await; + steer.send(SteerMessage::text("race steer")).await.unwrap(); + } + } + assert_eq!( + dones(&events), + vec![(DoneStatus::Completed, None), (DoneStatus::Completed, None)], + "{events:?}" + ); + // The raced message ran — joined the run or started the next turn. + assert!(text_of(&events).contains("reply:settle-race")); + assert!(text_of(&events).contains("reply:race steer"), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Steered { .. }))); +} + +/// A settle probe that reports busy (pi still streaming) re-arms: the turn +/// ends only once pi is at rest — never stranded, never errored. +#[tokio::test] +async fn a_busy_settle_probe_re_arms_until_pi_rests() { + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end(&harness(), request("busy-probe"), controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert_eq!(text_of(&events), "reply:busy-probe"); +} + +/// A settle probe that keeps failing ends the turn ERRORED — visibly, with +/// the reason — instead of hanging or silently killing the process. +#[tokio::test] +async fn a_failing_settle_probe_ends_the_turn_errored() { + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end(&harness(), request("probe-fail"), controls).await; + let dones = dones(&events); + assert_eq!(dones.len(), 1, "{events:?}"); + assert_eq!(dones[0].0, DoneStatus::Errored); + let error = dones[0].1.as_deref().unwrap(); + assert!(error.contains("probe") && error.contains("wedged"), "{error}"); +} + +/// pi older than 0.99 reports no disposition: the driver infers from whether +/// a run starts — a running prompt settles normally, and a handled one ends +/// the turn instead of sticking on Working forever. +#[tokio::test] +async fn old_pi_without_disposition_is_inferred_not_assumed() { + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end(&harness(), request("old-pi"), controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert_eq!(text_of(&events), "reply:old-pi"); + + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("old-pi-handled"), controls).await.unwrap(); + let mut events = Vec::new(); + until_done(&mut stream, &mut events, Duration::from_secs(15)).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // The session lives on: the next message runs. + steer.send(SteerMessage::text("revived")).await.unwrap(); + let mut tail = Vec::new(); + until_done(&mut stream, &mut tail, Duration::from_secs(15)).await; + assert_eq!(dones(&tail), vec![(DoneStatus::Completed, None)], "{tail:?}"); + assert_eq!(text_of(&tail), "reply:revived"); +} diff --git a/docs/PARITY.md b/docs/PARITY.md index 72f1c289f6..b08c4e118f 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -60,7 +60,7 @@ not built yet). | Devin (ACP) | done | Shared `AcpHarness` spec; `devin acp` (Cognition's native ACP server), turn-boundary steering, no effort ladder (effort rides the advertised model ids). Unattended mode via the `bypass` mode value. Subagent lifecycle and nested transcripts ride Devin's `cognition.ai/subagentSupport` extension. **Live-verified against the real `devin` CLI 3000.6.14** (2026-09-04): model discovery (193 rows), an edit + `cargo test` turn settled on the truthful `end_turn`, `run_subagent` rendered as a link chip with a nested child transcript, stop → `session/cancel` → `stopReason: cancelled`, and `session/load` resume. | | Grok (ACP) | done | Shared `AcpHarness` spec; `grok agent stdio`, turn-boundary steering. | | Hermes (ACP) | done | Shared `AcpHarness` spec; `hermes acp` (Nous Research's native ACP server), turn-boundary steering, no effort ladder yet. | -| Pi (native RPC) | done | `pi --mode rpc` — pi's first-party JSONL protocol (the maintainer-supported embedding path): id-correlated command responses, `agent_settled` ends every turn shape deterministically, turn errors (assistant error / exhausted auto-retry / failed compaction) reject the prompt, usage/context stats from `get_session_stats` + `message_update.usage` (first-party replacements for the retired pi-acp fork's patches), mid-run steering through pi's own `steer` queue, extension UI dialogs → the question flow, `--session ` durable resume. Turn-boundary steering, minimal→max thinking ladder, live model catalog from `get_available_models` (composite `/` rows, pi-acp spelling) with `set_model` applied per run, isolated title runs (`--no-tools` + shared title instructions as the system prompt). | +| Pi (native RPC) | done | `pi --mode rpc` — pi's first-party JSONL protocol (the maintainer-supported embedding path): id-correlated command responses, `agent_settled` + a state probe (not streaming, not compacting, zero pending messages, guarded by a settle generation) ends every turn shape deterministically, turn errors (assistant error / exhausted auto-retry / failed compaction) reject the prompt, usage/context stats from `get_session_stats` + `message_update.usage` (first-party replacements for the retired pi-acp fork's patches), every prompt — turn starts and mid-run steers alike — sent as `prompt` with `streamingBehavior: "steer"` (pi starts a run when idle, queues while streaming; the acknowledgement's `disposition` is read, and a rejected prompt never ends the session — the message re-queues), extension UI dialogs → the question flow, `--session ` durable resume. Turn-boundary steering, minimal→max thinking ladder, live model catalog from `get_available_models` (composite `/` rows, pi-acp spelling) with `set_model` applied per run, isolated title runs (`--no-tools` + shared title instructions as the system prompt). | | Mock harness | done | Scripted event replay; powers tests + the e2e smoke. | ## §5 Session doc schema From f6934ce6b988c78d23954181b3b7ce7dc8fbe71a Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 12:44:25 +0700 Subject: [PATCH 05/96] fix(harness): read Claude's resetsAt as unix seconds and tighten the classified Done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the previous commit: - Claude Code's rate_limit_event resetsAt is unix SECONDS, not millis — the repo's own live 2.1.228 capture (fixtures/claude/ live-2.1.228-background-subagent.jsonl) carries 1786985400 against a session at 1786971716 (a five-hour window resetting 3.8h out; as millis it would be January 1970). The parser now converts seconds → millis like the Codex snapshot path, and the fixture/tests feed seconds. - turn/completed only empties the Done's error text when the classified Error event actually carried it (kind without a message no longer drops the text silently). - Comments: the mock knob no longer names the not-yet-defined Limited state; the unknown-window "usage" label is documented on the proto variant; window_label wording follows the glossary. - Claude's assistant rate_limit code names the plain "usage" label directly instead of fishing the fallback out of rate_window_label(""). --- crates/harness/src/claude/normalize.rs | 18 +++++++++++++----- crates/harness/src/claude/wire.rs | 4 ++-- crates/harness/src/codex/mod.rs | 4 +++- crates/harness/src/codex/normalize.rs | 4 ++-- crates/harness/src/mock.rs | 6 +++--- crates/harness/tests/fixtures/fake-claude.sh | 4 ++-- crates/proto/src/agent.rs | 10 +++++++--- 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index 9647dc3dfb..1890f05484 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -48,7 +48,9 @@ fn rate_window_label(kind: &str) -> &'static str { fn assistant_error_kind(code: &str) -> Option { match code { "rate_limit" => Some(AgentErrorKind::UsageLimit { - window: rate_window_label("").into(), + // Which window was hit is unknown at this surface — the plain + // "usage" label (the fallback window wording). + window: "usage".into(), resets_at: None, }), "authentication_failed" => Some(AgentErrorKind::SignedOut), @@ -689,7 +691,12 @@ impl Normalizer { ), kind: Some(AgentErrorKind::UsageLimit { window: window.into(), - resets_at: f.rate_limit_info.resets_at.map(|ms| ms as i64), + // The CLI reports the reset in unix seconds + // (live-verified 2.1.228); the kind carries millis. + resets_at: f + .rate_limit_info + .resets_at + .map(|secs| (secs as i64).saturating_mul(1000)), }), }] } @@ -1467,10 +1474,11 @@ mod tests { #[test] fn a_blocking_limit_event_carries_the_window_and_reset_time() { // The blocking event yields kind usage-limit with the window label - // and the CLI's own reset time (unix millis); the message is - // unchanged, so older clients render exactly as before. + // and the CLI's own reset time — unix seconds on the wire (the live + // 2.1.228 capture carries seconds), millis in the kind. The message + // is unchanged, so older clients render exactly as before. let events = normalize_one( - r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400000}}"#, + r#"{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400}}"#, ); assert_eq!( events, diff --git a/crates/harness/src/claude/wire.rs b/crates/harness/src/claude/wire.rs index 827e88a9d2..135b5bb32b 100644 --- a/crates/harness/src/claude/wire.rs +++ b/crates/harness/src/claude/wire.rs @@ -152,8 +152,8 @@ pub(crate) struct RateLimitInfo { pub status: String, #[serde(rename = "rateLimitType", default)] pub rate_limit_type: Option, - /// Unix milliseconds when the rejected window resets — the CLI reports it - /// on the blocking event; absent when unknown. + /// Unix seconds when the rejected window resets — the CLI reports it on + /// the blocking event (live-verified 2.1.228); absent when unknown. #[serde(rename = "resetsAt", default)] pub resets_at: Option, /// `allowed`/`allowed_warning` when the account may spend provisioned diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index 4783387e99..0116025c35 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -1559,6 +1559,7 @@ async fn run_session(session: Session) { // part. Unclassified failures keep their historical // Done-only shape. let kind = turn_error_kind(¶ms, limit_reset.as_ref()); + let mut text_rides_error_event = false; if let (Some(message), Some(kind)) = (&error, &kind) { let classified = AgentEvent::Error { message: message.clone(), @@ -1567,6 +1568,7 @@ async fn run_session(session: Session) { if !send(&event_tx, classified).await { break 'main; } + text_rides_error_event = true; } let status = if interrupted || stopping { DoneStatus::Interrupted @@ -1589,7 +1591,7 @@ async fn run_session(session: Session) { AgentEvent::Done { status, result: None, - error: if kind.is_some() { None } else { error }, + error: if text_rides_error_event { None } else { error }, session_id: Some(thread_id.clone()), }, ) diff --git a/crates/harness/src/codex/normalize.rs b/crates/harness/src/codex/normalize.rs index 2c951fe84e..aaeb492250 100644 --- a/crates/harness/src/codex/normalize.rs +++ b/crates/harness/src/codex/normalize.rs @@ -153,8 +153,8 @@ pub(crate) struct RateLimitReset { pub resets_at: i64, } -/// Which claude.ai-style window label a Codex window duration gets, so both -/// drivers speak the same window vocabulary. +/// Which window label a usage-window duration gets — the same 5-hour/weekly +/// vocabulary the Claude driver uses, so clients see one label scheme. fn window_label(minutes: i64) -> &'static str { match minutes { 300 => "5-hour", diff --git a/crates/harness/src/mock.rs b/crates/harness/src/mock.rs index 7f1b991a38..766d03457c 100644 --- a/crates/harness/src/mock.rs +++ b/crates/harness/src/mock.rs @@ -192,9 +192,9 @@ impl Harness for MockHarness { let (body, tail) = self.script.split_at(done_ix); let error_event = mock_error.then(|| AgentEvent::Error { message: "Claude usage limit reached — try again after the limit resets.".into(), - // Same classification the Claude driver attaches to its - // `rate_limit` error code, so engine tests can drive Limited - // state without a real CLI. + // The same classification the Claude driver attaches to its + // `rate_limit` error code, so engine-side tests can assert on the + // structured kind without a real CLI. kind: Some(AgentErrorKind::UsageLimit { window: "usage".into(), resets_at: None, diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index f7dc25e769..114cbf12e3 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -358,8 +358,8 @@ case "$first" in # Terse assistant-level error code with no content. emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}' # Hard-rejected claude.ai usage window, with the CLI's own reset time - # (unix millis). - emit '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400000}}' + # (unix seconds, as the live 2.1.228 capture carries it). + emit '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400}}' # Result error with an EMPTY errors array: needs fallback wording. emit '{"type":"result","subtype":"error_max_turns","errors":[],"usage":{"input_tokens":1,"output_tokens":2},"session_id":"sess-err"}' ;; diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index fce877633e..61fd324a5f 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -556,8 +556,9 @@ pub enum DoneStatus { #[serde(tag = "type", rename_all = "camelCase")] pub enum AgentErrorKind { /// A harness account's plan usage limit blocked the turn. The window is - /// the harness's own label ("5-hour", "weekly", …); `resets_at` is the - /// epoch-millis instant the harness reported, when it reported one. + /// the harness's own label ("5-hour", "weekly", … — "usage" when the + /// harness did not name the window); `resets_at` is the epoch-millis + /// instant the harness reported, when it reported one. #[serde(rename_all = "camelCase")] UsageLimit { window: String, @@ -794,7 +795,10 @@ mod tests { kind: Some(AgentErrorKind::SignedOut), }; let json = serde_json::to_value(&signed_out).unwrap(); - assert_eq!(json["kind"], serde_json::json!({"type": "signedOut"})); + assert_eq!( + json.get("kind"), + Some(&serde_json::json!({"type": "signedOut"})) + ); assert_eq!(serde_json::from_value::(json).unwrap(), signed_out); // An unknown reset time is omitted, not null. From dc3edf429d6df7e773092ac57b2411e1dd43d5bf Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 13:19:24 +0700 Subject: [PATCH 06/96] fix(pi): await the armed acceptance by reference --- crates/harness/src/pi/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index ab90f274ee..b402cd9394 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -1075,7 +1075,7 @@ async fn run_session(session: Session) { exit_drain_deadline.unwrap_or_else(tokio::time::Instant::now) ), if exit_drain_deadline.is_some() => break 'main, - res = async { armed.as_mut().expect("guarded by if").acceptance.await }, if armed.is_some() => { + res = async { (&mut armed.as_mut().expect("guarded by if").acceptance).await }, if armed.is_some() => { let Armed { message: mailbox, .. } = armed.take().expect("guarded by if"); match res { Ok(data) => { From 206adcfc64679b1037e77000d87df52d8a2283cd Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 13:16:05 +0700 Subject: [PATCH 07/96] feat(engine): record side-chat creators and message origins Groundwork for completion notices (orchestration v2 ticket 11), all additive on the wire (ADR 0005): - a side chat created through the Roboco MCP create_chat / create_chats by an origin chat durably records the creating chat and a notify flag (default on; notify: false opts out per request). UI forks, blank side chats and human CLI creates never carry the mark, so they never notify; - Run and Steer commands (QueueCommand originChatId), queued rows (QueueMessage originChatId) and the user message entries they write carry the originating chat as structured data - through run, steer, hold-for-turn-end, the Stop that cancels a held send, and the retry re-issue (attribution text is unchanged on top of it); - wiregen: new AgentOrigin type; Chat.agentOrigin, SessionMessageEntry / SessionCommandEntry / QueuedMessage .originChatId, MutateParams createChat.agentOrigin; - glossary: Side chat in CONTEXT.md; - ui-tests.yml: the engine-local step runs --test agent_origin. --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 4 + crates/doc/src/commands.rs | 40 + crates/doc/src/queue.rs | 35 + crates/doc/src/rebuild.rs | 2 + crates/doc/src/registry.rs | 16 + crates/doc/src/registry/tests.rs | 1 + crates/doc/src/schema.rs | 23 + crates/doc/src/transcript_delta.rs | 2 + crates/doc/src/transcript_resume.rs | 1 + crates/doc/src/workspace.rs | 11 + crates/doc/tests/attachments_roundtrip.rs | 1 + crates/engine-client/src/cache.rs | 2 +- crates/engine/src/doc_host.rs | 129 ++- crates/engine/src/rpc.rs | 34 +- crates/engine/src/sessions.rs | 63 +- crates/engine/src/transcript_history.rs | 1 + crates/engine/src/workspace_host.rs | 9 +- crates/engine/tests/acp_lifecycle.rs | 1 + crates/engine/tests/adapter_chaos_live.rs | 1 + crates/engine/tests/agent_origin.rs | 754 ++++++++++++++++++ crates/engine/tests/codex_subagents.rs | 2 + crates/engine/tests/cursor_live.rs | 1 + crates/engine/tests/diff_sync_churn.rs | 1 + crates/engine/tests/e2e.rs | 19 +- crates/engine/tests/engine_local.rs | 2 +- .../tests/m5c_accounts_uploads_titles.rs | 4 +- crates/engine/tests/message_queue.rs | 5 +- crates/engine/tests/pi_resume.rs | 2 +- crates/engine/tests/queued_attachments.rs | 2 +- crates/engine/tests/restart_resume.rs | 6 + crates/engine/tests/rich_composer_delivery.rs | 14 +- crates/engine/tests/runtime_survival.rs | 42 +- crates/engine/tests/runtime_survival_live.rs | 1 + crates/engine/tests/self_continued_quiesce.rs | 5 +- crates/engine/tests/side_chats.rs | 10 +- crates/engine/tests/subagent_idle_reap.rs | 2 +- crates/engine/tests/transcript_resume.rs | 7 +- crates/engine/tests/transcript_salvage.rs | 1 + crates/engine/tests/turn_quiesce.rs | 7 +- crates/mobile-client/src/cache.rs | 2 + crates/mobile-client/src/new_session.rs | 1 + crates/mobile-client/src/notify.rs | 1 + crates/mobile-client/src/session/composer.rs | 1 + crates/mobile-client/src/session/mod.rs | 1 + .../mobile-client/src/session/transcript.rs | 1 + .../src/workspace/change_requests.rs | 1 + crates/mobile-client/src/workspace/view.rs | 1 + crates/mobile-client/tests/notify_engine.rs | 1 + crates/mobile-client/tests/real_engine.rs | 4 +- .../mobile-client/tests/side_chats_engine.rs | 3 + crates/mobile/src/layout/mod.rs | 1 + crates/mobile/src/layout/tests.rs | 7 + crates/proto/src/entities.rs | 103 +++ crates/roboco-mcp/src/roboco.rs | 42 +- crates/roboco-mcp/src/tools.rs | 226 +++++- crates/roboco-mcp/src/transcript.rs | 1 + crates/ui/examples/appshots-fixture.rs | 2 + crates/ui/src/change_requests.rs | 1 + crates/ui/src/changes.rs | 1 + crates/ui/src/composer.rs | 5 + crates/ui/src/composer_dictation_tests.rs | 1 + crates/ui/src/files/sections.rs | 3 + crates/ui/src/links.rs | 1 + crates/ui/src/rail.rs | 1 + crates/ui/src/settings/archived.rs | 1 + crates/ui/src/shell.rs | 3 + crates/ui/src/shell/command_palette.rs | 1 + crates/ui/src/shell/sidebar_sections.rs | 1 + crates/ui/src/shell/sidebar_state_sync.rs | 1 + crates/ui/src/shell/spaces.rs | 1 + crates/ui/src/state.rs | 8 + crates/ui/src/todo_panel.rs | 2 + crates/ui/src/transcript.rs | 3 + crates/wiregen/src/main.rs | 1 + .../proto/src/generated/AgentOrigin.ts | 21 + web/packages/proto/src/generated/Chat.ts | 10 +- .../proto/src/generated/MutateParams.ts | 10 +- .../proto/src/generated/QueuedMessage.ts | 9 +- .../src/generated/SessionCommandEntry.ts | 9 +- .../src/generated/SessionMessageEntry.ts | 9 +- web/packages/proto/src/generated/index.ts | 1 + 82 files changed, 1658 insertions(+), 110 deletions(-) create mode 100644 crates/engine/tests/agent_origin.rs create mode 100644 web/packages/proto/src/generated/AgentOrigin.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 07482cfffb..51e5b74f3f 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test agent_origin # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index 81049767ce..87dd992ad1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,10 @@ _Avoid_: API version, app version (that is `version`) The durable conversation and work history with a harness. Owns its transcript, configuration, and message queue; survives harness restarts. _Avoid_: Conversation, thread, session (when it means the chat) +**Side chat**: +A chat with a parent chat: forked from it in the UI, opened blank next to it, or created by its agent through the Roboco MCP tools. Agent-created side chats record their creator; user-created ones never notify it. Lists under the parent instead of the sidebar's main list. +_Avoid_: Child chat, subagent (that is a harness-spawned agent inside a run) + **Chat status**: A chat's live run state: idle, working, awaiting input, or errored. _Avoid_: Session, state (when it means chat status) diff --git a/crates/doc/src/commands.rs b/crates/doc/src/commands.rs index f793606eeb..6eadf1a71d 100644 --- a/crates/doc/src/commands.rs +++ b/crates/doc/src/commands.rs @@ -91,6 +91,12 @@ pub struct SessionCommandEntry { pub status: SessionCommandStatus, #[serde(default)] pub resolution: Option, + /// The chat this Run/Steer was sent BY through the Roboco MCP server — + /// structured attribution (additive; ADR 0005). None for user-typed + /// sends and for rows written before the field existed. Executing it + /// stamps the same origin on the user message entry it writes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_chat_id: Option, } impl SessionCommandEntry { @@ -187,6 +193,7 @@ mod tests { expires_at: None, status: SessionCommandStatus::Pending, resolution: None, + origin_chat_id: None, } } @@ -348,6 +355,39 @@ mod tests { assert!(!can_composer_cancel(&applied, "device-a")); } + #[test] + fn run_and_steer_entries_carry_their_origin_chat_additively() { + for payload in [ + SessionCommandPayload::Run { + request: run_request(), + message_id: "m1".into(), + }, + SessionCommandPayload::Steer { + prompt: "go".into(), + message_id: Some("m2".into()), + }, + ] { + let sent = SessionCommandEntry { + origin_chat_id: Some("chat-origin-1".into()), + ..entry("c1", payload, 1_000) + }; + let value = serde_json::to_value(&sent).unwrap(); + assert_eq!(value["originChatId"], "chat-origin-1", "{value}"); + assert_eq!( + serde_json::from_value::(value).unwrap(), + sent + ); + } + // Rows from older writers (and user-typed sends) carry no origin. + let plain = steer("c1", 1_000); + let value = serde_json::to_value(&plain).unwrap(); + assert!(value.get("originChatId").is_none()); + assert_eq!( + serde_json::from_value::(value).unwrap(), + plain + ); + } + fn run_request() -> RunRequest { RunRequest { prompt: "hello".into(), diff --git a/crates/doc/src/queue.rs b/crates/doc/src/queue.rs index f6d0387e6d..ec69d89f1f 100644 --- a/crates/doc/src/queue.rs +++ b/crates/doc/src/queue.rs @@ -50,6 +50,12 @@ pub struct QueuedMessage { /// `ReviewRequired`; they never silently become sendable again. #[serde(default, skip_serializing_if = "Option::is_none")] pub delivery_gate: Option, + /// The chat this row was sent BY through the Roboco MCP server — + /// structured attribution (additive; ADR 0005). None for user-typed rows + /// and rows written before the field existed. The promoted user message + /// entry keeps it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_chat_id: Option, } /// Why a queued row is not currently eligible for automatic or explicit @@ -97,6 +103,7 @@ impl QueuedMessage { issued_at: 0, edited_at: None, delivery_gate: None, + origin_chat_id: None, } } } @@ -360,6 +367,9 @@ fn write_queued_map(map: &loro::LoroMap, item: &QueuedMessage) -> Result<(), Doc crate::schema::loro_value_from_json(&serde_json::to_value(gate)?), )?; } + if let Some(origin_chat_id) = &item.origin_chat_id { + map.insert("originChatId", origin_chat_id.as_str())?; + } Ok(()) } @@ -403,6 +413,10 @@ fn queued_from_json(v: serde_json::Value) -> Option { }) }), }, + origin_chat_id: v + .get("originChatId") + .and_then(|o| o.as_str()) + .map(str::to_string), }) } @@ -424,6 +438,7 @@ mod tests { issued_at: 1_000, edited_at: None, delivery_gate: None, + origin_chat_id: None, } } @@ -482,6 +497,26 @@ mod tests { assert!(!rows[1].hold_for_turn_end); } + #[test] + fn origin_round_trips_and_old_rows_read_as_unattributed() { + let doc = doc(); + let mut sent = item("q1", "from an agent"); + sent.origin_chat_id = Some("chat-origin-1".into()); + doc.push_queued(&sent).unwrap(); + doc.push_queued(&item("q2", "typed by the user")).unwrap(); + + let rows = doc.read_queue().unwrap(); + assert_eq!(rows[0].origin_chat_id.as_deref(), Some("chat-origin-1")); + assert_eq!(rows[1].origin_chat_id, None); + // The wire name is camelCase and additive: an old writer's row + // (simulated by dropping the field) keeps reading. + let json = serde_json::to_value(rows).unwrap(); + assert_eq!(json[0]["originChatId"], "chat-origin-1"); + assert!(json[1].get("originChatId").is_none()); + let legacy = serde_json::from_value::>(json).unwrap(); + assert_eq!(legacy[0].origin_chat_id.as_deref(), Some("chat-origin-1")); + } + #[test] fn delivery_gate_round_trips_and_finish_clears_it_atomically() { let doc = doc(); diff --git a/crates/doc/src/rebuild.rs b/crates/doc/src/rebuild.rs index a2d31e11ce..ab775826c9 100644 --- a/crates/doc/src/rebuild.rs +++ b/crates/doc/src/rebuild.rs @@ -156,6 +156,7 @@ mod tests { fn fat_entry(id: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![ @@ -195,6 +196,7 @@ mod tests { fn command(id: &str, status: SessionCommandStatus) -> SessionCommandEntry { SessionCommandEntry { + origin_chat_id: None, id: id.into(), payload: SessionCommandPayload::Interrupt {}, issued_by: "dev-b".into(), diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index aa6c959cc4..499e4fe3df 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -1240,6 +1240,14 @@ impl RegistryDoc { chat.room_gen.map(|g| json!(g)).unwrap_or(Value::Null), ), ("parentChatId", opt_str(chat.parent_chat_id.as_deref())), + ( + "agentOrigin", + chat.agent_origin + .as_ref() + .map(serde_json::to_value) + .transpose()? + .unwrap_or(Value::Null), + ), ]); self.write(KIND_CHATS, &chat.id.clone(), OpKind::Upsert, set); Ok(()) @@ -1626,6 +1634,14 @@ impl RegistryDoc { ("spaceId", opt_str(chat.space_id.as_deref())), ("lastSeenAt", opt_ms(chat.last_seen_at)), ("parentChatId", opt_str(chat.parent_chat_id.as_deref())), + ( + "agentOrigin", + chat.agent_origin + .as_ref() + .map(serde_json::to_value) + .transpose()? + .unwrap_or(Value::Null), + ), ]), ); } diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 452ebb35ca..254d3a3357 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -252,6 +252,7 @@ fn device(id: &str, name: &str) -> Device { fn chat(id: &str, device_id: &str) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: device_id.into(), title: Some("First chat".into()), diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index 998ae67a13..2a2249e6fc 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -60,6 +60,12 @@ pub struct SessionMessageEntry { /// the field existed. #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_ms: Option, + /// The chat this user entry was sent BY through the Roboco MCP server — + /// structured attribution (additive; ADR 0005), the durable record the + /// completion-notice rules key on. None on typed messages and entries + /// written before the field existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_chat_id: Option, } /// The doc-resident flat part map (`DocMessagePart` in TS). Distinct from the app-layer @@ -554,6 +560,9 @@ impl SessionDoc { .as_str() .ok_or_else(|| DocError::Schema("status not a string".into()))?, )?; + if let Some(origin_chat_id) = &entry.origin_chat_id { + map.insert("originChatId", origin_chat_id.as_str())?; + } self.doc.commit(); Ok(()) } @@ -815,6 +824,9 @@ fn write_entry_scalar_fields(map: &LoroMap, entry: &SessionMessageEntry) -> Resu if let Some(duration_ms) = entry.duration_ms { map.insert("durationMs", duration_ms)?; } + if let Some(origin_chat_id) = &entry.origin_chat_id { + map.insert("originChatId", origin_chat_id.as_str())?; + } Ok(()) } @@ -914,6 +926,8 @@ fn entry_from_json(v: serde_json::Value) -> Result, #[serde(default)] duration_ms: Option, + #[serde(default)] + origin_chat_id: Option, } match serde_json::from_value::(v.clone()) { Ok(raw) => Ok(SessionMessageEntry { @@ -925,6 +939,7 @@ fn entry_from_json(v: serde_json::Value) -> Result SegmentWriter<'a> { write_entry_scalar_fields( &map, &SessionMessageEntry { + origin_chat_id: None, id: entry_id.into(), role: MessageRole::Assistant, parts: vec![], @@ -1408,6 +1425,7 @@ mod tests { source_title: "Main conversation".into(), }; doc.push_message(&SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: "fork:side".into(), role: MessageRole::System, @@ -1468,6 +1486,7 @@ mod tests { fn user_entry(id: &str, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -1487,6 +1506,7 @@ mod tests { let doc = SessionDoc::init("whale").unwrap(); for segment in 0..4 { doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: format!("segment-{segment}"), role: MessageRole::Assistant, parts: (0..100) @@ -1732,6 +1752,7 @@ mod tests { fn resolve_input_stamps_the_part_in_place() { let doc = SessionDoc::init("chat-1").unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "m1".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Input { @@ -1981,6 +2002,7 @@ mod tests { fn pre_strip_doc_parts_still_round_trip() { let doc = SessionDoc::init("chat-3").unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "m1".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Tool { @@ -2046,6 +2068,7 @@ mod tests { use crate::commands::{SessionCommandPayload, SessionCommandStatus}; let doc = SessionDoc::init("chat-1").unwrap(); let entry = SessionCommandEntry { + origin_chat_id: None, id: "c1".into(), payload: SessionCommandPayload::Steer { prompt: "focus".into(), diff --git a/crates/doc/src/transcript_delta.rs b/crates/doc/src/transcript_delta.rs index 1e7395911f..20e4888b02 100644 --- a/crates/doc/src/transcript_delta.rs +++ b/crates/doc/src/transcript_delta.rs @@ -360,6 +360,7 @@ mod tests { fn entry(id: &str, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { @@ -545,6 +546,7 @@ mod context_update_tests { #[test] fn replay_cutoff_keeps_only_historical_parts_and_unicode_text_prefixes() { let mut entry = SessionMessageEntry { + origin_chat_id: None, id: "message".into(), role: crate::MessageRole::Assistant, parts: vec![MessagePart::Text { diff --git a/crates/doc/src/transcript_resume.rs b/crates/doc/src/transcript_resume.rs index e24b45d7c9..c98c0ca95c 100644 --- a/crates/doc/src/transcript_resume.rs +++ b/crates/doc/src/transcript_resume.rs @@ -345,6 +345,7 @@ mod tests { fn entry(id: &str, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index e8df63adb5..00f0656467 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -286,6 +286,13 @@ impl WorkspaceDoc { )?; set_opt_str(&row, "spaceId", chat.space_id.as_deref())?; set_opt_str(&row, "parentChatId", chat.parent_chat_id.as_deref())?; + match &chat.agent_origin { + Some(origin) => row.insert( + "agentOrigin", + LoroValue::from(serde_json::to_value(origin)?), + )?, + None => row.delete("agentOrigin")?, + } set_opt_ms(&row, "lastSeenAt", chat.last_seen_at)?; self.doc.commit(); Ok(()) @@ -720,6 +727,8 @@ pub(crate) struct RawChat { room_gen: Option, #[serde(default)] parent_chat_id: Option, + #[serde(default)] + agent_origin: Option, } /// Decode a chat row's `config` leniently: unknown enum values (a newer @@ -761,6 +770,7 @@ impl From for Chat { last_seen_at: raw.last_seen_at.map(dt), room_gen: raw.room_gen, parent_chat_id: raw.parent_chat_id, + agent_origin: raw.agent_origin, } } } @@ -846,6 +856,7 @@ mod tests { fn chat(id: &str, device_id: &str) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: device_id.into(), title: Some("First chat".into()), diff --git a/crates/doc/tests/attachments_roundtrip.rs b/crates/doc/tests/attachments_roundtrip.rs index 595acb0893..48a59a9ebe 100644 --- a/crates/doc/tests/attachments_roundtrip.rs +++ b/crates/doc/tests/attachments_roundtrip.rs @@ -21,6 +21,7 @@ fn run_request_attachments_survive_command_round_trip() { resume: None, }; doc.queue_command(&SessionCommandEntry { + origin_chat_id: None, id: "c1".into(), payload: SessionCommandPayload::Run { request, diff --git a/crates/engine-client/src/cache.rs b/crates/engine-client/src/cache.rs index 5e5bc6c59c..aaab337c65 100644 --- a/crates/engine-client/src/cache.rs +++ b/crates/engine-client/src/cache.rs @@ -176,7 +176,7 @@ mod tests { core.doc_host .open("same-chat") .unwrap() - .write_user_message("message", "cached history", 123) + .write_user_message("message", "cached history", 123, None) .unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("ws://{}", listener.local_addr().unwrap()); diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 9d18e6538b..6ef1746333 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -435,11 +435,14 @@ impl ChatDocHandle { /// Write a complete user message entry, idempotent by id (the client-minted message /// id — a re-executed command or optimistic echo never duplicates the entry). + /// `origin_chat_id` is the structured attribution for messages sent by a + /// chat through the Roboco MCP server; `None` for typed messages. pub fn write_user_message( &self, message_id: &str, text: &str, created_at: i64, + origin_chat_id: Option<&str>, ) -> Result<(), DocError> { if self.doc.read_entries()?.iter().any(|e| e.id == message_id) { return Ok(()); @@ -456,6 +459,7 @@ impl ChatDocHandle { status: Some(MessageStatus::Complete), continuation_of: None, duration_ms: None, + origin_chat_id: origin_chat_id.map(str::to_string), }) } @@ -1048,7 +1052,7 @@ impl DocHost { chat_id: &str, payload: SessionCommandPayload, ) -> Result { - self.queue_command_with_transfers(chat_id, payload, Vec::new()) + self.queue_command_with_transfers(chat_id, payload, Vec::new(), None) } /// [`Self::queue_command`] plus queued-attachment transfers: the command's @@ -1056,12 +1060,16 @@ impl DocHost { /// dir; when another device hosts the chat, a background task pushes them /// over the peer link (retry until they land) while the command is /// already durably queued. The send is a local write — attachment bytes - /// chase it, never gate it (2026-08-19 incident). + /// chase it, never gate it (2026-08-19 incident). `origin_chat_id` is the + /// structured attribution when a chat sent this through the Roboco MCP + /// server (Run/Steer commands only); it survives the relay and retry + /// paths and is stamped on the user message entry the command writes. pub fn queue_command_with_transfers( &self, chat_id: &str, payload: SessionCommandPayload, _transfers: Vec, + origin_chat_id: Option<&str>, ) -> Result { let handle = self.open(chat_id)?; let id = new_id(); @@ -1083,6 +1091,7 @@ impl DocHost { expires_at: Some(now + COMMAND_DEFAULT_TTL_MS), status: SessionCommandStatus::Pending, resolution: None, + origin_chat_id: origin_chat_id.map(str::to_string), }; handle.doc.queue_command(&entry)?; // Sending a message revives an archived chat: the user is acting in it @@ -1119,18 +1128,21 @@ impl DocHost { text: &str, attachments: Vec, ) -> Result { - self.queue_message_with_behavior(chat_id, text, attachments, false) + self.queue_message_with_behavior(chat_id, text, attachments, false, None) } /// Append a message while preserving the submitter's active-turn policy /// on the synchronized row. This matters when another device hosts the /// chat: the host, not the submitting UI, decides when to drain it. + /// `origin_chat_id` is the structured attribution when a chat queued this + /// through the Roboco MCP server; the promoted user message entry keeps it. pub fn queue_message_with_behavior( &self, chat_id: &str, text: &str, attachments: Vec, hold_for_turn_end: bool, + origin_chat_id: Option<&str>, ) -> Result { let handle = self.open(chat_id)?; let id = new_id(); @@ -1143,6 +1155,7 @@ impl DocHost { issued_at: now_ms(), edited_at: None, delivery_gate: None, + origin_chat_id: origin_chat_id.map(str::to_string), })?; handle.publish_queue(); // Same reasoning as a command: the user is acting in this chat again. @@ -1884,7 +1897,12 @@ impl DocHost { continue; }; changed = true; - if let Err(err) = handle.write_user_message(&row.id, &row.text, row.issued_at) { + if let Err(err) = handle.write_user_message( + &row.id, + &row.text, + row.issued_at, + row.origin_chat_id.as_deref(), + ) { tracing::warn!(chat = %handle.chat_id, error = %err, "cancelled held send write failed"); } } @@ -1926,7 +1944,12 @@ impl DocHost { self.resolve_attachment_refs(&mut prompt, &mut attachments); if send == QueueSend::Steer { match sessions - .steer(chat_id, &prompt, Some(message_id.clone())) + .steer( + chat_id, + &prompt, + Some(message_id.clone()), + item.origin_chat_id.clone(), + ) .await? { SteerOutcome::Accepted => return Ok(()), @@ -1969,8 +1992,15 @@ impl DocHost { request.resume = None; // dispatch re-derives the harness session request.attachments = attachments; let harness = self.harness_for_request(chat_id, &request); - self.dispatch_with_source_context(&sessions, chat_id, harness, request, Some(message_id)) - .await?; + self.dispatch_with_source_context( + &sessions, + chat_id, + harness, + request, + Some(message_id), + item.origin_chat_id.clone(), + ) + .await?; Ok(()) } @@ -2069,6 +2099,9 @@ impl DocHost { expires_at: Some(now + COMMAND_DEFAULT_TTL_MS), status: SessionCommandStatus::Pending, resolution: None, + // The re-issue speaks for the same message: it keeps the + // origin of the send it re-sends. + origin_chat_id: old.origin_chat_id.clone(), }; tracing::info!(chat = %chat_id, old = %old.id, new = %reissue.id, "retry re-issues a dead send attempt"); @@ -2552,9 +2585,12 @@ impl DocHost { SessionCommandPayload::Steer { prompt, .. } => prompt.clone(), _ => String::new(), }; - if let Err(err) = - handle.write_user_message(&message_id, &prompt, entry.issued_at.min(now_ms())) - { + if let Err(err) = handle.write_user_message( + &message_id, + &prompt, + entry.issued_at.min(now_ms()), + entry.origin_chat_id.as_deref(), + ) { tracing::warn!(chat = %chat_id, error = %err, "cancelled message write failed"); } tracing::info!(chat = %chat_id, "prompt sent before a Stop that has acted: cancelled"); @@ -2649,6 +2685,7 @@ impl DocHost { entry.issued_at, false, Some(&request), + entry.origin_chat_id.clone(), )?; return Ok(( SessionCommandStatus::Applied, @@ -2665,6 +2702,7 @@ impl DocHost { message_id, &request.prompt, entry.issued_at.min(now_ms()), + entry.origin_chat_id.as_deref(), ) { tracing::warn!(chat = %chat_id, error = %err, "canonical user-message write failed"); } @@ -2674,6 +2712,7 @@ impl DocHost { harness, request, Some(message_id.clone()), + entry.origin_chat_id.clone(), ) .await?; // A fresh user-authored turn is the deliberate action that @@ -2689,6 +2728,7 @@ impl DocHost { prompt, message_id.clone(), entry.issued_at, + entry.origin_chat_id.clone(), ) .await } @@ -2779,8 +2819,15 @@ impl DocHost { "orphaned input resolve failed"); } let harness = self.harness_for_request(chat_id, &request); - self.dispatch_with_source_context(sessions, chat_id, harness, request, None) - .await?; + self.dispatch_with_source_context( + sessions, + chat_id, + harness, + request, + None, + None, + ) + .await?; Ok(( SessionCommandStatus::Applied, Some("answered as new turn".into()), @@ -2805,12 +2852,19 @@ impl DocHost { prompt: &str, message_id: Option, issued_at: i64, + origin_chat_id: Option, ) -> Result<(SessionCommandStatus, Option), EngineError> { let chat_id = &handle.chat_id; // Keep upstream's queued-attachment rewrite and send-time canonical // history while preserving the personal cut's turn-boundary queue. let prompt = self.resolve_prompt_attachments(prompt); - if self.held_until_turn_end(sessions, chat_id, &prompt, message_id.as_deref())? { + if self.held_until_turn_end( + sessions, + chat_id, + &prompt, + message_id.as_deref(), + origin_chat_id.clone(), + )? { return Ok(( SessionCommandStatus::Applied, Some("held until the turn ends".into()), @@ -2821,13 +2875,23 @@ impl DocHost { // transcript copy would duplicate it until the update finishes. if !sessions.live_run_update_pending(chat_id) && let Some(message_id) = message_id.as_deref() - && let Err(err) = - handle.write_user_message(message_id, &prompt, issued_at.min(now_ms())) + && let Err(err) = handle.write_user_message( + message_id, + &prompt, + issued_at.min(now_ms()), + origin_chat_id.as_deref(), + ) { tracing::warn!(chat = %chat_id, error = %err, "canonical user-message write failed"); } match sessions - .steer_at(chat_id, &prompt, message_id.clone(), issued_at) + .steer_at( + chat_id, + &prompt, + message_id.clone(), + issued_at, + origin_chat_id.clone(), + ) .await? { SteerOutcome::Accepted => { @@ -2836,7 +2900,12 @@ impl DocHost { } SteerOutcome::NotSteerable => { if let Some(message_id) = message_id.as_deref() { - handle.write_user_message(message_id, &prompt, issued_at.min(now_ms()))?; + handle.write_user_message( + message_id, + &prompt, + issued_at.min(now_ms()), + origin_chat_id.as_deref(), + )?; } let request = sessions .last_request(chat_id) @@ -2853,8 +2922,15 @@ impl DocHost { // images; this prompt's own refs (if any) ride its text. request.attachments = Vec::new(); let harness = self.harness_for_request(chat_id, &request); - self.dispatch_with_source_context(sessions, chat_id, harness, request, message_id) - .await?; + self.dispatch_with_source_context( + sessions, + chat_id, + harness, + request, + message_id, + origin_chat_id, + ) + .await?; handle.queue_paused.store(false, Ordering::Release); Ok(( SessionCommandStatus::Applied, @@ -2863,7 +2939,7 @@ impl DocHost { } SteerOutcome::DeferredByUpdate => { let id = message_id.unwrap_or_else(new_id); - self.hold_until_update_ends(handle, &id, &prompt, issued_at)?; + self.hold_until_update_ends(handle, &id, &prompt, issued_at, origin_chat_id)?; // The completed turn's status publication normally re-drains // this queue. Also cover completion racing the enqueue itself. self.drain_queue(handle).await; @@ -2893,6 +2969,7 @@ impl DocHost { chat_id: &str, prompt: &str, message_id: Option<&str>, + origin_chat_id: Option, ) -> Result { // A persistent session parked BETWEEN turns DOES take the next prompt // through its mailbox (the warm-child fast path), so the question is @@ -2921,6 +2998,7 @@ impl DocHost { now_ms(), true, None, + origin_chat_id, )?; Ok(true) } @@ -2937,6 +3015,7 @@ impl DocHost { issued_at: i64, steer: bool, request: Option<&roboco_proto::RunRequest>, + origin_chat_id: Option, ) -> Result<(), EngineError> { let item = QueuedMessage { id: message_id.to_string(), @@ -2947,6 +3026,7 @@ impl DocHost { issued_at: issued_at.min(now_ms()), edited_at: None, delivery_gate: None, + origin_chat_id, }; if handle.doc.read_queue()?.iter().any(|row| row.id == item.id) { return Ok(()); // a redelivered command: already held @@ -2976,8 +3056,9 @@ impl DocHost { id: &str, prompt: &str, issued_at: i64, + origin_chat_id: Option, ) -> Result<(), EngineError> { - self.hold_until_turn_end(handle, id, prompt, issued_at, true, None) + self.hold_until_turn_end(handle, id, prompt, issued_at, true, None, origin_chat_id) } async fn capture_source_context(&self, cwd: &str) -> Option { @@ -3008,6 +3089,7 @@ impl DocHost { harness: HarnessId, request: roboco_proto::RunRequest, message_id: Option, + origin_chat_id: Option, ) -> Result { if let Some(workspace) = self.workspace() && let Some(context) = self.capture_source_context(&request.cwd).await @@ -3016,7 +3098,7 @@ impl DocHost { tracing::warn!(chat = %chat_id, error = %err, "conversation source stamp failed"); } sessions - .dispatch(chat_id, harness, request, message_id) + .dispatch(chat_id, harness, request, message_id, origin_chat_id) .await } @@ -3271,6 +3353,7 @@ mod transcript_revisit_tests { for i in 0..2000 { source .push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: format!("row-{i}"), role: roboco_doc::MessageRole::User, parts: vec![roboco_doc::MessagePart::Text { @@ -3308,7 +3391,7 @@ mod transcript_revisit_tests { let (_dir, host) = host(); let handle = host.open("cached").unwrap(); handle - .write_user_message("row", "locally persisted transcript", 0) + .write_user_message("row", "locally persisted transcript", 0, None) .unwrap(); let rx = handle.watch_messages(); assert_eq!(rx.borrow().entries.len(), 1); diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 7c5021b167..e70ba0fa5c 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -237,6 +237,11 @@ where struct QueueCommandParams { chat_id: String, command: SessionCommandPayload, + /// The chat this Run/Steer is sent BY through the Roboco MCP server — + /// structured attribution (additive). Absent for user-typed sends; the + /// command entry and its user message entry keep it. + #[serde(default)] + origin_chat_id: Option, /// Queued attachments (bytes already committed locally as `pending://` /// refs) the engine delivers to a remote host AFTER the command is /// durably queued — never as a gate in front of it. @@ -264,6 +269,10 @@ struct QueueMessageParams { /// supports mid-turn steering. #[serde(default)] hold_for_turn_end: bool, + /// The chat this row is sent BY through the Roboco MCP server — + /// structured attribution (additive). Absent for user-typed rows. + #[serde(default)] + origin_chat_id: Option, } #[derive(Debug, Deserialize)] @@ -645,6 +654,13 @@ pub enum MutateParams { #[serde(default)] #[ts(optional)] parent_chat_id: Option, + /// Set when an AGENT is creating this one through the Roboco MCP + /// `create_chat` / `create_chats` tools: the creating chat and its + /// notify preference (default on). Absent on UI creations — forks + /// and blank side chats never notify. + #[serde(default)] + #[ts(optional)] + agent_origin: Option, }, /// Create a space (device + folder pair). Idempotent by id; a live /// duplicate `(deviceId, path)` no-ops. `gitDetected` is seeded from the @@ -997,6 +1013,7 @@ impl EngineRpc { branch, cwd, parent_chat_id, + agent_origin, } => { self.workspace .create_chat_with_parent( @@ -1006,6 +1023,7 @@ impl EngineRpc { config, cwd, parent_chat_id, + agent_origin, ) .map_err(failed)?; if let Some(branch) = branch.as_deref().filter(|b| !b.is_empty()) { @@ -1462,7 +1480,12 @@ impl RpcService for EngineRpc { let p: QueueCommandParams = parse_params(params)?; let command_id = self .doc_host - .queue_command_with_transfers(&p.chat_id, p.command, p.transfers) + .queue_command_with_transfers( + &p.chat_id, + p.command, + p.transfers, + p.origin_chat_id.as_deref(), + ) .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&serde_json::json!({ "commandId": command_id })) } @@ -1587,6 +1610,7 @@ impl RpcService for EngineRpc { target .doc() .push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: marker_id.clone(), role: roboco_doc::MessageRole::System, @@ -1697,6 +1721,7 @@ impl RpcService for EngineRpc { &p.text, p.attachments, p.hold_for_turn_end, + p.origin_chat_id.as_deref(), ) .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&serde_json::json!({ "id": id })) @@ -3308,6 +3333,7 @@ mod tests { handle .doc() .push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: "turn".into(), role: roboco_doc::MessageRole::Assistant, parts: (0..500) @@ -3350,14 +3376,14 @@ mod tests { assert_eq!(preview["reset"][0]["parts"][0]["id"], "part-372"); // Changes between preview and subscribe must appear in the full reset. handle - .write_user_message("arrived", "new local message", 1) + .write_user_message("arrived", "new local message", 1, None) .unwrap(); let full = stream.next().await.unwrap(); assert!(full.get("historyPending").is_none()); assert_eq!(full["reset"][0]["parts"].as_array().unwrap().len(), 500); assert_eq!(full["reset"][1]["id"], "arrived"); handle - .write_user_message("live", "after attach", 2) + .write_user_message("live", "after attach", 2, None) .unwrap(); let live = tokio::time::timeout(std::time::Duration::from_secs(2), stream.next()) .await @@ -3505,6 +3531,7 @@ mod context_usage_tests { let append = |id: &str| { source .push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: roboco_doc::MessageRole::Assistant, parts: vec![roboco_doc::MessagePart::Text { @@ -3638,6 +3665,7 @@ mod context_usage_tests { .unwrap(); }); let entry = |id: &str| roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: roboco_doc::MessageRole::Assistant, parts: vec![roboco_doc::MessagePart::Text { diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index fc7d4c441b..faa783399a 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -439,15 +439,28 @@ impl SessionsEngine { /// - A live steerable run receives the prompt as its next turn via the mailbox /// (roboco's persistent-session routing); otherwise any live run is interrupted /// first — never two runtimes driving one chat. + /// + /// `origin_chat_id` is the structured attribution when a chat sent this + /// through the Roboco MCP server; the doc-host command path has usually + /// written the entry already (idempotent by id), and the queue-drain + /// path relies on this write to stamp it. pub async fn dispatch( &self, chat_id: &str, harness_id: HarnessId, request: RunRequest, message_id: Option, + origin_chat_id: Option, ) -> Result { - self.dispatch_with(chat_id, harness_id, request, message_id, false) - .await + self.dispatch_with( + chat_id, + harness_id, + request, + message_id, + origin_chat_id, + false, + ) + .await } /// [`Self::dispatch`] with the startup-crash retry marker: the retry @@ -461,9 +474,17 @@ impl SessionsEngine { harness_id: HarnessId, request: RunRequest, message_id: Option, + origin_chat_id: Option, startup_retry: bool, ) -> futures::future::BoxFuture<'a, Result> { - Box::pin(self.dispatch_inner(chat_id, harness_id, request, message_id, startup_retry)) + Box::pin(self.dispatch_inner( + chat_id, + harness_id, + request, + message_id, + origin_chat_id, + startup_retry, + )) } async fn dispatch_inner( @@ -472,6 +493,7 @@ impl SessionsEngine { harness_id: HarnessId, mut request: RunRequest, mut message_id: Option, + origin_chat_id: Option, startup_retry: bool, ) -> Result { // Project-less chats store cwd `~` (the creating device can't know the @@ -558,7 +580,12 @@ impl SessionsEngine { lock(&self.inner.last_requests).insert(chat_id.to_string(), request.clone()); } let handle = self.doc_handle(chat_id)?; - handle.write_user_message(&user_id, &request.prompt, now_ms())?; + handle.write_user_message( + &user_id, + &request.prompt, + now_ms(), + origin_chat_id.as_deref(), + )?; // A turn counts from acceptance — a prompt deferred behind an // update must not snapshot a turn that has not started. self.note_turn_start(chat_id, &request.cwd); @@ -617,7 +644,12 @@ impl SessionsEngine { let harness = self.inner.registry.resolve(harness_id)?; let handle = self.doc_handle(chat_id)?; let user_id = message_id.unwrap_or_else(new_id); - handle.write_user_message(&user_id, &request.prompt, now_ms())?; + handle.write_user_message( + &user_id, + &request.prompt, + now_ms(), + origin_chat_id.as_deref(), + )?; // Engine-owned resume (roboco sessions.ts:736 — every dispatch read the // chat's stored harness session): callers always send `resume: None`; @@ -779,18 +811,23 @@ impl SessionsEngine { chat_id: &str, prompt: &str, message_id: Option, + origin_chat_id: Option, ) -> Result { - self.steer_at(chat_id, prompt, message_id, now_ms()).await + self.steer_at(chat_id, prompt, message_id, now_ms(), origin_chat_id) + .await } /// [`Self::steer`] with the prompt's issue time: a queued row promoted /// into a live run keeps its original send time on the transcript. + /// `origin_chat_id` is the structured attribution when a chat sent this + /// through the Roboco MCP server; the user entry it writes keeps it. pub(crate) async fn steer_at( &self, chat_id: &str, prompt: &str, message_id: Option, issued_at: i64, + origin_chat_id: Option, ) -> Result { let target = lock(&self.inner.runs) .get(chat_id) @@ -857,7 +894,12 @@ impl SessionsEngine { }); }; let handle = self.doc_handle(chat_id)?; - handle.write_user_message(&user_id, prompt, issued_at.min(now_ms()))?; + handle.write_user_message( + &user_id, + prompt, + issued_at.min(now_ms()), + origin_chat_id.as_deref(), + )?; // A routed steer is a turn too. Fired here (not only on the confirmed // path) — a reclaim falls back to dispatch, which just re-snapshots. if let Some(request) = self.last_request(chat_id) { @@ -1813,6 +1855,7 @@ impl SubagentSink { tracing::warn!(doc = %self.doc_id, error = %err, "subagent segment close failed"); } let entry = SessionMessageEntry { + origin_chat_id: None, id: new_id(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -2936,7 +2979,7 @@ async fn drive_run( // The user entry write inside dispatch is idempotent by // message id; `startup_retry` makes this attempt final. if let Err(err) = engine - .dispatch_with(&chat, harness_id, retry, Some(message_id), true) + .dispatch_with(&chat, harness_id, retry, Some(message_id), None, true) .await { tracing::error!(chat = %chat, error = %err, "startup-crash retry dispatch failed"); @@ -3371,6 +3414,7 @@ mod tests { .link(); let previous = format!("Previous {skill}\nSecond line with \\ and \"quotes\""); doc.push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: "u1".into(), role: roboco_doc::MessageRole::User, parts: vec![roboco_doc::MessagePart::Text { @@ -3412,6 +3456,7 @@ mod tests { ("u3", roboco_doc::MessageRole::User, "future pending request"), ] { doc.push_message(&roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: id.into(), role, parts: vec![roboco_doc::MessagePart::Text { @@ -3802,7 +3847,7 @@ mod tests { .unwrap(); let chat = "chat-sink-spin"; core.sessions - .dispatch(chat, HarnessId::Mock, request(), None) + .dispatch(chat, HarnessId::Mock, request(), None, None) .await .unwrap(); feed.send(AgentEvent::SessionStarted { diff --git a/crates/engine/src/transcript_history.rs b/crates/engine/src/transcript_history.rs index 63945e6162..e638aea87d 100644 --- a/crates/engine/src/transcript_history.rs +++ b/crates/engine/src/transcript_history.rs @@ -141,6 +141,7 @@ mod tests { fn entry(id: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index 60fe873218..7416bd8d32 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -456,13 +456,18 @@ impl WorkspaceHost { config: Option, cwd: Option, ) -> Result<(), EngineError> { - self.create_chat_with_parent(chat_id, space_id, device_id, config, cwd, None) + self.create_chat_with_parent(chat_id, space_id, device_id, config, cwd, None, None) } /// [`create_chat`](Self::create_chat) recording the chat this one hangs /// off (`parentChatId`) — a side chat forked from it, or the chat whose /// agent spawned this one through the Roboco MCP server. Children list /// under their parent in the explorer instead of the main sidebar. + /// `agent_origin` is set when an AGENT created this one through the + /// Roboco MCP `create_chat`/`create_chats` tools (the creator chat and + /// its notify preference); absent on user-created chats, which never + /// notify. It rides the same row as `parentChatId` and survives + /// restarts with it. pub fn create_chat_with_parent( &self, chat_id: &str, @@ -471,6 +476,7 @@ impl WorkspaceHost { config: Option, cwd: Option, parent_chat_id: Option, + agent_origin: Option, ) -> Result<(), EngineError> { if self.read(|doc| doc.chat(chat_id))?.is_some() { return Ok(()); // idempotent: optimistic client retries never duplicate @@ -519,6 +525,7 @@ impl WorkspaceHost { space_id: space.as_ref().map(|s| s.id.clone()), last_seen_at: None, parent_chat_id: parent_chat_id.filter(|p| !p.trim().is_empty()), + agent_origin, }) })?; Ok(()) diff --git a/crates/engine/tests/acp_lifecycle.rs b/crates/engine/tests/acp_lifecycle.rs index 076f2ea756..452c5a4b12 100644 --- a/crates/engine/tests/acp_lifecycle.rs +++ b/crates/engine/tests/acp_lifecycle.rs @@ -40,6 +40,7 @@ async fn quiet_acp_prompt_stays_working_until_response() { let doc = handle.doc(); let queue = |id: &str, payload| { doc.queue_command(&SessionCommandEntry { + origin_chat_id: None, id: id.into(), payload, issued_by: "viewer".into(), diff --git a/crates/engine/tests/adapter_chaos_live.rs b/crates/engine/tests/adapter_chaos_live.rs index 3b2eb24cd2..69bc623ad9 100644 --- a/crates/engine/tests/adapter_chaos_live.rs +++ b/crates/engine/tests/adapter_chaos_live.rs @@ -245,6 +245,7 @@ impl Suite { }) }; Chat { + agent_origin: None, suite: self, id, sends: Default::default(), diff --git a/crates/engine/tests/agent_origin.rs b/crates/engine/tests/agent_origin.rs new file mode 100644 index 0000000000..39a78a2b2c --- /dev/null +++ b/crates/engine/tests/agent_origin.rs @@ -0,0 +1,754 @@ +//! Message origin and agent-created side chats (orchestration v2, ticket 11). +//! +//! The groundwork completion notices need, all of it additive on the wire: +//! - a side chat created through the Roboco MCP surface (`Mutate createChat` +//! with `agentOrigin`) durably records the creating chat and its notify +//! flag, and survives engine restarts; +//! - user-made chats — forks, blank side chats, plain creates — carry no +//! agent origin and never notify; +//! - Run and Steer commands sent with an origin chat (`QueueCommand +//! originChatId`), and queued rows (`QueueMessage originChatId`), stamp +//! that origin on the user message entry they write, through every +//! delivery path: run, steer, hold-for-turn-end — and the Stop that +//! cancels a held send writes it unrun with the origin kept. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{MessagePart, MessageRole, SessionMessageEntry}; +use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SandboxLevel, + SessionStatus, SteeringMode, +}; + +const CHAT: &str = "chat-origin"; +const PARENT: &str = "chat-parent-1"; + +/// A turn that runs until the test says so, so "busy" is a state the test +/// controls. Steers are accepted through the mailbox (the entry is written +/// before the harness ever sees the prompt). +struct HeldHarness { + steering: SteeringMode, + finish: tokio::sync::broadcast::Sender<()>, + prompts: Arc>>, +} + +impl HeldHarness { + fn new(steering: SteeringMode) -> Arc { + let (finish, _) = tokio::sync::broadcast::channel(16); + Arc::new(Self { + steering, + finish, + prompts: Arc::new(Mutex::new(Vec::new())), + }) + } +} + +#[async_trait] +impl Harness for HeldHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Held" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + self.steering + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + controls: RunControls, + ) -> Result>, HarnessError> { + self.prompts.lock().unwrap().push(request.prompt.clone()); + let mut finish = self.finish.subscribe(); + let mut steering = controls.steering; + let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-origin".into(), + assistant_message_id: format!("a-{}", request.prompt), + })]); + let done = futures::stream::once(async move { + loop { + tokio::select! { + _ = finish.recv() => { + return Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-origin".into()), + }); + } + steer = steering.recv() => { + if steer.is_none() { + return Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-origin".into()), + }); + } + } + } + } + }); + Ok(started.chain(done).boxed()) + } +} + +fn run_request(prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(HarnessId::Mock), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: false, + resume: None, + attachments: vec![], + worktree: None, + } +} + +async fn wait_for(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +fn setup( + steering: SteeringMode, +) -> ( + EngineCore, + Arc, + Arc>>, + std::path::PathBuf, +) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.keep(); + let harness = HeldHarness::new(steering); + let prompts = harness.prompts.clone(); + let registry = HarnessRegistry::new(); + registry.register(harness.clone()); + let core = EngineCore::assemble(&path, Arc::new(registry), HarnessId::Mock) + .expect("engine core assembles"); + (core, harness, prompts, path) +} + +fn entries(core: &EngineCore, chat: &str) -> Vec { + core.doc_host + .open(chat) + .expect("chat doc") + .doc() + .read_entries() + .expect("transcript") +} + +/// The user entry whose text is exactly `text`, with its origin chat. +fn user_entry_origin(core: &EngineCore, chat: &str, text: &str) -> Option> { + entries(core, chat) + .into_iter() + .find(|entry| { + entry.role == MessageRole::User + && entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::Text { text: body, .. } if body == text)) + }) + .map(|entry| entry.origin_chat_id) +} + +async fn wait_idle(core: &EngineCore, chat: &str) { + wait_for( + || { + core.sessions + .session_status(chat) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the chat to go idle", + ) + .await; +} + +/// An agent-made side chat records its creator and notify flag durably; the +/// row is the record completion notices will key on, so it must survive a +/// restart with the chat itself. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn agent_created_side_chats_record_their_creator_and_notify_durably() { + let (core, harness, _prompts, data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + + for (chat, notify) in [("side-notify", None), ("side-quiet", Some(false))] { + let mut params = serde_json::json!({ + "op": "createChat", + "chatId": chat, + "deviceId": core.device_id, + "parentChatId": PARENT, + "agentOrigin": { "chatId": PARENT }, + }); + if let Some(notify) = notify { + params["agentOrigin"]["notify"] = serde_json::json!(notify); + } + client + .call(roboco_rpc::methods::MUTATE, params) + .await + .expect("createChat with agentOrigin"); + } + + let notify_row = core.workspace.chat("side-notify").unwrap().unwrap(); + assert_eq!( + notify_row.agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: true, + }), + "the notify flag defaults to on" + ); + assert_eq!(notify_row.parent_chat_id.as_deref(), Some(PARENT)); + let quiet_row = core.workspace.chat("side-quiet").unwrap().unwrap(); + assert_eq!( + quiet_row.agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: false, + }), + "notify: false is honoured" + ); + + let _ = harness.finish.send(()); + core.shutdown().await; + drop(client); + + // The row is durable: the restart reads it back exactly. + let restarted = + EngineCore::assemble(&data_dir, Arc::new(HarnessRegistry::new()), HarnessId::Mock) + .unwrap(); + assert_eq!( + restarted.workspace.chat("side-notify").unwrap().unwrap().agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: true, + }) + ); + assert_eq!( + restarted.workspace.chat("side-quiet").unwrap().unwrap().agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: false, + }) + ); + restarted.shutdown().await; +} + +/// User-made chats are never marked: a fork, a blank side chat and a plain +/// create all carry no agent origin, so none of them can ever notify. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn user_created_chats_are_never_marked_agent_created() { + let (core, harness, _prompts, _data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + + // A plain create. + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": "plain", + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + // A blank side chat (the UI's "+ New Side Chat"). + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": "blank-side", + "deviceId": core.device_id, + "parentChatId": "plain", + }), + ) + .await + .expect("createChat"); + // A createChat that carries a parent but no agentOrigin (a human at a + // terminal naming a parent). + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": "human-side", + "deviceId": core.device_id, + "parentChatId": "plain", + }), + ) + .await + .expect("createChat"); + + // A fork of a chat with history: the copy lists under the parent but is + // user-made. + let source = core.doc_host.open("plain").unwrap(); + source + .doc() + .push_message(&SessionMessageEntry { + origin_chat_id: None, + id: "u1".into(), + role: MessageRole::User, + parts: vec![MessagePart::Text { + id: "t1".into(), + text: "remember this".into(), + }], + created_at: 1, + device_id: core.device_id.clone(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + }) + .unwrap(); + drop(source); + client + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "fork", "sourceChatId": "plain" }), + ) + .await + .expect("fork"); + + for chat in ["plain", "blank-side", "human-side", "fork"] { + let row = core.workspace.chat(chat).unwrap().unwrap(); + assert_eq!( + row.agent_origin, None, + "{chat} is user-created and must never be marked" + ); + } + assert_eq!( + core.workspace.chat("fork").unwrap().unwrap().parent_chat_id, + Some("plain".into()) + ); + + let _ = harness.finish.send(()); + core.shutdown().await; +} + +/// A Run command sent by a chat through the Roboco MCP surface carries its +/// origin on the ledger row AND on the user message entry it writes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_commands_stamp_their_origin_on_the_user_entry() { + let (core, harness, prompts, _data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + + let command = serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request("from the orchestrator"), + message_id: "m-run-1".into(), + }) + .unwrap(); + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": command, + "originChatId": PARENT, + }), + ) + .await + .expect("queue run"); + + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "from the orchestrator"), + "the run to dispatch", + ) + .await; + + // The ledger row keeps the origin… + let commands = core.doc_host.open(CHAT).unwrap().doc().read_commands().unwrap(); + let run = commands + .iter() + .find(|c| c.payload.kind() == roboco_doc::SessionCommandKind::Run) + .expect("run command in ledger"); + assert_eq!(run.origin_chat_id.as_deref(), Some(PARENT)); + // …and so does the user entry the run wrote. + assert_eq!( + user_entry_origin(&core, CHAT, "from the orchestrator"), + Some(Some(PARENT.into())), + "the user entry keeps the originating chat" + ); + + let _ = harness.finish.send(()); + wait_idle(&core, CHAT).await; + core.shutdown().await; +} + +/// A Steer command into a live steerable run writes its user entry with the +/// origin (the mid-run mailbox path). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn steer_commands_stamp_their_origin_on_the_user_entry() { + let (core, harness, prompts, _data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + + // Start a turn and let it hang. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request("opening turn"), + message_id: "m-open".into(), + }).unwrap(), + }), + ) + .await + .expect("queue opening run"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "opening turn"), + "the opening turn to start", + ) + .await; + + let steer = serde_json::to_value(roboco_doc::SessionCommandPayload::Steer { + prompt: "mid-run course fix".into(), + message_id: Some("m-steer-1".into()), + }) + .unwrap(); + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": steer, + "originChatId": PARENT, + }), + ) + .await + .expect("queue steer"); + + wait_for( + || user_entry_origin(&core, CHAT, "mid-run course fix").is_some(), + "the steer's user entry to land", + ) + .await; + let commands = core.doc_host.open(CHAT).unwrap().doc().read_commands().unwrap(); + let steer = commands + .iter() + .find(|c| matches!(&c.payload, roboco_doc::SessionCommandPayload::Steer { message_id, .. } if message_id.as_deref() == Some("m-steer-1"))) + .expect("steer command in ledger"); + assert_eq!(steer.origin_chat_id.as_deref(), Some(PARENT)); + assert_eq!( + user_entry_origin(&core, CHAT, "mid-run course fix"), + Some(Some(PARENT.into())) + ); + + let _ = harness.finish.send(()); + wait_idle(&core, CHAT).await; + core.shutdown().await; +} + +/// A queued row sent by a chat keeps its origin through the hold and the +/// promotion at turn end (the `mode: "queue"` delivery). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn queued_rows_keep_their_origin_when_promoted() { + let (core, harness, prompts, _data_dir) = setup(SteeringMode::TurnBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + + // Start a turn and let it hang: a turn-boundary agent holds the row. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request("opening turn"), + message_id: "m-open".into(), + }).unwrap(), + }), + ) + .await + .expect("queue opening run"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "opening turn"), + "the opening turn to start", + ) + .await; + + let queued = client + .call( + roboco_rpc::methods::QUEUE_MESSAGE, + serde_json::json!({ + "chatId": CHAT, + "text": "held from the orchestrator", + "originChatId": PARENT, + }), + ) + .await + .expect("queue message"); + let queued_id = queued["id"].as_str().unwrap().to_owned(); + + // The row itself carries the origin while it waits. + let row = core + .doc_host + .open(CHAT) + .unwrap() + .doc() + .read_queue() + .unwrap() + .into_iter() + .find(|row| row.id == queued_id) + .expect("held row"); + assert_eq!(row.origin_chat_id.as_deref(), Some(PARENT)); + + // End the turn: the row is promoted and its user entry keeps the origin. + let _ = harness.finish.send(()); + wait_for( + || user_entry_origin(&core, CHAT, "held from the orchestrator").is_some(), + "the held row to be promoted", + ) + .await; + wait_for( + || { + core.doc_host + .open(CHAT) + .unwrap() + .doc() + .read_queue() + .unwrap() + .is_empty() + }, + "the queue to empty", + ) + .await; + assert_eq!( + user_entry_origin(&core, CHAT, "held from the orchestrator"), + Some(Some(PARENT.into())) + ); + + // Let the promoted turn end too, then settle. + let _ = harness.finish.send(()); + wait_idle(&core, CHAT).await; + core.shutdown().await; +} + +/// A Stop drops the sends it cancels: the held steer leaves the queue and +/// lands in the transcript unrun — still carrying its origin chat. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_stop_cancels_a_held_send_but_keeps_its_origin() { + let (core, harness, prompts, _data_dir) = setup(SteeringMode::TurnBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + + // Start a turn and let it hang: a turn-boundary agent holds the steer. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request("opening turn"), + message_id: "m-open".into(), + }).unwrap(), + }), + ) + .await + .expect("queue opening run"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "opening turn"), + "the opening turn to start", + ) + .await; + + // A steer sent by the orchestrator mid-turn: held for the turn's end. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Steer { + prompt: "stopped mid course".into(), + message_id: Some("m-stopped".into()), + }).unwrap(), + "originChatId": PARENT, + }), + ) + .await + .expect("queue steer"); + wait_for( + || { + core.doc_host + .open(CHAT) + .unwrap() + .doc() + .read_queue() + .unwrap() + .iter() + .any(|row| row.id == "m-stopped") + }, + "the steer to be held for the turn's end", + ) + .await; + + // Stop: the held send is cancelled — it lands in the transcript unrun, + // with the origin it was sent with. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Interrupt {}) .unwrap(), + }), + ) + .await + .expect("queue stop"); + wait_for( + || user_entry_origin(&core, CHAT, "stopped mid course").is_some(), + "the cancelled steer to land unrun in the transcript", + ) + .await; + assert_eq!( + user_entry_origin(&core, CHAT, "stopped mid course"), + Some(Some(PARENT.into())) + ); + wait_for( + || { + core.doc_host + .open(CHAT) + .unwrap() + .doc() + .read_queue() + .unwrap() + .is_empty() + }, + "the cancelled row to leave the queue", + ) + .await; + assert!( + !prompts + .lock() + .unwrap() + .iter() + .any(|p| p == "stopped mid course"), + "the cancelled steer never reaches the agent" + ); + + let _ = harness.finish.send(()); + wait_idle(&core, CHAT).await; + core.shutdown().await; +} + +/// Typed messages (the composer path) never carry an origin. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn typed_sends_carry_no_origin() { + let (core, harness, prompts, _data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": CHAT, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request("typed by the user"), + message_id: "m-typed".into(), + }).unwrap(), + }), + ) + .await + .expect("queue typed run"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "typed by the user"), + "the typed run to dispatch", + ) + .await; + + assert_eq!(user_entry_origin(&core, CHAT, "typed by the user"), Some(None)); + let commands = core.doc_host.open(CHAT).unwrap().doc().read_commands().unwrap(); + assert!(commands.iter().all(|c| c.origin_chat_id.is_none())); + + let _ = harness.finish.send(()); + wait_idle(&core, CHAT).await; + core.shutdown().await; +} diff --git a/crates/engine/tests/codex_subagents.rs b/crates/engine/tests/codex_subagents.rs index 83ecc5ef3b..c1104006a7 100644 --- a/crates/engine/tests/codex_subagents.rs +++ b/crates/engine/tests/codex_subagents.rs @@ -72,6 +72,7 @@ async fn check_persistence( HarnessId::Codex, request.clone(), Some("user-prompt".into()), + None, ) .await .unwrap(); @@ -177,6 +178,7 @@ async fn check_persistence( HarnessId::Codex, request, Some("user-followup".into()), + None, ) .await .unwrap(); diff --git a/crates/engine/tests/cursor_live.rs b/crates/engine/tests/cursor_live.rs index 3c9010145b..99710756bd 100644 --- a/crates/engine/tests/cursor_live.rs +++ b/crates/engine/tests/cursor_live.rs @@ -130,6 +130,7 @@ async fn remote_steer_batch_reaches_real_muse_with_every_message() { .as_millis() as i64; for (i, token) in tokens.iter().enumerate().skip(1) { handle.doc().queue_command(&roboco_doc::SessionCommandEntry { + origin_chat_id: None, id: format!("remote-{i}"), payload: SessionCommandPayload::Steer {prompt: format!("Remember {token}. Reply with the immediately previous user token and this token. Do not use tools."), message_id: Some(format!("message-{i}"))}, issued_by: "remote-viewer".into(), issued_at: now + i as i64, diff --git a/crates/engine/tests/diff_sync_churn.rs b/crates/engine/tests/diff_sync_churn.rs index 7823f2afaa..4a74d75322 100644 --- a/crates/engine/tests/diff_sync_churn.rs +++ b/crates/engine/tests/diff_sync_churn.rs @@ -484,6 +484,7 @@ async fn archived_chats_get_no_entry_and_archiving_drops_it() { } fn chat_row(core: &EngineCore, chat_id: &str) -> roboco_proto::Chat { + agent_origin: None, core.workspace .watch_chats() .borrow() diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 2ca2aa62aa..1a818e9feb 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -284,6 +284,7 @@ async fn update_deferred_steering_preserves_the_active_turn_and_queued_prompt() HarnessId::Mock, run_request("opening"), Some("opening".into()), + None, ) .await .unwrap(); @@ -420,6 +421,7 @@ async fn pending_update_does_not_block_dispatch_or_other_harnesses() { HarnessId::ClaudeCode, run_request("waiting"), None, + None, ), ) .await @@ -434,13 +436,14 @@ async fn pending_update_does_not_block_dispatch_or_other_harnesses() { HarnessId::ClaudeCode, run_request("cancelled"), None, + None, ), ) .await .expect("dispatch must not wait for installation") .unwrap(); core.sessions - .dispatch("other", HarnessId::Codex, run_request("other"), None) + .dispatch("other", HarnessId::Codex, run_request("other"), None, None) .await .unwrap(); wait_for( @@ -477,6 +480,7 @@ fn queue_as_viewer(doc: &SessionDoc, id: &str, payload: SessionCommandPayload) { frontier: None, }); doc.queue_command(&SessionCommandEntry { + origin_chat_id: None, id: id.into(), payload, issued_by: VIEWER.into(), @@ -1227,6 +1231,7 @@ async fn recover_stale_journal_stamps_aborted_on_boot() { let doc = SessionDoc::init(CHAT).unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "m-user".into(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -1329,6 +1334,7 @@ async fn recover_stale_journal_settles_chips_in_completed_local_entries() { let doc = SessionDoc::init(CHAT).unwrap(); for (id, device) in [("local", device_id), ("remote", "other-device")] { doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![ @@ -1400,6 +1406,7 @@ async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { .unwrap() .doc() .push_message(&SessionMessageEntry { + origin_chat_id: None, id: "old-assistant".into(), role: MessageRole::Assistant, parts: vec![spawn_chip("old-spawn", SubagentStatus::Running)], @@ -1411,7 +1418,7 @@ async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { }) .unwrap(); core.sessions - .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None) .await .unwrap(); wait_for( @@ -2963,13 +2970,14 @@ async fn pending_steer_handoff_does_not_publish_a_completion() { HarnessId::Mock, run_request("redirect"), Some("user-steer".into()), + None, ) .await .unwrap(); } else { assert_eq!( core.sessions - .steer(CHAT, "redirect", Some("user-steer".into())) + .steer(CHAT, "redirect", Some("user-steer".into()), None) .await .unwrap(), roboco_engine::sessions::SteerOutcome::Accepted @@ -3080,7 +3088,7 @@ async fn generated_image_is_materialized_before_publication_and_survives_reopen( ); sessions.set_doc_host(host.clone()); sessions - .dispatch(CHAT, HarnessId::Mock, run_request("image"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("image"), None, None) .await .unwrap(); wait_for( @@ -3128,7 +3136,7 @@ async fn generated_image_is_materialized_before_publication_and_survives_reopen( ); // Resume echoes the successful completed item after Codex removed its source. sessions - .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None) .await .unwrap(); wait_for( @@ -3180,6 +3188,7 @@ async fn real_image_generation_profile_smoke() { HarnessId::Codex, run_request("Generate an image of a small green goblin using image generation."), None, + None, ) .await .unwrap(); diff --git a/crates/engine/tests/engine_local.rs b/crates/engine/tests/engine_local.rs index 5e69500d84..6873ec150f 100644 --- a/crates/engine/tests/engine_local.rs +++ b/crates/engine/tests/engine_local.rs @@ -68,7 +68,7 @@ async fn local_listener_feeds_exclude_legacy_cloud_rows_and_keep_transcripts() { core.doc_host .open("local-chat") .unwrap() - .write_user_message("m1", "saved locally", 123) + .write_user_message("m1", "saved locally", 123, None) .unwrap(); // Completed subagent/tool sidecars use the local database too. core.doc_host.upload_tool_sidecar( diff --git a/crates/engine/tests/m5c_accounts_uploads_titles.rs b/crates/engine/tests/m5c_accounts_uploads_titles.rs index 4781895067..a51ef42d04 100644 --- a/crates/engine/tests/m5c_accounts_uploads_titles.rs +++ b/crates/engine/tests/m5c_accounts_uploads_titles.rs @@ -969,7 +969,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { resume: None, }; core.sessions - .dispatch(chat_id, HarnessId::Mock, request, None) + .dispatch(chat_id, HarnessId::Mock, request, None, None) .await .expect("dispatch"); @@ -1015,7 +1015,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { resume: None, }; core.sessions - .dispatch(chat_id, HarnessId::Mock, request, None) + .dispatch(chat_id, HarnessId::Mock, request, None, None) .await .expect("second dispatch"); tokio::time::sleep(Duration::from_millis(400)).await; diff --git a/crates/engine/tests/message_queue.rs b/crates/engine/tests/message_queue.rs index dee0ae691a..bc67df5ef0 100644 --- a/crates/engine/tests/message_queue.rs +++ b/crates/engine/tests/message_queue.rs @@ -592,6 +592,7 @@ async fn batched_remote_steers_preserve_every_message_in_order_exactly_once() { handle .doc() .queue_command(&roboco_doc::SessionCommandEntry { + origin_chat_id: None, id: format!("remote-command-{i}"), payload: SessionCommandPayload::Steer { prompt: prompt.clone(), @@ -655,7 +656,7 @@ async fn queued_text_waits_for_a_steerable_turn_even_with_legacy_policy() { .queue_message(CHAT, "first queued", Vec::new()) .unwrap(); core.doc_host - .queue_message_with_behavior(CHAT, "second queued", Vec::new(), false) + .queue_message_with_behavior(CHAT, "second queued", Vec::new(), false, None) .unwrap(); tokio::time::sleep(Duration::from_millis(150)).await; assert_eq!(queue_texts(&core), vec!["first queued", "second queued"]); @@ -1147,7 +1148,7 @@ async fn explicit_queue_delivery_is_rejected_off_host_without_taking_the_row() { .expect("move chat host"); let id = core .doc_host - .queue_message_with_behavior(CHAT, "remote-only", Vec::new(), true) + .queue_message_with_behavior(CHAT, "remote-only", Vec::new(), true, None) .expect("queue held row"); let send_error = core diff --git a/crates/engine/tests/pi_resume.rs b/crates/engine/tests/pi_resume.rs index 3772d43bc3..884cb4ff0f 100644 --- a/crates/engine/tests/pi_resume.rs +++ b/crates/engine/tests/pi_resume.rs @@ -38,7 +38,7 @@ async fn pi_idle_crash_next_dispatch_resumes_the_stored_session() { resume: None, }; core.sessions - .dispatch(chat, HarnessId::Pi, req, None) + .dispatch(chat, HarnessId::Pi, req, None, None) .await .unwrap(); tokio::time::timeout(Duration::from_secs(10), async { diff --git a/crates/engine/tests/queued_attachments.rs b/crates/engine/tests/queued_attachments.rs index cec1131141..af33c29676 100644 --- a/crates/engine/tests/queued_attachments.rs +++ b/crates/engine/tests/queued_attachments.rs @@ -274,7 +274,7 @@ async fn queued_row_waits_for_attachment_bytes_then_sends_resolved_paths() { // have not landed yet: the idle chat must NOT send it. let pending_ref = "pending://att-q1/queued shot.png"; core.doc_host - .queue_message_with_behavior(CHAT, "what is this", vec![pending_ref.into()], false) + .queue_message_with_behavior(CHAT, "what is this", vec![pending_ref.into()], false, None) .expect("queue row"); tokio::time::sleep(Duration::from_millis(400)).await; assert_eq!( diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index 76114d2c4c..bff66b9c46 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -334,6 +334,7 @@ async fn kill_crash_recovers_resume_from_journal_and_stamps_aborted() { let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); let doc = SessionDoc::init(CHAT).unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-user-1".into(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -348,6 +349,7 @@ async fn kill_crash_recovers_resume_from_journal_and_stamps_aborted() { }) .unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-assistant-1".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { @@ -582,6 +584,7 @@ async fn fresh_crash_auto_resumes_and_notes_the_interruption() { let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); let doc = SessionDoc::init(CHAT).unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-user-1".into(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -596,6 +599,7 @@ async fn fresh_crash_auto_resumes_and_notes_the_interruption() { }) .unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-assistant-1".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { @@ -998,6 +1002,7 @@ async fn a_crash_before_the_reply_reached_disk_still_auto_resumes() { let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); let doc = SessionDoc::init(CHAT).unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-user-1".into(), role: MessageRole::User, parts: vec![MessagePart::Text { @@ -1098,6 +1103,7 @@ async fn a_crash_recovers_the_conversation_through_a_symlinked_folder() { let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); let doc = SessionDoc::init(CHAT).unwrap(); doc.push_message(&SessionMessageEntry { + origin_chat_id: None, id: "msg-user-1".into(), role: MessageRole::User, parts: vec![MessagePart::Text { diff --git a/crates/engine/tests/rich_composer_delivery.rs b/crates/engine/tests/rich_composer_delivery.rs index a7fbb0851c..a6926a34d3 100644 --- a/crates/engine/tests/rich_composer_delivery.rs +++ b/crates/engine/tests/rich_composer_delivery.rs @@ -278,7 +278,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let (_tmp, core, _harness, mut rx) = setup(id).await; let (raw, readable) = rich_text("first"); core.sessions - .dispatch(CHAT, id, request(&raw), None) + .dispatch(CHAT, id, request(&raw), None, None) .await .unwrap(); let Delivery::Run(run) = receive(&mut rx).await else { @@ -290,7 +290,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let (warm, readable) = rich_text("warm"); core.sessions - .dispatch(CHAT, id, request(&warm), None) + .dispatch(CHAT, id, request(&warm), None, None) .await .unwrap(); let Delivery::Steer(text, _) = receive(&mut rx).await else { @@ -301,7 +301,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let (steer, readable) = rich_text("steer"); assert!(matches!( - core.sessions.steer(CHAT, &steer, None).await.unwrap(), + core.sessions.steer(CHAT, &steer, None, None).await.unwrap(), SteerOutcome::Accepted )); let Delivery::Steer(text, _) = receive(&mut rx).await else { @@ -316,7 +316,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let mut req = request(&attached); req.attachments.push(path.to_string_lossy().into_owned()); core.sessions - .dispatch(CHAT, id, req.clone(), None) + .dispatch(CHAT, id, req.clone(), None, None) .await .unwrap(); let Delivery::Steer(text, attachments) = receive(&mut rx).await else { @@ -338,7 +338,7 @@ async fn rich_selections_are_converted_once_on_startup_retry_for_every_harness() // Startup retries are intentionally limited to an engine-injected // resume: establish a real prior session before simulating its crash. core.sessions - .dispatch(CHAT, id, request("seed session"), None) + .dispatch(CHAT, id, request("seed session"), None, None) .await .unwrap(); let Delivery::Run(_) = receive(&mut rx).await else { @@ -362,7 +362,7 @@ async fn rich_selections_are_converted_once_on_startup_retry_for_every_harness() harness.fail_start.store(true, Ordering::SeqCst); let (raw, readable) = rich_text("retry"); core.sessions - .dispatch(CHAT, id, request(&raw), Some("same-user".into())) + .dispatch(CHAT, id, request(&raw), Some("same-user".into()), None) .await .unwrap(); for _ in 0..2 { @@ -382,7 +382,7 @@ async fn queue_edits_preserve_reselected_skills_until_delivery_for_every_harness for id in HARNESSES { let (_tmp, core, _harness, mut rx) = setup(id).await; core.sessions - .dispatch(CHAT, id, request("opening"), None) + .dispatch(CHAT, id, request("opening"), None, None) .await .unwrap(); let Delivery::Run(_) = receive(&mut rx).await else { diff --git a/crates/engine/tests/runtime_survival.rs b/crates/engine/tests/runtime_survival.rs index 30993e12c3..a9538a4e10 100644 --- a/crates/engine/tests/runtime_survival.rs +++ b/crates/engine/tests/runtime_survival.rs @@ -278,7 +278,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None) + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None) .await .unwrap(); settle(&rig, 1).await; @@ -292,7 +292,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { follow_up.attachments = vec!["/tmp/shot.png".into()]; rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, follow_up, None) + .dispatch(CHAT, HarnessId::Mock, follow_up, None, None) .await .unwrap(); settle(&rig, 2).await; @@ -323,7 +323,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { other_model.model = Some("process-2".into()); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, other_model, None) + .dispatch(CHAT, HarnessId::Mock, other_model, None, None) .await .unwrap(); settle(&rig, 4).await; @@ -339,7 +339,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { let rig = rig_with(true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None) + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None) .await .unwrap(); settle(&rig, 1).await; @@ -349,7 +349,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { other_model.reasoning = Some(ReasoningLevel::Medium); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, other_model, None) + .dispatch(CHAT, HarnessId::Mock, other_model, None, None) .await .unwrap(); settle(&rig, 2).await; @@ -361,7 +361,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { again.reasoning = Some(ReasoningLevel::Medium); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, again, None) + .dispatch(CHAT, HarnessId::Mock, again, None, None) .await .unwrap(); settle(&rig, 3).await; @@ -378,7 +378,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { elsewhere.cwd = "/var".into(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, elsewhere, None) + .dispatch(CHAT, HarnessId::Mock, elsewhere, None, None) .await .unwrap(); settle(&rig, 4).await; @@ -394,7 +394,7 @@ async fn a_message_orphaned_by_a_dying_runtime_replays_as_sent() { let rig = rig_with(true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) .await .unwrap(); wait_for( @@ -408,7 +408,7 @@ async fn a_message_orphaned_by_a_dying_runtime_replays_as_sent() { sent.attachments = vec!["/tmp/shot.png".into()]; rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, sent, Some("m-orphan".into())) + .dispatch(CHAT, HarnessId::Mock, sent, Some("m-orphan".into()), None) .await .unwrap(); settle(&rig, 2).await; @@ -489,7 +489,7 @@ async fn a_send_held_for_the_turn_end_runs_as_sent_without_a_ping() { let (pings, recorder) = record_pings(&rig); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None) + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None) .await .unwrap(); wait_for( @@ -565,7 +565,7 @@ async fn stop_cancels_sends_held_for_the_stopped_turn() { let rig = rig_full(false, true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None) + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None) .await .unwrap(); wait_for( @@ -645,7 +645,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) .await .unwrap(); wait_for( @@ -666,6 +666,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { HarnessId::Mock, request("hold: sent before the stop"), Some("m-late".into()), + None, ) .await .unwrap(); @@ -684,6 +685,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { HarnessId::Mock, request("after the stop"), Some("m-next".into()), + None, ) .await .unwrap(); @@ -700,7 +702,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: build it"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: build it"), None, None) .await .unwrap(); wait_for( @@ -735,7 +737,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { ); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: again"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: again"), None, None) .await .unwrap(); wait_for( @@ -769,7 +771,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { // The next message starts a fresh process. rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("status?"), None) + .dispatch(CHAT, HarnessId::Mock, request("status?"), None, None) .await .unwrap(); settle(&rig, 3).await; @@ -819,7 +821,7 @@ async fn a_send_right_after_stop_runs_in_a_fresh_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) .await .unwrap(); wait_for( @@ -870,7 +872,7 @@ async fn a_send_accepted_by_a_stopping_runtime_is_redispatched() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) .await .unwrap(); wait_for( @@ -882,7 +884,7 @@ async fn a_send_accepted_by_a_stopping_runtime_is_redispatched() { rig.core.sessions.interrupt(CHAT), rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("racing the stop"), None) + .dispatch(CHAT, HarnessId::Mock, request("racing the stop"), None, None) ); stop.unwrap(); send.unwrap(); @@ -916,7 +918,7 @@ async fn a_steer_sent_before_stop_is_cancelled_with_it() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) .await .unwrap(); wait_for( @@ -968,6 +970,7 @@ async fn stop_ends_a_parked_runtime_holding_background_work() { HarnessId::Mock, request("launch a background scout"), None, + None, ) .await .unwrap(); @@ -1005,6 +1008,7 @@ async fn the_idle_reaper_spares_a_runtime_holding_background_work() { HarnessId::Mock, request("launch a background scout"), None, + None, ) .await .unwrap(); diff --git a/crates/engine/tests/runtime_survival_live.rs b/crates/engine/tests/runtime_survival_live.rs index 667b1f93e7..32a9576229 100644 --- a/crates/engine/tests/runtime_survival_live.rs +++ b/crates/engine/tests/runtime_survival_live.rs @@ -311,6 +311,7 @@ async fn agent_process_survives_sends_and_cancel() { &cwd, ), Some("m-2b-steer".into()), + None, ) .await .unwrap(); diff --git a/crates/engine/tests/self_continued_quiesce.rs b/crates/engine/tests/self_continued_quiesce.rs index 707d38ee12..1bb6d48036 100644 --- a/crates/engine/tests/self_continued_quiesce.rs +++ b/crates/engine/tests/self_continued_quiesce.rs @@ -212,7 +212,7 @@ async fn self_continued_turn_parks_on_the_short_window() { let rig = assemble("watch the build"); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, run_request("watch the build"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("watch the build"), None, None) .await .expect("dispatch"); @@ -258,6 +258,7 @@ async fn steered_turn_keeps_the_normal_window() { HarnessId::Mock, run_request("watch the build again"), None, + None, ) .await .expect("dispatch"); @@ -286,7 +287,7 @@ async fn steered_turn_keeps_the_normal_window() { // the short window (its Done is genuinely coming). rig.core .sessions - .steer(CHAT, "and then?", None) + .steer(CHAT, "and then?", None, None) .await .expect("steer accepted"); wait_for( diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 9f68aa5320..09fdd5e27c 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -71,6 +71,7 @@ impl Harness for Capture { } fn message(id: &str, role: MessageRole, text: &str, status: MessageStatus) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: id.into(), role, @@ -190,6 +191,7 @@ async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_sessi worktree: None, }, Some("side-user".into()), + None, ) .await .unwrap(); @@ -436,6 +438,7 @@ async fn side_turn( worktree: None, }, Some(message_id.into()), + None, ) .await .unwrap(); @@ -477,6 +480,7 @@ async fn native_commands_and_empty_side_chats_skip_the_history_wrapper() { None, Some("/tmp".into()), parent.map(str::to_owned), + None, ) .unwrap(); } @@ -664,7 +668,7 @@ async fn warm_side_chat_sends_owed_fork_history_once() { }; let dispatch = |prompt: &'static str, id: &'static str| { core.sessions - .dispatch("fork", HarnessId::Mock, request(prompt), Some(id.into())) + .dispatch("fork", HarnessId::Mock, request(prompt), Some(id.into()), None) }; dispatch("/review", "f1").await.unwrap(); assert_eq!(settle(1).await, "/review"); @@ -686,7 +690,7 @@ async fn warm_side_chat_sends_owed_fork_history_once() { ); // Delivered: an explicit steer and later sends go out bare. core.sessions - .steer("fork", "And now?", Some("f3".into())) + .steer("fork", "And now?", Some("f3".into()), None) .await .unwrap(); assert_eq!(settle(3).await, "And now?"); @@ -834,6 +838,7 @@ async fn orphaned_history_steer_still_owes_the_history() { HarnessId::Mock, request("/review"), Some("f1".into()), + None, ) .await .unwrap(); @@ -856,6 +861,7 @@ async fn orphaned_history_steer_still_owes_the_history() { HarnessId::Mock, request("What should you remember?"), Some("f2".into()), + None, ) .await .unwrap(); diff --git a/crates/engine/tests/subagent_idle_reap.rs b/crates/engine/tests/subagent_idle_reap.rs index a08e3efed6..9546add610 100644 --- a/crates/engine/tests/subagent_idle_reap.rs +++ b/crates/engine/tests/subagent_idle_reap.rs @@ -195,7 +195,7 @@ async fn park_after_spawn() -> Parked { let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock) .expect("engine core assembles"); core.sessions - .dispatch(CHAT, HarnessId::Mock, run_request("fan out"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("fan out"), None, None) .await .expect("dispatch"); diff --git a/crates/engine/tests/transcript_resume.rs b/crates/engine/tests/transcript_resume.rs index 51378ee602..611d234bca 100644 --- a/crates/engine/tests/transcript_resume.rs +++ b/crates/engine/tests/transcript_resume.rs @@ -25,6 +25,7 @@ const ENTRY_TEXT: usize = 300; fn assistant_entry(id: &str, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { @@ -132,7 +133,7 @@ async fn transcript_resume_over_the_real_listener() { let handle = core.doc_host.open(CHAT).unwrap(); for i in 0..250 { handle - .write_user_message(&format!("u{i}"), &text(i), i as i64) + .write_user_message(&format!("u{i}"), &text(i), i as i64, None) .unwrap(); handle .doc() @@ -254,7 +255,7 @@ async fn transcript_resume_over_the_real_listener() { // 5. Live updates keep carrying digests, and the client map stays exact. handle - .write_user_message("u-after", &text(999), 2_000) + .write_user_message("u-after", &text(999), 2_000, None) .unwrap(); let live_value = next_value(&mut resumed, "live delta after a write").await; let live = decode(&live_value, "live delta"); @@ -371,7 +372,7 @@ async fn transcript_resume_over_the_real_listener() { let compat_reset_value = next_value(&mut compat_rx, "compat reset").await; assert!(compat_reset_value.get("digests").is_none()); let doc = core.doc_host.open(CHAT).unwrap(); - doc.write_user_message("u-compat", &text(998), 3_000).unwrap(); + doc.write_user_message("u-compat", &text(998), 3_000, None).unwrap(); let compat_delta_value = next_value(&mut compat_rx, "compat live delta").await; assert!( compat_delta_value.get("digests").is_none(), diff --git a/crates/engine/tests/transcript_salvage.rs b/crates/engine/tests/transcript_salvage.rs index a36cb9396f..8e222c22b0 100644 --- a/crates/engine/tests/transcript_salvage.rs +++ b/crates/engine/tests/transcript_salvage.rs @@ -25,6 +25,7 @@ fn entry( status: Option, ) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role, parts: vec![MessagePart::Text { diff --git a/crates/engine/tests/turn_quiesce.rs b/crates/engine/tests/turn_quiesce.rs index f523603920..7aeb444a65 100644 --- a/crates/engine/tests/turn_quiesce.rs +++ b/crates/engine/tests/turn_quiesce.rs @@ -259,6 +259,7 @@ async fn parked_self_continuation_folds_and_requiesces() { HarnessId::Mock, run_request("pull the app and benchmark it"), None, + None, ) .await .expect("dispatch"); @@ -339,6 +340,7 @@ async fn missing_turn_end_settles_instead_of_working_forever() { HarnessId::Mock, run_request("pull the app and benchmark it"), None, + None, ) .await .expect("dispatch"); @@ -357,7 +359,7 @@ async fn missing_turn_end_settles_instead_of_working_forever() { let outcome = rig .core .sessions - .steer(CHAT, "what about now", None) + .steer(CHAT, "what about now", None, None) .await .expect("steer"); assert!(matches!(outcome, SteerOutcome::Accepted)); @@ -401,6 +403,7 @@ async fn open_tool_call_never_quiesces() { HarnessId::Mock, run_request("run the slow build"), None, + None, ) .await .expect("dispatch"); @@ -457,7 +460,7 @@ async fn stale_tool_echo_stays_parked() { let rig = assemble("do a thing"); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, run_request("do a thing"), None) + .dispatch(CHAT, HarnessId::Mock, run_request("do a thing"), None, None) .await .expect("dispatch"); diff --git a/crates/mobile-client/src/cache.rs b/crates/mobile-client/src/cache.rs index b0a95cb786..d24725d999 100644 --- a/crates/mobile-client/src/cache.rs +++ b/crates/mobile-client/src/cache.rs @@ -281,6 +281,7 @@ mod tests { use super::*; fn stub_chat(id: &str) -> roboco_proto::Chat { + agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "deviceId": "dev", @@ -353,6 +354,7 @@ mod tests { "engine", &format!("chat-{i}"), &[SessionMessageEntry { + origin_chat_id: None, id: format!("m{i}"), role: roboco_doc::MessageRole::User, parts: vec![], diff --git a/crates/mobile-client/src/new_session.rs b/crates/mobile-client/src/new_session.rs index 616cedcae7..9514adedcd 100644 --- a/crates/mobile-client/src/new_session.rs +++ b/crates/mobile-client/src/new_session.rs @@ -104,6 +104,7 @@ impl PendingChat { /// merged engine row so `build_row` needs no special case. pub(crate) fn synthetic_chat(&self, chat_id: &str) -> roboco_proto::Chat { roboco_proto::Chat { + agent_origin: None, id: chat_id.to_owned(), device_id: self.device_id.clone(), title: None, diff --git a/crates/mobile-client/src/notify.rs b/crates/mobile-client/src/notify.rs index d57b14faa5..8763415a40 100644 --- a/crates/mobile-client/src/notify.rs +++ b/crates/mobile-client/src/notify.rs @@ -697,6 +697,7 @@ mod tests { fn chat(raw: &str, title: Option<&str>, parent: Option<&str>) -> Chat { Chat { + agent_origin: None, id: raw.into(), device_id: "engine".into(), created_at: Utc::now(), diff --git a/crates/mobile-client/src/session/composer.rs b/crates/mobile-client/src/session/composer.rs index d36000f5f5..b02dac35c0 100644 --- a/crates/mobile-client/src/session/composer.rs +++ b/crates/mobile-client/src/session/composer.rs @@ -405,6 +405,7 @@ mod tests { #[test] fn open_input_finds_the_newest_unresolved_question() { let entry = |id: &str, parts: Vec| Arc::new(SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts, diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index e78d75a743..34a7dc1352 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -1685,6 +1685,7 @@ mod tests { /// One assistant entry carrying the given tool parts. fn entry(parts: Vec) -> Arc { Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "e1".into(), role: roboco_doc::MessageRole::Assistant, parts, diff --git a/crates/mobile-client/src/session/transcript.rs b/crates/mobile-client/src/session/transcript.rs index ef9c4a328b..c477d04d0b 100644 --- a/crates/mobile-client/src/session/transcript.rs +++ b/crates/mobile-client/src/session/transcript.rs @@ -535,6 +535,7 @@ mod tests { fn entry(id: &str, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: roboco_doc::MessageRole::User, parts: vec![MessagePart::Text { diff --git a/crates/mobile-client/src/workspace/change_requests.rs b/crates/mobile-client/src/workspace/change_requests.rs index 6048050aec..4f88e066dd 100644 --- a/crates/mobile-client/src/workspace/change_requests.rs +++ b/crates/mobile-client/src/workspace/change_requests.rs @@ -304,6 +304,7 @@ mod tests { fn chat(id: &str, branch: Option<&str>, archived: bool) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: "dev-1".into(), title: None, diff --git a/crates/mobile-client/src/workspace/view.rs b/crates/mobile-client/src/workspace/view.rs index 91e6351b52..c9a8ffa5f8 100644 --- a/crates/mobile-client/src/workspace/view.rs +++ b/crates/mobile-client/src/workspace/view.rs @@ -1250,6 +1250,7 @@ mod tests { fn chat(id: &str) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: "dev-1".into(), title: None, diff --git a/crates/mobile-client/tests/notify_engine.rs b/crates/mobile-client/tests/notify_engine.rs index 1c1add72fe..f2bb728747 100644 --- a/crates/mobile-client/tests/notify_engine.rs +++ b/crates/mobile-client/tests/notify_engine.rs @@ -432,6 +432,7 @@ async fn side_chats_never_emit() { None, None, Some("same-chat".to_owned()), + None, ) .unwrap(); let side = rig.client.scope(&rig.engine_id, "side-chat"); diff --git a/crates/mobile-client/tests/real_engine.rs b/crates/mobile-client/tests/real_engine.rs index a5ed8de5ea..c5de41c80e 100644 --- a/crates/mobile-client/tests/real_engine.rs +++ b/crates/mobile-client/tests/real_engine.rs @@ -768,7 +768,7 @@ async fn scoped_ids_feed_watches_and_transcript_connections_work() { core.doc_host .open("same-chat") .unwrap() - .write_user_message("m1", "hello", 123) + .write_user_message("m1", "hello", 123, None) .unwrap(); let listener = serve(&core, engine_dir.path()).await; let url = pairing_url(&listener, engine_dir.path(), None); @@ -929,7 +929,7 @@ async fn large_reset_streams_intact_while_calls_stay_fast() { core.doc_host .open("same-chat") .unwrap() - .write_user_message("big-message", &big, 123) + .write_user_message("big-message", &big, 123, None) .unwrap(); let listener = serve(&core, engine_dir.path()).await; let url = pairing_url(&listener, engine_dir.path(), None); diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index a3bc8aba8a..1f29d7322a 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -270,6 +270,7 @@ async fn rig_with_background( }), Some("/checkout/parent".into()), None, + None, ) .unwrap(); let listener = serve(&core, engine_dir.path()).await; @@ -355,6 +356,7 @@ impl Rig { /// The engine's own row for a raw chat id. fn engine_row(&self, raw: &str) -> roboco_proto::Chat { + agent_origin: None, self.core .workspace .chat(raw) @@ -691,6 +693,7 @@ async fn the_subagent_doc_streams_and_counts_while_it_runs() { .unwrap() .doc() .push_message(&roboco_doc::schema::SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: "resumed".into(), role: roboco_doc::MessageRole::Assistant, diff --git a/crates/mobile/src/layout/mod.rs b/crates/mobile/src/layout/mod.rs index 5f41b03c78..4e6ecfbed4 100644 --- a/crates/mobile/src/layout/mod.rs +++ b/crates/mobile/src/layout/mod.rs @@ -541,6 +541,7 @@ pub(crate) fn debug_input(entries: Vec, working: bool) -> Transcript .into_iter() .map(|e| { Arc::new(SessionMessageEntry { + origin_chat_id: None, parts: vec![MessagePart::Text { id: "t0".into(), text: e.text, diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index f7964d1be2..0418c25795 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -244,6 +244,7 @@ fn thinking_renders_styled_markdown_not_markers() { let mut w = worker(390.0); w.input = TranscriptInput { entries: vec![Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Reasoning { id: "r0".into(), text: reasoning.into() }], @@ -284,6 +285,7 @@ fn thinking_renders_styled_markdown_not_markers() { let mut w = worker(width); w.input = TranscriptInput { entries: vec![Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Reasoning { id: "r0".into(), text: reasoning.into() }], @@ -317,6 +319,7 @@ fn streaming_thought_markdown_settles_to_the_fresh_parse() { let text = "**Checking** the `parser`\n\n1. first step\n2. second with [docs](https://example.com)\n\n> note\n\n```rust\nfn main() {}\n```"; let entry = |status: MessageStatus, text: &str| { Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Reasoning { id: "r0".into(), text: text.to_owned() }], @@ -367,6 +370,7 @@ fn thought_input(text: &str, streaming: bool) -> TranscriptInput { use roboco_doc::schema::{MessageRole, SessionMessageEntry}; TranscriptInput { entries: vec![Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Reasoning { id: "r0".into(), text: text.to_owned() }], @@ -509,6 +513,7 @@ fn bench_layout_passes() { text.push_str(["word ", "more ", "text\n\n", "`code` ", "**bold** "][i % 5]); let mut e = entries.clone(); e.push(Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "live".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { id: "t0".into(), text: text.clone() }], @@ -642,6 +647,7 @@ fn running_subagent_shows_a_spinner_after_its_spawn_resolves() { let mut w = worker(390.0); w.input = TranscriptInput { entries: vec![Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![spawn(status)], @@ -693,6 +699,7 @@ fn spawn_cards_with_a_doc_ref_become_subagent_links() { let mut w = worker(390.0); w.input = TranscriptInput { entries: vec![Arc::new(SessionMessageEntry { + origin_chat_id: None, id: "a".into(), role: MessageRole::Assistant, parts: vec![part], diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index c7cabf4b3c..6c791c6b0f 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -123,6 +123,28 @@ pub struct ConversationSourceContext { pub observed_at: DateTime, } +/// Durable record that an AGENT created a chat through the Roboco MCP +/// `create_chat` / `create_chats` tools: the chat whose agent ran the tool, +/// and whether that chat wants completion notices for this one. Written once +/// at creation; the engine's completion-notice machinery (spec: orchestration +/// v2) keys on it. Absent on user-created chats (UI forks, blank side chats, +/// human CLI sends) and on standalone chats — those never notify. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct AgentOrigin { + /// The chat whose agent created this one (the Roboco MCP origin chat). + pub chat_id: String, + /// Whether the creator is notified when this chat's turns settle. + /// Defaults to on; `notify: false` opts out at creation. A missing flag + /// (an older writer) reads as on. + #[serde(default = "default_true")] + pub notify: bool, +} + +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct Chat { @@ -178,6 +200,12 @@ pub struct Chat { /// deleted) is tolerated rather than cascaded. #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_chat_id: Option, + /// Set when an agent — not the user — created this chat through the + /// Roboco MCP tools: which chat ran `create_chat`, and whether it is owed + /// completion notices. Additive (ADR 0005): rows from older writers read + /// as not agent-created and never notify. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_origin: Option, } impl Chat { @@ -1564,4 +1592,79 @@ mod tests { assert!(encoded.get("setupError").is_none()); assert!(serde_json::from_value::(encoded).is_ok()); } + + #[test] + fn agent_origin_is_camel_case_additive_and_defaults_notify_on() { + let origin = AgentOrigin { + chat_id: "chat-origin-1".into(), + notify: false, + }; + let value = serde_json::to_value(&origin).unwrap(); + assert_eq!(value["chatId"], "chat-origin-1"); + assert_eq!(value["notify"], false); + assert_eq!( + serde_json::from_value::(value).unwrap(), + origin + ); + // A missing flag (older writer) reads as notify: on. + assert_eq!( + serde_json::from_value::(serde_json::json!({ + "chatId": "chat-origin-1" + })) + .unwrap() + .notify, + true + ); + } + + #[test] + fn chat_row_carries_agent_origin_and_omits_it_when_absent() { + let chat = Chat { + id: "chat-side-1".into(), + device_id: "device-1".into(), + title: None, + archived: false, + cwd: None, + branch: None, + checkout_id: None, + source_context: None, + config: None, + last_message_preview: None, + last_message_at: None, + created_at: Utc.with_ymd_and_hms(2026, 10, 16, 12, 0, 0).unwrap(), + harness_session_id: None, + harness_session_cwd: None, + space_id: None, + last_seen_at: None, + room_gen: None, + parent_chat_id: Some("chat-parent-1".into()), + agent_origin: Some(AgentOrigin { + chat_id: "chat-parent-1".into(), + notify: true, + }), + }; + let value = serde_json::to_value(&chat).unwrap(); + assert_eq!(value["agentOrigin"]["chatId"], "chat-parent-1"); + assert_eq!(value["agentOrigin"]["notify"], true); + assert_eq!(serde_json::from_value::(value).unwrap(), chat); + // Additive: a row from an older writer carries no agentOrigin and + // reads as not agent-created. + let legacy = serde_json::json!({ + "id": "chat-old-1", + "deviceId": "device-1", + "title": null, + "archived": false, + "cwd": null, + "branch": null, + "checkoutId": null, + "config": null, + "lastMessagePreview": null, + "lastMessageAt": null, + "createdAt": "2026-10-16T12:00:00Z", + }); + let old = serde_json::from_value::(legacy).unwrap(); + assert_eq!(old.agent_origin, None); + let encoded = serde_json::to_value(&old).unwrap(); + assert!(encoded.get("agentOrigin").is_none()); + } } diff --git a/crates/roboco-mcp/src/roboco.rs b/crates/roboco-mcp/src/roboco.rs index fa291f7932..d326d34e7c 100644 --- a/crates/roboco-mcp/src/roboco.rs +++ b/crates/roboco-mcp/src/roboco.rs @@ -295,18 +295,21 @@ impl Roboco { } /// Durable command into the chat doc; the host device drains it. + /// `origin_chat_id` is the structured attribution when a chat is sending + /// this through the Roboco MCP server (Run/Steer commands); `None` for + /// control commands (interrupt, respond-input). pub async fn queue_command( &self, chat_id: &str, payload: &SessionCommandPayload, + origin_chat_id: Option<&str>, ) -> anyhow::Result { let command = serde_json::to_value(payload).context("serialize command")?; - let reply = self - .call( - methods::QUEUE_COMMAND, - json!({ "chatId": chat_id, "command": command }), - ) - .await?; + let mut params = json!({ "chatId": chat_id, "command": command }); + if let Some(origin_chat_id) = origin_chat_id { + params["originChatId"] = json!(origin_chat_id); + } + let reply = self.call(methods::QUEUE_COMMAND, params).await?; Ok(reply .get("commandId") .and_then(Value::as_str) @@ -315,14 +318,24 @@ impl Roboco { } /// Queue-row send for a busy chat whose harness cannot steer mid-turn: - /// the host promotes it when the live turn ends. - pub async fn queue_message(&self, chat_id: &str, text: &str) -> anyhow::Result { - let reply = self - .call( - methods::QUEUE_MESSAGE, - json!({ "chatId": chat_id, "text": text, "holdForTurnEnd": true }), - ) - .await?; + /// the host promotes it when the live turn ends. `origin_chat_id` is the + /// structured attribution when a chat is sending this through the Roboco + /// MCP server. + pub async fn queue_message( + &self, + chat_id: &str, + text: &str, + origin_chat_id: Option<&str>, + ) -> anyhow::Result { + let mut params = json!({ + "chatId": chat_id, + "text": text, + "holdForTurnEnd": true, + }); + if let Some(origin_chat_id) = origin_chat_id { + params["originChatId"] = json!(origin_chat_id); + } + let reply = self.call(methods::QUEUE_MESSAGE, params).await?; Ok(reply .get("id") .and_then(Value::as_str) @@ -686,6 +699,7 @@ mod tests { fn chat(id: &str, title: Option<&str>) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: "dev".into(), title: title.map(str::to_owned), diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 104b320582..7e966fe999 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -138,6 +138,7 @@ fn catalog() -> Vec { "cwd": { "type": "string", "description": "Working directory override (an existing worktree path). Defaults to the project folder." }, "prompt": { "type": "string", "description": "First message to send right away." }, "wait": { "type": "boolean", "default": false, "description": "With prompt: block until the first turn finishes and return the reply." }, + "notify": { "type": "boolean", "default": true, "description": "For side chats you create: whether you want a completion notice when the chat finishes, fails, stops or needs input (default on). Pass false for fire-and-forget children." }, "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 600 } } }), @@ -309,6 +310,10 @@ struct CreateChatArgs { prompt: Option, #[serde(default)] wait: bool, + /// Opt out of completion notices for this side chat (default on). Only + /// meaningful when an agent is the creator; user-made chats never notify. + #[serde(default)] + notify: Option, timeout_secs: Option, } @@ -719,6 +724,19 @@ impl Tools { "Cannot create a child of a side chat. Choose a top-level parent chat." ); } + + // Agent-created side chats record their creator durably: the engine's + // completion notices key on it. Only when THIS server speaks for a + // chat (an agent ran the tool) — a human at a CLI or the UI never + // marks a chat, so forks and blank side chats never notify. The + // origin check above already proved that chat is top-level. + let agent_origin = match (kind, self.roboco.origin().chat_id.as_deref()) { + (ChatKind::Side, Some(origin_id)) => Some(roboco_proto::AgentOrigin { + chat_id: origin_id.to_owned(), + notify: args.notify.unwrap_or(true), + }), + _ => None, + }; // Engine-local target resolution: the project within the selected // device (the local engine when neither is given). Upstream validates // the *remote* host's catalogs here; ours is always this engine, so @@ -789,6 +807,12 @@ impl Tools { if let Some(parent) = &parent_chat_id { mutate["parentChatId"] = json!(parent); } + if let Some(origin) = &agent_origin { + mutate["agentOrigin"] = json!({ + "chatId": origin.chat_id, + "notify": origin.notify, + }); + } if let Some(branch) = args .branch .as_deref() @@ -822,11 +846,13 @@ impl Tools { "reasoning": reasoning, "title": args.title, "parentChatId": parent_chat_id, + "agentOrigin": agent_origin, }); if let Some(prompt) = args.prompt.filter(|p| !p.trim().is_empty()) { // The row may not have folded into WatchChats yet; build the // chat locally from what we just wrote rather than re-reading. let chat = Chat { + agent_origin: agent_origin.clone(), id: chat_id.clone(), device_id: device_id.clone(), title: args.title.clone(), @@ -989,7 +1015,7 @@ impl Tools { let chat = self.roboco.resolve_chat(&args.chat).await?; let command_id = self .roboco - .queue_command(&chat.id, &SessionCommandPayload::Interrupt {}) + .queue_command(&chat.id, &SessionCommandPayload::Interrupt {}, None) .await?; Ok(json!({ "chatId": chat.id, "commandId": command_id })) } @@ -1031,6 +1057,7 @@ impl Tools { request_id: request_id.clone(), answers, }, + None, ) .await?; Ok(json!({ "chatId": chat.id, "requestId": request_id, "commandId": command_id })) @@ -1064,7 +1091,10 @@ impl Tools { ) } - /// Pick and perform the delivery the composer would. + /// Pick and perform the delivery the composer would. Run and steer + /// commands (and queue rows) carry this server's origin chat as + /// structured attribution — the durable record the engine's completion + /// notices key on — in addition to the text attribution in `text`. async fn deliver( &self, chat: &Chat, @@ -1130,6 +1160,7 @@ impl Tools { request, message_id: uuid::Uuid::new_v4().to_string(), }, + self.roboco.origin().chat_id.as_deref(), ) .await? } @@ -1141,10 +1172,19 @@ impl Tools { prompt: text, message_id: Some(uuid::Uuid::new_v4().to_string()), }, + self.roboco.origin().chat_id.as_deref(), + ) + .await? + } + _ => { + self.roboco + .queue_message( + &chat.id, + &text, + self.roboco.origin().chat_id.as_deref(), ) .await? } - _ => self.roboco.queue_message(&chat.id, &text).await?, }; Ok(json!({ "delivery": chosen, @@ -1518,6 +1558,186 @@ mod tests { assert_eq!(writes[2].1["command"]["request"]["prompt"], "go"); } + #[tokio::test] + async fn create_chat_marks_agent_created_side_chats_with_the_creator_and_notify() { + let world = Arc::new(World::default()); + let agent = tools( + world.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + // An agent's side chat records the creator and the notify default (on). + let created = agent + .call("create_chat", json!({ "project": "space-1" })) + .await + .unwrap(); + assert_eq!( + created["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": true }) + ); + assert_eq!( + world.writes.lock().unwrap()[0].1["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": true }) + ); + + // `notify: false` opts out at creation. + let created = agent + .call( + "create_chat", + json!({ "project": "space-1", "notify": false }), + ) + .await + .unwrap(); + assert_eq!( + created["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": false }) + ); + assert_eq!( + world.writes.lock().unwrap()[1].1["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": false }) + ); + + // A standalone chat has no parent, so no agent origin either. + let created = agent + .call("create_chat", json!({ "kind": "chat" })) + .await + .unwrap(); + assert_eq!(created["kind"], "chat"); + assert!(created["agentOrigin"].is_null()); + assert!(world.writes.lock().unwrap()[2].1.get("agentOrigin").is_none()); + + // A human at a terminal (no origin) never marks a chat, even when a + // parent is named: user-made side chats never notify. + let human = tools(world.clone(), Origin::default()); + let created = human + .call( + "create_chat", + json!({ "project": "space-1", "parent": "Alpha" }), + ) + .await + .unwrap(); + assert_eq!(created["parentChatId"], "chat-alpha-1"); + assert!(created["agentOrigin"].is_null()); + assert!(world.writes.lock().unwrap()[3].1.get("agentOrigin").is_none()); + + // The notify option rides each create_chats request. + let batch = agent + .call( + "create_chats", + json!({ "requests": [ + { "project": "space-1" }, + { "project": "space-1", "notify": false } + ]}), + ) + .await + .unwrap(); + let results = batch["results"].as_array().unwrap(); + assert_eq!(results[0]["isError"], false); + assert_eq!( + results[0]["result"]["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": true }) + ); + assert_eq!(results[1]["isError"], false); + assert_eq!( + results[1]["result"]["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": false }) + ); + let writes = world.writes.lock().unwrap(); + assert_eq!( + writes[4].1["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": true }) + ); + assert_eq!( + writes[5].1["agentOrigin"], + json!({ "chatId": "chat-beta-2", "notify": false }) + ); + } + + #[tokio::test] + async fn sends_carry_the_origin_chat_as_structured_data() { + let world = Arc::new(World::default()); + let agent = tools( + world.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + // Run command. + let sent = agent + .call( + "send_message", + json!({ "chat": "alpha", "text": "please review" }), + ) + .await + .unwrap(); + assert_eq!(sent["sent"]["delivery"], "run"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[0].0, methods::QUEUE_COMMAND); + assert_eq!(writes[0].1["chatId"], "chat-alpha-1"); + assert_eq!(writes[0].1["originChatId"], "chat-beta-2"); + assert_eq!(writes[0].1["command"]["kind"], "run"); + drop(writes); + + // Steer command. + let sent = agent + .call( + "send_message", + json!({ "chat": "alpha", "text": "mid-run course fix", "mode": "steer" }), + ) + .await + .unwrap(); + assert_eq!(sent["sent"]["delivery"], "steer"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[1].0, methods::QUEUE_COMMAND); + assert_eq!(writes[1].1["originChatId"], "chat-beta-2"); + assert_eq!(writes[1].1["command"]["kind"], "steer"); + drop(writes); + + // Held queue row. + let sent = agent + .call( + "send_message", + json!({ "chat": "alpha", "text": "for later", "mode": "queue" }), + ) + .await + .unwrap(); + assert_eq!(sent["sent"]["delivery"], "queue"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[2].0, methods::QUEUE_MESSAGE); + assert_eq!(writes[2].1["chatId"], "chat-alpha-1"); + assert_eq!(writes[2].1["originChatId"], "chat-beta-2"); + drop(writes); + + // Control commands carry no origin: they are not user messages. + agent + .call("interrupt_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[3].0, methods::QUEUE_COMMAND); + assert_eq!(writes[3].1["command"]["kind"], "interrupt"); + assert!(writes[3].1.get("originChatId").is_none()); + drop(writes); + + // A human at a terminal sends without structured attribution (the + // text stays as typed). + let human = tools(world.clone(), Origin::default()); + human + .call("send_message", json!({ "chat": "alpha", "text": "typed" })) + .await + .unwrap(); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[4].0, methods::QUEUE_COMMAND); + assert!(writes[4].1.get("originChatId").is_none()); + let prompt = writes[4].1["command"]["request"]["prompt"] + .as_str() + .unwrap(); + assert_eq!(prompt, "typed"); + } + #[tokio::test] async fn create_chat_records_the_origin_as_parent() { let world = Arc::new(World::default()); diff --git a/crates/roboco-mcp/src/transcript.rs b/crates/roboco-mcp/src/transcript.rs index 2d82c3c562..929a218f00 100644 --- a/crates/roboco-mcp/src/transcript.rs +++ b/crates/roboco-mcp/src/transcript.rs @@ -225,6 +225,7 @@ mod tests { fn entry(id: &str, role: MessageRole, parts: Vec) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: id.into(), role, diff --git a/crates/ui/examples/appshots-fixture.rs b/crates/ui/examples/appshots-fixture.rs index 05afe1c013..7652518b29 100644 --- a/crates/ui/examples/appshots-fixture.rs +++ b/crates/ui/examples/appshots-fixture.rs @@ -135,6 +135,7 @@ fn main() -> anyhow::Result<()> { let message = attachments::with_attachments(&body, &paths); let queue = vec![ roboco_doc::QueuedMessage { + origin_chat_id: None, id: "queued-review".into(), text: body, attachments: paths.clone(), @@ -145,6 +146,7 @@ fn main() -> anyhow::Result<()> { delivery_gate: None, }, roboco_doc::QueuedMessage { + origin_chat_id: None, id: "queued-text".into(), text: "Then check the spacing and keyboard navigation.".into(), attachments: vec![], diff --git a/crates/ui/src/change_requests.rs b/crates/ui/src/change_requests.rs index 7b30d3e3b8..8df70f1ac6 100644 --- a/crates/ui/src/change_requests.rs +++ b/crates/ui/src/change_requests.rs @@ -388,6 +388,7 @@ mod tests { fn chat(id: &str, device: &str, cwd: Option<&str>, checkout: Option<&str>) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: device.into(), title: None, diff --git a/crates/ui/src/changes.rs b/crates/ui/src/changes.rs index ae6682d446..da067b23d4 100644 --- a/crates/ui/src/changes.rs +++ b/crates/ui/src/changes.rs @@ -6026,6 +6026,7 @@ rename to new_name.rs fn chat(checkout: Option<&str>, device: &str, cwd: Option<&str>) -> Chat { Chat { + agent_origin: None, id: "c1".into(), device_id: device.into(), title: None, diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index b047338059..6356babc86 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8250,6 +8250,7 @@ impl Composer { // Optimistic echo (client-minted id doubles as the persisted message id, // so the doc frame dedups it away). let echo = SessionMessageEntry { + origin_chat_id: None, id: message_id.clone(), role: roboco_doc::MessageRole::User, parts: vec![MessagePart::Text { @@ -8416,6 +8417,7 @@ impl Composer { // its queue row is the only representation until dispatch. if should_publish_optimistic_echo(queue) { let refreshed = SessionMessageEntry { + origin_chat_id: None, id: message_id.clone(), role: roboco_doc::MessageRole::User, parts: vec![MessagePart::Text { @@ -15132,6 +15134,7 @@ mod tests { resolved: false, }; let entry = |status: Option, parts: Vec| SessionMessageEntry { + origin_chat_id: None, id: "m".into(), role: MessageRole::Assistant, parts, @@ -15166,6 +15169,7 @@ mod tests { let t = vec![ entry(Some(MessageStatus::Aborted), vec![input_part.clone()]), SessionMessageEntry { + origin_chat_id: None, id: "m2".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Text { @@ -15199,6 +15203,7 @@ mod tests { // found (a last-entry-only read vanished the panel exactly when the // user typed, bricking the answer flow). let user_echo = SessionMessageEntry { + origin_chat_id: None, id: "u2".into(), role: MessageRole::User, parts: vec![MessagePart::Text { diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs index ca962d9727..ce6a7a945b 100644 --- a/crates/ui/src/composer_dictation_tests.rs +++ b/crates/ui/src/composer_dictation_tests.rs @@ -623,6 +623,7 @@ fn dictation_question_takeover_stops_capture_without_losing_draft(cx: &mut TestA }); state.update(cx, |state, cx| { state.transcript = vec![SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: "question-message".into(), role: MessageRole::Assistant, diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs index 18f88f3d57..b3881a936e 100644 --- a/crates/ui/src/files/sections.rs +++ b/crates/ui/src/files/sections.rs @@ -1179,6 +1179,7 @@ mod tests { fn entry(parts: Vec) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, duration_ms: None, id: "e1".into(), role: MessageRole::Assistant, @@ -1235,6 +1236,7 @@ mod tests { #[test] fn subagent_rows_are_most_recently_updated_first() { let at = |id: &str, minutes_ago: i64, parts| SessionMessageEntry { + origin_chat_id: None, id: id.into(), created_at: (Utc::now() - chrono::Duration::minutes(minutes_ago)).timestamp_millis(), ..entry(parts) @@ -1293,6 +1295,7 @@ mod tests { #[test] fn running_subagents_lead_longest_running_first() { let at = |id: &str, minutes_ago: i64, parts| SessionMessageEntry { + origin_chat_id: None, id: id.into(), created_at: (Utc::now() - chrono::Duration::minutes(minutes_ago)).timestamp_millis(), ..entry(parts) diff --git a/crates/ui/src/links.rs b/crates/ui/src/links.rs index be5ced254d..b2d9155b79 100644 --- a/crates/ui/src/links.rs +++ b/crates/ui/src/links.rs @@ -114,6 +114,7 @@ mod tests { fn harness_chat(harness: HarnessId) -> Chat { Chat { + agent_origin: None, id: "chat".into(), device_id: "device".into(), title: None, diff --git a/crates/ui/src/rail.rs b/crates/ui/src/rail.rs index 36f5abdaef..23a6aee8a8 100644 --- a/crates/ui/src/rail.rs +++ b/crates/ui/src/rail.rs @@ -580,6 +580,7 @@ mod tests { fn entry(id: &str, role: MessageRole, text: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role, parts: vec![MessagePart::Text { diff --git a/crates/ui/src/settings/archived.rs b/crates/ui/src/settings/archived.rs index 94bb7a295f..89cdc30c59 100644 --- a/crates/ui/src/settings/archived.rs +++ b/crates/ui/src/settings/archived.rs @@ -348,6 +348,7 @@ mod tests { fn chat(id: &str, archived: bool) -> Chat { Chat { + agent_origin: None, id: id.into(), device_id: "d".into(), title: None, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 657375d463..62f902ac92 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2395,6 +2395,7 @@ impl Shell { std::slice::from_ref(&pending_path), ); let echo = roboco_doc::SessionMessageEntry { + origin_chat_id: None, id: "demo-upload-echo".into(), role: roboco_doc::MessageRole::User, parts: vec![roboco_doc::MessagePart::Text { @@ -11495,6 +11496,7 @@ mod tests { source: Option<(&str, &str)>, ) -> roboco_proto::Chat { roboco_proto::Chat { + agent_origin: None, id: "chat".into(), device_id: "remote-device".into(), title: None, @@ -12583,6 +12585,7 @@ mod exit_regressions { shell.state.update(cx, |state, cx| { state.apply_spaces(vec![space("mine", "local"), space("other", "remote")]); state.apply_chats(vec![roboco_proto::Chat { + agent_origin: None, id: "elsewhere".into(), device_id: "remote".into(), title: None, diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 19ed020fa3..5b7a184ffa 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -596,6 +596,7 @@ mod tests { } fn chat(id: &str, parent: Option<&str>, archived: bool, age: i64) -> roboco_proto::Chat { + agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "title": id, "deviceId": "local", "archived": archived, "parentChatId": parent, diff --git a/crates/ui/src/shell/sidebar_sections.rs b/crates/ui/src/shell/sidebar_sections.rs index 70bb6b02ca..c57bf0d832 100644 --- a/crates/ui/src/shell/sidebar_sections.rs +++ b/crates/ui/src/shell/sidebar_sections.rs @@ -586,6 +586,7 @@ mod tests { } fn chat(id: &str) -> roboco_proto::Chat { + agent_origin: None, serde_json::from_value(serde_json::json!({"id":id,"title":id,"deviceId":"local","archived":false,"createdAt":"2026-09-20T00:00:00Z"})).unwrap() } fn prepare(shell: &mut Shell, cx: &mut Context) { diff --git a/crates/ui/src/shell/sidebar_state_sync.rs b/crates/ui/src/shell/sidebar_state_sync.rs index cbd8b7b965..6b620ed2d0 100644 --- a/crates/ui/src/shell/sidebar_state_sync.rs +++ b/crates/ui/src/shell/sidebar_state_sync.rs @@ -403,6 +403,7 @@ mod tests { } fn chat(id: &str) -> roboco_proto::Chat { + agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "title": id, "deviceId": "local", "archived": false, "createdAt": "2026-09-20T00:00:00Z" diff --git a/crates/ui/src/shell/spaces.rs b/crates/ui/src/shell/spaces.rs index c28841ce78..17cb4ad588 100644 --- a/crates/ui/src/shell/spaces.rs +++ b/crates/ui/src/shell/spaces.rs @@ -6800,6 +6800,7 @@ mod tests { fn chat(id: &str) -> roboco_proto::Chat { roboco_proto::Chat { + agent_origin: None, id: id.into(), device_id: "device".into(), title: None, diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index ae06385237..63a4d1582c 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -3930,6 +3930,7 @@ mod tests { .unwrap() .to_utc(); Chat { + agent_origin: None, id: id.into(), device_id: "dev".into(), title: None, @@ -3986,6 +3987,7 @@ mod tests { fn user_entry(id: &str) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: roboco_doc::MessageRole::User, parts: Vec::new(), @@ -5327,6 +5329,7 @@ mod tests { let mut state = AppState::new(); state.selected_chat = Some("c1".into()); let echo = |id: &str| SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: roboco_doc::MessageRole::User, parts: vec![], @@ -5337,6 +5340,7 @@ mod tests { duration_ms: None, }; let row = |id: &str| roboco_doc::QueuedMessage { + origin_chat_id: None, id: id.into(), text: "held".into(), attachments: vec![], @@ -5365,6 +5369,7 @@ mod tests { let mut state = AppState::new(); state.selected_chat = Some("c1".into()); let echo = SessionMessageEntry { + origin_chat_id: None, id: "m1".into(), role: roboco_doc::MessageRole::User, parts: vec![], @@ -5383,6 +5388,7 @@ mod tests { assert_eq!(state.pending_echoes().len(), 1); // The confirming frame prunes it. state.apply_transcript(vec![SessionMessageEntry { + origin_chat_id: None, id: "m1".into(), ..echo.clone() }]); @@ -5391,6 +5397,7 @@ mod tests { state.push_echo( "c1", SessionMessageEntry { + origin_chat_id: None, id: "m2".into(), ..echo.clone() }, @@ -5401,6 +5408,7 @@ mod tests { state.push_echo( "other", SessionMessageEntry { + origin_chat_id: None, id: "m3".into(), ..echo }, diff --git a/crates/ui/src/todo_panel.rs b/crates/ui/src/todo_panel.rs index fa39792dfb..6b5fb20979 100644 --- a/crates/ui/src/todo_panel.rs +++ b/crates/ui/src/todo_panel.rs @@ -704,6 +704,7 @@ mod tests { fn entry(role: MessageRole, parts: Vec) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: "m".into(), role, parts, @@ -970,6 +971,7 @@ mod composer_tests { fn assistant_with_todo(list: Vec) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: "m".into(), role: MessageRole::Assistant, parts: vec![MessagePart::Tool { diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 27ce9d98de..ed7ff998c7 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -1626,6 +1626,7 @@ pub fn rows_for_entry( Vec::new() } else { let work_entry = SessionMessageEntry { + origin_chat_id: None, parts: work_parts, duration_ms: None, continuation_of: None, @@ -11867,6 +11868,7 @@ mod tests { fn assistant(id: &str, status: MessageStatus, parts: Vec) -> SessionMessageEntry { SessionMessageEntry { + origin_chat_id: None, id: id.into(), role: MessageRole::Assistant, parts, @@ -15605,6 +15607,7 @@ mod tests { // User entries carry the strip on their single row (pending too). let user = SessionMessageEntry { + origin_chat_id: None, id: "u1".into(), role: MessageRole::User, parts: vec![text_part("p1", "hi")], diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index dfab1ab9fb..155337088f 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -126,6 +126,7 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::Space, roboco_proto::ChatConfig, roboco_proto::ConversationSourceContext, + roboco_proto::AgentOrigin, roboco_proto::Chat, roboco_proto::ChatIndicator, roboco_proto::SessionStatus, diff --git a/web/packages/proto/src/generated/AgentOrigin.ts b/web/packages/proto/src/generated/AgentOrigin.ts new file mode 100644 index 0000000000..669e5f579b --- /dev/null +++ b/web/packages/proto/src/generated/AgentOrigin.ts @@ -0,0 +1,21 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * Durable record that an AGENT created a chat through the Roboco MCP + * `create_chat` / `create_chats` tools: the chat whose agent ran the tool, + * and whether that chat wants completion notices for this one. Written once + * at creation; the engine's completion-notice machinery (spec: orchestration + * v2) keys on it. Absent on user-created chats (UI forks, blank side chats, + * human CLI sends) and on standalone chats — those never notify. + */ +export type AgentOrigin = { +/** + * The chat whose agent created this one (the Roboco MCP origin chat). + */ +chatId: string, +/** + * Whether the creator is notified when this chat's turns settle. + * Defaults to on; `notify: false` opts out at creation. A missing flag + * (an older writer) reads as on. + */ +notify: boolean, }; diff --git a/web/packages/proto/src/generated/Chat.ts b/web/packages/proto/src/generated/Chat.ts index c79ba956dd..19e48af312 100644 --- a/web/packages/proto/src/generated/Chat.ts +++ b/web/packages/proto/src/generated/Chat.ts @@ -1,6 +1,7 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). // Field `roomGen` is a legacy wire name (ADR 0005): tolerate it, never build on it — see CONTEXT.md. +import type { AgentOrigin } from "./AgentOrigin"; import type { ChatConfig } from "./ChatConfig"; import type { ConversationSourceContext } from "./ConversationSourceContext"; @@ -58,4 +59,11 @@ roomGen?: number | null, * parent instead. Absent for top-level chats; a dangling id (parent * deleted) is tolerated rather than cascaded. */ -parentChatId?: string | null, }; +parentChatId?: string | null, +/** + * Set when an agent — not the user — created this chat through the + * Roboco MCP tools: which chat ran `create_chat`, and whether it is owed + * completion notices. Additive (ADR 0005): rows from older writers read + * as not agent-created and never notify. + */ +agentOrigin?: AgentOrigin | null, }; diff --git a/web/packages/proto/src/generated/MutateParams.ts b/web/packages/proto/src/generated/MutateParams.ts index f67e8a2e8e..5c1d734809 100644 --- a/web/packages/proto/src/generated/MutateParams.ts +++ b/web/packages/proto/src/generated/MutateParams.ts @@ -1,5 +1,6 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +import type { AgentOrigin } from "./AgentOrigin"; import type { ChatConfig } from "./ChatConfig"; /** @@ -31,4 +32,11 @@ cwd?: string, * MCP). Recorded on the row as `parentChatId` — children list under * their parent in the explorer instead of the main sidebar. */ -parentChatId?: string, } | { "op": "createSpace", spaceId: string, deviceId: string, path: string, name?: string, gitDetected: boolean, } | { "op": "renameSpace", spaceId: string, name?: string, } | { "op": "deleteSpace", spaceId: string, } | { "op": "renameChat", chatId: string, title: string, } | { "op": "setChatBranch", chatId: string, branch: string, } | { "op": "setChatCwd", chatId: string, cwd: string, } | { "op": "setChatActivity", chatId: string, lastMessageAt?: number, createdAt?: number, } | { "op": "setChatHost", chatId: string, deviceId: string, } | { "op": "setChatArchived", chatId: string, archived: boolean, } | { "op": "setChatConfig", chatId: string, config: ChatConfig, } | { "op": "deleteChat", chatId: string, } | { "op": "renameDevice", deviceId: string, name: string, } | { "op": "markChatSeen", chatId: string, at?: number, }; +parentChatId?: string, +/** + * Set when an AGENT is creating this one through the Roboco MCP + * `create_chat` / `create_chats` tools: the creating chat and its + * notify preference (default on). Absent on UI creations — forks + * and blank side chats never notify. + */ +agentOrigin?: AgentOrigin, } | { "op": "createSpace", spaceId: string, deviceId: string, path: string, name?: string, gitDetected: boolean, } | { "op": "renameSpace", spaceId: string, name?: string, } | { "op": "deleteSpace", spaceId: string, } | { "op": "renameChat", chatId: string, title: string, } | { "op": "setChatBranch", chatId: string, branch: string, } | { "op": "setChatCwd", chatId: string, cwd: string, } | { "op": "setChatActivity", chatId: string, lastMessageAt?: number, createdAt?: number, } | { "op": "setChatHost", chatId: string, deviceId: string, } | { "op": "setChatArchived", chatId: string, archived: boolean, } | { "op": "setChatConfig", chatId: string, config: ChatConfig, } | { "op": "deleteChat", chatId: string, } | { "op": "renameDevice", deviceId: string, name: string, } | { "op": "markChatSeen", chatId: string, at?: number, }; diff --git a/web/packages/proto/src/generated/QueuedMessage.ts b/web/packages/proto/src/generated/QueuedMessage.ts index e8d856ca2f..0cdc225381 100644 --- a/web/packages/proto/src/generated/QueuedMessage.ts +++ b/web/packages/proto/src/generated/QueuedMessage.ts @@ -42,4 +42,11 @@ editedAt?: number | null, * agent while a client has an edit open. Expired edits fail closed into * `ReviewRequired`; they never silently become sendable again. */ -deliveryGate?: QueueDeliveryGate | null, }; +deliveryGate?: QueueDeliveryGate | null, +/** + * The chat this row was sent BY through the Roboco MCP server — + * structured attribution (additive; ADR 0005). None for user-typed rows + * and rows written before the field existed. The promoted user message + * entry keeps it. + */ +originChatId?: string | null, }; diff --git a/web/packages/proto/src/generated/SessionCommandEntry.ts b/web/packages/proto/src/generated/SessionCommandEntry.ts index 95623b4f89..aa820a4c6b 100644 --- a/web/packages/proto/src/generated/SessionCommandEntry.ts +++ b/web/packages/proto/src/generated/SessionCommandEntry.ts @@ -13,4 +13,11 @@ issuedAt: number, basedOn: CommandBasedOn | null, /** * Epoch millis; defaults to issued_at + COMMAND_DEFAULT_TTL_MS when absent. */ -expiresAt: number | null, status: SessionCommandStatus, resolution: string | null, }; +expiresAt: number | null, status: SessionCommandStatus, resolution: string | null, +/** + * The chat this Run/Steer was sent BY through the Roboco MCP server — + * structured attribution (additive; ADR 0005). None for user-typed + * sends and for rows written before the field existed. Executing it + * stamps the same origin on the user message entry it writes. + */ +originChatId?: string | null, }; diff --git a/web/packages/proto/src/generated/SessionMessageEntry.ts b/web/packages/proto/src/generated/SessionMessageEntry.ts index 0273eeaffc..9e76c58388 100644 --- a/web/packages/proto/src/generated/SessionMessageEntry.ts +++ b/web/packages/proto/src/generated/SessionMessageEntry.ts @@ -18,4 +18,11 @@ createdAt: number, deviceId: string, status?: MessageStatus | null, continuation * finishes. Absent on user rows, live streams, and docs written before * the field existed. */ -durationMs?: number | null, }; +durationMs?: number | null, +/** + * The chat this user entry was sent BY through the Roboco MCP server — + * structured attribution (additive; ADR 0005), the durable record the + * completion-notice rules key on. None on typed messages and entries + * written before the field existed. + */ +originChatId?: string | null, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 1774ec3248..3afe4bf29e 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -9,6 +9,7 @@ export * from "./AgentLoginMode"; export * from "./AgentLoginPoll"; export * from "./AgentLoginStart"; export * from "./AgentLoginStatus"; +export * from "./AgentOrigin"; export * from "./AgentUsageWindow"; export * from "./AttachmentChunk"; export * from "./AttachmentTransfer"; From bda510d9c56c487ccb2c86faa59755e0c9e45333 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 14:03:10 +0700 Subject: [PATCH 08/96] fix: pass the origin through re-dispatches, drop a stray field, filter the error assertion --- crates/engine/src/sessions.rs | 6 ++++++ crates/harness/src/claude/normalize.rs | 14 +++++++++++--- crates/mobile-client/src/cache.rs | 1 - 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 8da235c1da..ad034a815a 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1206,6 +1206,9 @@ impl SessionsEngine { harness_id, request, Some(user_id), + // The user entry already carries its origin; the + // re-dispatch dedupes on its id. + None, ) .await { @@ -3292,6 +3295,9 @@ async fn drive_run( harness_id, request, Some(steer.message_id.clone()), + // The steer's user entry already carries its origin; + // write_user_message dedupes on its id. + None, ) .await { diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index 1890f05484..0b68bdfb13 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -1508,9 +1508,17 @@ mod tests { #[test] fn assistant_error_codes_classify_limits_and_auth() { + // The error message also completes its (empty) assistant message; + // only the error event is under test here. + let errors = |line: &str| { + normalize_one(line) + .into_iter() + .filter(|event| matches!(event, AgentEvent::Error { .. })) + .collect::>() + }; // The terse `rate_limit` code: a usage limit, window unknown (the // accompanying rate_limit_event usually carries the details). - let events = normalize_one( + let events = errors( r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}"#, ); assert_eq!( @@ -1524,7 +1532,7 @@ mod tests { }] ); // Authentication failed: signed out, distinct from a limit. - let events = normalize_one( + let events = errors( r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"authentication_failed"}"#, ); assert_eq!( @@ -1535,7 +1543,7 @@ mod tests { }] ); // Other codes stay unclassified. - let events = normalize_one( + let events = errors( r#"{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"billing_error"}"#, ); assert_eq!( diff --git a/crates/mobile-client/src/cache.rs b/crates/mobile-client/src/cache.rs index d24725d999..131a661b5e 100644 --- a/crates/mobile-client/src/cache.rs +++ b/crates/mobile-client/src/cache.rs @@ -281,7 +281,6 @@ mod tests { use super::*; fn stub_chat(id: &str) -> roboco_proto::Chat { - agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "deviceId": "dev", From fc75646c9780b3ec835f697bad4de6a335fa4d57 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 14:29:45 +0700 Subject: [PATCH 09/96] fix: drop misplaced agent_origin inserts; expect the slot's window label --- crates/engine/tests/adapter_chaos_live.rs | 1 - crates/engine/tests/diff_sync_churn.rs | 1 - crates/harness/src/codex/normalize.rs | 4 ++-- crates/mobile-client/tests/side_chats_engine.rs | 1 - crates/ui/src/shell/command_palette.rs | 1 - crates/ui/src/shell/sidebar_sections.rs | 1 - crates/ui/src/shell/sidebar_state_sync.rs | 1 - 7 files changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/engine/tests/adapter_chaos_live.rs b/crates/engine/tests/adapter_chaos_live.rs index 69bc623ad9..3b2eb24cd2 100644 --- a/crates/engine/tests/adapter_chaos_live.rs +++ b/crates/engine/tests/adapter_chaos_live.rs @@ -245,7 +245,6 @@ impl Suite { }) }; Chat { - agent_origin: None, suite: self, id, sends: Default::default(), diff --git a/crates/engine/tests/diff_sync_churn.rs b/crates/engine/tests/diff_sync_churn.rs index 4a74d75322..7823f2afaa 100644 --- a/crates/engine/tests/diff_sync_churn.rs +++ b/crates/engine/tests/diff_sync_churn.rs @@ -484,7 +484,6 @@ async fn archived_chats_get_no_entry_and_archiving_drops_it() { } fn chat_row(core: &EngineCore, chat_id: &str) -> roboco_proto::Chat { - agent_origin: None, core.workspace .watch_chats() .borrow() diff --git a/crates/harness/src/codex/normalize.rs b/crates/harness/src/codex/normalize.rs index aaeb492250..260684b149 100644 --- a/crates/harness/src/codex/normalize.rs +++ b/crates/harness/src/codex/normalize.rs @@ -1300,13 +1300,13 @@ mod tests { ); assert_eq!(rate_limit_reset(&json!({"message": "x"})), None); // An exhausted window whose reset the snapshot omits is skipped, not - // fatal; an unknown duration falls back to the usage label. + // fatal; a missing duration falls back to the slot's usual window. assert_eq!( rate_limit_reset(&json!({"rateLimits": { "primary": {"usedPercent": 100}, "secondary": {"usedPercent": 100, "resetsAt": 1736208000} }})), - Some(RateLimitReset { window: "usage".into(), resets_at: 1_736_208_000_000 }) + Some(RateLimitReset { window: "weekly".into(), resets_at: 1_736_208_000_000 }) ); } diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index 1f29d7322a..494388ec5b 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -356,7 +356,6 @@ impl Rig { /// The engine's own row for a raw chat id. fn engine_row(&self, raw: &str) -> roboco_proto::Chat { - agent_origin: None, self.core .workspace .chat(raw) diff --git a/crates/ui/src/shell/command_palette.rs b/crates/ui/src/shell/command_palette.rs index 5b7a184ffa..19ed020fa3 100644 --- a/crates/ui/src/shell/command_palette.rs +++ b/crates/ui/src/shell/command_palette.rs @@ -596,7 +596,6 @@ mod tests { } fn chat(id: &str, parent: Option<&str>, archived: bool, age: i64) -> roboco_proto::Chat { - agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "title": id, "deviceId": "local", "archived": archived, "parentChatId": parent, diff --git a/crates/ui/src/shell/sidebar_sections.rs b/crates/ui/src/shell/sidebar_sections.rs index c57bf0d832..70bb6b02ca 100644 --- a/crates/ui/src/shell/sidebar_sections.rs +++ b/crates/ui/src/shell/sidebar_sections.rs @@ -586,7 +586,6 @@ mod tests { } fn chat(id: &str) -> roboco_proto::Chat { - agent_origin: None, serde_json::from_value(serde_json::json!({"id":id,"title":id,"deviceId":"local","archived":false,"createdAt":"2026-09-20T00:00:00Z"})).unwrap() } fn prepare(shell: &mut Shell, cx: &mut Context) { diff --git a/crates/ui/src/shell/sidebar_state_sync.rs b/crates/ui/src/shell/sidebar_state_sync.rs index 6b620ed2d0..cbd8b7b965 100644 --- a/crates/ui/src/shell/sidebar_state_sync.rs +++ b/crates/ui/src/shell/sidebar_state_sync.rs @@ -403,7 +403,6 @@ mod tests { } fn chat(id: &str) -> roboco_proto::Chat { - agent_origin: None, serde_json::from_value(serde_json::json!({ "id": id, "title": id, "deviceId": "local", "archived": false, "createdAt": "2026-09-20T00:00:00Z" From 38c01b33389c9c95cf3af2dfcb3a4f0eba3eff1f Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 13:57:28 +0700 Subject: [PATCH 10/96] feat(pi): steer mid-turn - pi becomes a step-boundary harness Orchestration v2 ticket 03 (.scratch/t3-orchestration-v2/issues/03): messages sent to a working Pi chat reach pi at its next step boundary, exactly as for Claude Code and Codex, instead of sitting in the engine queue until the whole run ends. - The harness declaration and the engine registry descriptor flip to StepBoundary together (the engine routes queue holds, the queued row action and MCP send_message auto mode off the descriptor; the queue row now reads "Steer" for pi). This reverses wave-4 ticket 30 items 8-9 - the reversal is recorded in that ticket decision record. - set_steering_mode "all" is sent at session start unless the user configured a mode (pi agent-dir or project .pi/settings.json), so a burst of steers is delivered together, in order. Best-effort: an older pi without the command logs and runs on its own default. Title runs never touch the setting. PiHarness::with_agent_dir pins the settings dir for hermetic tests. - Steers carry their attached images: the mid-run prompt stages the mailbox message attachments as inline ImageContent blocks, exactly like a turn-start prompt. - Stop sends clear_queue before abort: steers pi accepted but never consumed are cancelled with the turn - the engine drops their at-least-once ledger entries on the interrupted exit and their user entries already sit in the transcript, the same outcome as a held send cancelled by Stop. - A run that recovered from an error through a successful compaction retry ends completed: a successful compaction_end with willRetry clears the turn error that triggered it. Tests (fake-pi-rpc.sh prior art, extended): steering-mode default and the never-overwrite guard (agent-dir and project settings, session-file markers); a steer burst delivered together in order; a steer carrying an image (wire-level base64 assert); stop clearing pi queue before abort (fixture asserts the wire order and the stranded text); a compaction-recovered run completing. New engine integration suite pi_steering.rs (message-queue prior art, but driving the real Pi harness through the fixture): a mid-run message from a queued row's Steer action is steered into the live turn - in the transcript, queue empty, turn still working, one turn, no follow-up re-run - and a Stop with an unconsumed steer leaves it in the transcript unrun with no re-dispatch. Registry test: every descriptor mirrors its harness declaration. ui-tests.yml runs the new suite beside pi_resume. Not run locally (CI policy). No wire changes (ADR 0005): SteeringMode already carried step-boundary; nothing new crosses the wire. --- .github/workflows/ui-tests.yml | 2 +- .../30-pi-native-driver-parity-review.md | 14 + crates/engine/src/registry.rs | 50 ++- crates/engine/tests/pi_steering.rs | 377 ++++++++++++++++++ crates/harness/src/pi/mod.rs | 211 +++++++++- crates/harness/tests/fixtures/fake-pi-rpc.sh | 113 +++++- crates/harness/tests/pi.rs | 211 +++++++++- docs/PARITY.md | 2 +- 8 files changed, 951 insertions(+), 29 deletions(-) create mode 100644 crates/engine/tests/pi_steering.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 325ac22036..d9b2d3a806 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -56,7 +56,7 @@ jobs: - name: Pi native driver integration tests run: | cargo test --locked -p roboco-harness --test pi - cargo test --locked -p roboco-engine --test pi_resume + cargo test --locked -p roboco-engine --test pi_resume --test pi_steering - name: Engine-local storage and recovery run: | cargo test --locked -p roboco-sync --lib diff --git a/.scratch/upstream-ports-2026-10-wave4/issues/30-pi-native-driver-parity-review.md b/.scratch/upstream-ports-2026-10-wave4/issues/30-pi-native-driver-parity-review.md index 2d41d4f5fe..c62cc5e59e 100644 --- a/.scratch/upstream-ports-2026-10-wave4/issues/30-pi-native-driver-parity-review.md +++ b/.scratch/upstream-ports-2026-10-wave4/issues/30-pi-native-driver-parity-review.md @@ -167,6 +167,20 @@ Verification: reading + `git show` only (ticket 30's budget); no code changed, no cargo. Test execution deferred to the wave-final batched pass (user directive) — nothing here to execute. +- **Reversal (2026-10-16, orchestration v2 ticket 03 — + `.scratch/t3-orchestration-v2/issues/03-pi-steers-mid-turn.md`):** items + 8 and 9 above are superseded. The T3 study measured that our + TurnBoundary classification held every message sent to a working Pi chat + in the engine queue until `agent_settled` — "our steering rides pi's own + steer queue" was true on the wire and false in delivery. Ticket 02 + already moved the driver to upstream's `prompt` + + `streamingBehavior: "steer"` shape; ticket 03 flips the declaration and + the registry descriptor to `StepBoundary` together, selects steering + mode `"all"` at session start unless the user configured one (upstream + #630's overwrite guard now fully applies), and covers both with tests + (fixture `set_steering_mode` handling, an engine-level pi steering test + driving the real queue/steer flow). + - Wave-final batched verification (2026-10-03, merged main `cf94f415`): one batched pass over all lanes — ui lib 1521/1521; engine 529/530 (the one failure is the documented pre-existing diff --git a/crates/engine/src/registry.rs b/crates/engine/src/registry.rs index 78cbf8fe9a..cdce7b3a9d 100644 --- a/crates/engine/src/registry.rs +++ b/crates/engine/src/registry.rs @@ -726,15 +726,17 @@ pub fn default_registry() -> HarnessRegistry { ); // pi over its NATIVE RPC mode (`pi --mode rpc`, the maintainer-supported // embedding path), same lazy pattern: the static descriptor mirrors - // PiHarness exactly — turn-boundary steering via pi's own steer queue, - // pi's thinking ladder minus its "off" tier, deterministic turn ends - // (agent_settled). + // PiHarness exactly — STEP-boundary steering via pi's own steer queue + // (prompt + steer behavior, mode "all" at session start), pi's thinking + // ladder minus its "off" tier, deterministic turn ends (agent_settled). + // Orchestration v2 ticket 03 flipped this from turn-boundary; the + // harness declaration and this descriptor must always agree. registry.register_lazy( HarnessDescriptor { id: HarnessId::Pi, name: "Pi".into(), supports_steering: true, - steering_mode: SteeringMode::TurnBoundary, + steering_mode: SteeringMode::StepBoundary, reasoning_levels: vec![ ReasoningLevel::Minimal, ReasoningLevel::Low, @@ -991,7 +993,7 @@ mod tests { let pi = registry.resolve(HarnessId::Pi).unwrap(); assert_eq!(pi.id(), HarnessId::Pi); assert_eq!(pi.display_name(), "Pi"); - assert_eq!(pi.steering_mode(), SteeringMode::TurnBoundary); + assert_eq!(pi.steering_mode(), SteeringMode::StepBoundary); assert_eq!( pi.reasoning_levels(), &[ @@ -1005,6 +1007,44 @@ mod tests { ); } + /// The static descriptor a lazy slot carries must mirror the harness it + /// resolves to — the descriptor-stability rule, checked for every slot + /// at once. A disagreement on `steering_mode` is exactly the bug + /// orchestration v2 ticket 03 guards against: the engine routes + /// mid-turn sends (queue holds, row actions, MCP `send_message`) off the + /// DESCRIPTOR while the driver speaks for itself, so the two must flip + /// together. (`installed`/`can_install`/`enabled` differ by design — + /// they are per-device, re-probed on `descriptors()`.) + #[test] + fn registry_descriptors_mirror_their_harness_declarations() { + let registry = default_registry(); + let descriptors = registry.descriptors(); + assert!(!descriptors.is_empty()); + for descriptor in descriptors { + let harness = registry + .resolve(descriptor.id) + .expect("every default slot resolves (construction never probes a CLI)"); + let described = describe(harness.as_ref()); + assert_eq!(descriptor.id, described.id, "slot identity"); + assert_eq!( + descriptor.steering_mode, described.steering_mode, + "{:?}: the descriptor and the harness declaration disagree on the steering mode", + descriptor.id + ); + assert_eq!( + descriptor.supports_steering, described.supports_steering, + "{:?}: steering support disagrees", + descriptor.id + ); + assert_eq!( + descriptor.reasoning_levels, described.reasoning_levels, + "{:?}: the reasoning ladder disagrees", + descriptor.id + ); + assert_eq!(descriptor.name, described.name, "{:?}: the display name disagrees", descriptor.id); + } + } + /// Catalogs serialized by engines that predate the `installed`/`enabled` /// fields must keep deserializing — installed, and enabled per the /// detection fallback. diff --git a/crates/engine/tests/pi_steering.rs b/crates/engine/tests/pi_steering.rs new file mode 100644 index 0000000000..2b34d79cb2 --- /dev/null +++ b/crates/engine/tests/pi_steering.rs @@ -0,0 +1,377 @@ +//! Pi steers mid-turn through the REAL engine surfaces (orchestration v2, +//! ticket 03): a message sent to a working Pi chat reaches the fake pi at +//! its next step boundary — steered into the live run, never held to turn +//! end — and a Stop with steers pi never consumed cancels them exactly like +//! any other held send. Prior art: `pi_resume.rs` drives the real +//! `PiHarness` against the fixture RPC script through engine dispatch; +//! `message_queue.rs` drives the queue + steer surfaces. The fixture's +//! `steer-flow` scenario is the ordering oracle: it cannot settle before +//! the steer arrives, so a message held to turn end would hang the run. +#![cfg(unix)] + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use roboco_doc::{MessagePart, MessageRole, SessionCommandPayload, SessionMessageEntry}; +use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_harness::PiHarness; +use roboco_proto::{HarnessId, SessionStatus}; + +const CHAT: &str = "pi-steer"; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../harness/tests/fixtures/fake-pi-rpc.sh") +} + +/// Assemble a full engine around the REAL Pi harness pointed at the fixture +/// RPC process (short interrupt graces so a stuck scenario fails fast +/// instead of hanging the suite). +fn assemble(dir: &std::path::Path, agent_dir: &std::path::Path) -> EngineCore { + let registry = HarnessRegistry::new(); + registry.register(Arc::new( + PiHarness::new() + .with_executable(fixture_path()) + .with_agent_dir(agent_dir) + .with_graces(Duration::from_millis(150), Duration::from_millis(300)), + )); + EngineCore::assemble(dir, Arc::new(registry), HarnessId::Pi).expect("engine core assembles") +} + +/// A pi chat in a real temp cwd (the fixture writes its session file and +/// runs there), pre-titled so the auto-titler never spawns a fixture run of +/// its own. +async fn create_pi_chat(core: &EngineCore, cwd: &str) { + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + "cwd": cwd, + "config": { + "harness": "pi", + "model": null, + "reasoning": null, + "sandbox": "workspace-write", + }, + }), + ) + .await + .expect("createChat"); + core.workspace.rename_chat(CHAT, "Pre-titled").expect("rename chat"); +} + +fn entries(core: &EngineCore) -> Vec { + core.doc_host + .open(CHAT) + .expect("chat doc") + .doc() + .read_entries() + .unwrap_or_default() +} + +fn texts_of(core: &EngineCore, role: MessageRole) -> Vec { + entries(core) + .iter() + .filter(|e| e.role == role) + .map(|e| { + e.parts + .iter() + .filter_map(|p| match p { + MessagePart::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .collect() +} + +fn queue_texts(core: &EngineCore) -> Vec { + core.doc_host + .open(CHAT) + .expect("chat doc") + .doc() + .read_queue() + .unwrap_or_default() + .into_iter() + .map(|row| row.text) + .collect() +} + +async fn wait_for(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +/// A message sent to a working Pi chat from the composer's queue row (the +/// "Steer" action) reaches pi at its next step boundary: the user message +/// lands in the transcript and the row leaves the queue WHILE the turn is +/// still in flight, and the message folds into that turn — it never waits +/// for the run to end and never starts a follow-up turn of its own. The +/// fixture's `steer-flow` script structurally guarantees the wire order +/// (it reads the steer before it settles); a held message would deadlock +/// the scenario and this test would time out instead. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_message_sent_mid_run_is_steered_not_held_to_turn_end() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("chat"); + std::fs::create_dir_all(&cwd).unwrap(); + let core = assemble(&dir.path().join("data"), agent_dir.path()); + create_pi_chat(&core, &cwd.display().to_string()).await; + + // The opening turn: the fixture streams its first segment and then + // waits for the steer — the window where the message must go IN. + core.doc_host + .queue_message(CHAT, "steer-flow", Vec::new()) + .expect("queue opening"); + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("first part ")) + }, + "the opening turn to stream", + ) + .await; + assert!(core.sessions.turn_in_flight(CHAT)); + + // The composer path while busy: the row lands in the queue, then its + // primary action — Steer, because pi is a step-boundary harness — + // routes it into the live run. + let row = core + .doc_host + .queue_message(CHAT, "steered text", Vec::new()) + .expect("queue the mid-run message"); + assert_eq!(queue_texts(&core), vec!["steered text"]); + let sent = core.doc_host.steer_queued_now(CHAT, &row).await.expect("steer now"); + assert!(sent); + + // The message reached the transcript, the row left the queue, and the + // turn is STILL in flight (the fixture pauses after the queued ack, so + // this window is wide): steered, not held to turn end. + wait_for( + || { + texts_of(&core, MessageRole::User).iter().any(|m| m == "steered text") + && queue_texts(&core).is_empty() + }, + "the steered message to land in the transcript", + ) + .await; + assert!( + core.sessions.turn_in_flight(CHAT), + "the steer must land while the turn is still working" + ); + assert!( + !texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply-to-steer")), + "the run cannot have answered the steer before accepting it" + ); + + // The whole exchange stays one turn: the steer folds into the live run + // and pi answers it there (never a fresh follow-up turn, which the + // fixture would answer as its own reply). + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply-to-steer")) + }, + "the steered reply to stream", + ) + .await; + wait_for( + || !core.sessions.turn_in_flight(CHAT), + "the turn to settle", + ) + .await; + assert_eq!( + texts_of(&core, MessageRole::User), + vec!["steer-flow", "steered text"] + ); + assert!( + !texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:steered text")), + "the steered message must not re-run as its own turn" + ); + assert_eq!(queue_texts(&core), Vec::::new()); + assert_eq!( + core.sessions.session_status(CHAT).map(|s| s.status), + Some(SessionStatus::Idle) + ); + core.shutdown().await; +} + +/// The MCP `send_message` auto-mode route: a `Steer` command queued while +/// pi works goes through the command log and `deliver_prompt`, which — for +/// a step-boundary harness — routes it into the live run instead of holding +/// it to turn end (the pre-flip behavior this ticket replaces). Same +/// fixture ordering oracle: the scenario settles only after the steer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_mcp_steer_command_reaches_the_live_run() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("chat"); + std::fs::create_dir_all(&cwd).unwrap(); + let core = assemble(&dir.path().join("data"), agent_dir.path()); + create_pi_chat(&core, &cwd.display().to_string()).await; + + core.doc_host + .queue_message(CHAT, "steer-flow", Vec::new()) + .expect("queue opening"); + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("first part ")) + }, + "the opening turn to stream", + ) + .await; + + // The exact durable command the MCP server queues for a working chat + // (send_message auto mode resolves to a Steer command). + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Steer { + prompt: "steered text".into(), + message_id: Some("mcp-steer-1".into()), + }, + ) + .expect("queue the steer command"); + + // Steered into the live run: the message lands in the transcript while + // the turn is still working, and never sits in the queue as a held row. + wait_for( + || { + texts_of(&core, MessageRole::User).iter().any(|m| m == "steered text") + && queue_texts(&core).is_empty() + }, + "the steered message to land in the transcript", + ) + .await; + assert!(core.sessions.turn_in_flight(CHAT)); + + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply-to-steer")) + }, + "the steered reply to stream", + ) + .await; + wait_for( + || !core.sessions.turn_in_flight(CHAT), + "the turn to settle", + ) + .await; + assert_eq!( + texts_of(&core, MessageRole::User), + vec!["steer-flow", "steered text"] + ); + assert!(!texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:steered text"))); + core.shutdown().await; +} + +/// Stop with steers pi never consumed: the driver clears pi queue before +/// the abort (the fixture asserts the wire order and the stranded text), +/// the turn ends interrupted, and the message is handled exactly like a +/// held send cancelled by Stop — it stays in the transcript, unrun, and no +/// fresh runtime picks it back up. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop_cancels_unconsumed_steers_like_any_held_send() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("chat"); + std::fs::create_dir_all(&cwd).unwrap(); + let core = assemble(&dir.path().join("data"), agent_dir.path()); + create_pi_chat(&core, &cwd.display().to_string()).await; + + core.doc_host + .queue_message(CHAT, "stop-clears-queue", Vec::new()) + .expect("queue opening"); + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("working")) + }, + "the opening turn to stream", + ) + .await; + + // The steer is accepted into pi queue but never consumed (the fixture + // streams its marker and waits for the Stop). + let row = core + .doc_host + .queue_message(CHAT, "stranded steer", Vec::new()) + .expect("queue the steer"); + let sent = core.doc_host.steer_queued_now(CHAT, &row).await.expect("steer now"); + assert!(sent); + wait_for( + || { + texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("steer queued")) + }, + "pi to accept the steer into its queue", + ) + .await; + + core.doc_host + .queue_command(CHAT, SessionCommandPayload::Interrupt {}) + .expect("queue interrupt"); + wait_for( + || !core.sessions.turn_in_flight(CHAT), + "the interrupted turn to settle", + ) + .await; + + // The message was sent — it is in the transcript, like every held send + // Stop cancels — and it never runs: no reply, no re-dispatch, no new + // turn. The fixture asserts clear_queue carried the stranded text away + // BEFORE the abort. + wait_for( + || texts_of(&core, MessageRole::User).iter().any(|m| m == "stranded steer"), + "the cancelled message to stay in the transcript", + ) + .await; + assert!( + !texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:")), + "the cancelled steer must never run" + ); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !texts_of(&core, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:")), + "no fresh runtime may pick the cancelled steer back up" + ); + assert!(!core.sessions.turn_in_flight(CHAT)); + assert_eq!( + core.sessions.session_status(CHAT).map(|s| s.status), + Some(SessionStatus::Idle) + ); + assert_eq!(queue_texts(&core), Vec::::new()); + core.shutdown().await; +} diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index 5bc78348a5..39c1d6da46 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -30,9 +30,15 @@ //! is unbounded: pi's preflight (extensions, the Roboco MCP bridge, //! compaction) may take arbitrarily long, and only Stop interrupts it. A //! rejected prompt never ends the session — the message returns to the -//! driver queue and the rejection surfaces. `abort` interrupts, waiting -//! for idle before responding. `follow_up` stays unused: a parked -//! session's next message is a fresh prompt in roboco's model. +//! driver queue and the rejection surfaces. A mid-run steer carries its +//! staged images inline, exactly like a turn-start prompt. Pi is a +//! STEP-BOUNDARY harness (the engine registry descriptor agrees): the +//! steering mode is set to `"all"` at session start unless the user +//! already configured one (agent-dir or project `.pi/settings.json`), +//! so a burst of steers is delivered together, in order, at the next +//! step boundary. `abort` interrupts, waiting for idle before +//! responding. `follow_up` stays unused: a parked session's next message +//! is a fresh prompt in roboco's model. //! - **Turn settlement**: `agent_settled`, then a state probe: the turn //! ends only once pi reports it is not streaming, not compacting and //! holds no pending messages, and the settle generation is unchanged @@ -42,7 +48,12 @@ //! still ends with a deterministic `Done` (the engine retires its //! quiesce watchdogs). Turn errors reject the prompt: a final assistant //! error (`stopReason: "error"`), exhausted `auto_retry_*`, or a failed -//! `compaction_end` without `willRetry` produce `Done { Errored }`. +//! `compaction_end` without `willRetry` produce `Done { Errored }` — and +//! a successful compaction retry clears the error state, so a run that +//! recovered through compaction ends completed. Stop sends `clear_queue` +//! before `abort`: steers pi queued but never consumed are cancelled +//! with the turn (the engine's at-least-once ledger already owns their +//! fate), never delivered into the aborted run. //! - **Stats**: `get_session_stats.contextUsage` feeds the context meter //! after each settle; `message_update.usage` rides per-turn `Usage` //! passthrough — first-party replacements for the pi-acp fork's @@ -135,6 +146,37 @@ const INSTALL_HINT: &str = "pi (searched PATH, the login shell's PATH, npm globa `npm install -g @earendil-works/pi-coding-agent` — Node.js >= 24; set \ PI_EXECUTABLE to override)"; +/// pi's settings directory: an explicit override (tests), else +/// `PI_CODING_AGENT_DIR`, else `~/.pi/agent`. +fn pi_agent_dir(explicit: Option<&Path>) -> PathBuf { + explicit.map(Path::to_path_buf).unwrap_or_else(|| { + crate::model_context::root( + "PI_CODING_AGENT_DIR", + crate::executable::home_or_current_dir().join(".pi").join("agent"), + ) + }) +} + +/// Whether a steering mode is already set in the settings pi loads for +/// `cwd` (the agent directory's global settings, then the project's): +/// `set_steering_mode "all"` at session start must never overwrite an +/// explicit choice — the user's, or `/steering` picked inside a chat +/// (pi persists that in the same files). A missing, unreadable, or +/// malformed file simply means "not configured". +fn steering_mode_configured(agent_dir: Option<&Path>, cwd: &str) -> bool { + [ + pi_agent_dir(agent_dir).join("settings.json"), + Path::new(cwd).join(".pi").join("settings.json"), + ] + .into_iter() + .any(|settings| { + std::fs::read(&settings) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|settings| settings.get("steeringMode").is_some()) + }) +} + /// pi accepts both spellings for its npm-global install locations. fn pi_install_paths() -> Vec { let mut dirs = Vec::new(); @@ -293,6 +335,11 @@ async fn apply_model(client: &PiClient, model: &str) -> Result<(), HarnessError> /// fixture RPC process with [`PiHarness::with_executable`]. pub struct PiHarness { executable: Option, + /// The pi agent directory the steering-mode default reads settings + /// from: `PI_CODING_AGENT_DIR` / `~/.pi/agent` unless overridden. Only + /// the steering-mode check consults it — model contexts keep hashing + /// `auth.json` alone (see [`PiHarness::model_context`]). + agent_dir: Option, /// Grace between `abort` and SIGTERM. interrupt_grace: Duration, /// Grace between SIGTERM and SIGKILL. @@ -312,6 +359,7 @@ impl Default for PiHarness { fn default() -> Self { Self { executable: None, + agent_dir: None, interrupt_grace: Duration::from_secs(2), kill_grace: Duration::from_secs(3), startup_timeout: Duration::from_secs(120), @@ -331,6 +379,14 @@ impl PiHarness { self } + /// Pin the pi agent directory the steering-mode default reads + /// `settings.json` from (tests: a temp dir makes the "user configured + /// a mode" check hermetic against the real `~/.pi/agent`). + pub fn with_agent_dir(mut self, path: impl Into) -> Self { + self.agent_dir = Some(path.into()); + self + } + /// Tune the interrupt→SIGTERM→SIGKILL escalation timing. pub fn with_graces(mut self, interrupt_grace: Duration, kill_grace: Duration) -> Self { self.interrupt_grace = interrupt_grace; @@ -518,6 +574,12 @@ impl PiHarness { let (client, incoming) = PiClient::new(stdin, stdout); let (event_tx, event_rx) = mpsc::channel::>(256); + // Steering mode "all" at session start unless the user configured + // one: pi defaults to one-at-a-time, and a burst of roboco steers + // belongs together at the next step boundary, in order. Title runs + // never steer, so the one-shot sandbox never touches the setting. + let steering_mode_all = + !title_only && !steering_mode_configured(self.agent_dir.as_deref(), &request.cwd); tokio::spawn(run_session(Session { child, client, @@ -527,6 +589,7 @@ impl PiHarness { request, resume_path, mcp_scratch, + steering_mode_all, interrupt_grace: self.interrupt_grace, kill_grace: self.kill_grace, startup_timeout: self.startup_timeout, @@ -551,11 +614,13 @@ impl Harness for PiHarness { fn supports_steering(&self) -> bool { true } - /// pi's steer queue (reached through `prompt` + steer behavior) - /// delivers after the current assistant turn's tool calls — turn - /// boundaries, matching the registry descriptor. + /// pi's steer queue (reached through `prompt` + steer behavior, with the + /// mode set to "all") delivers after the current assistant turn's tool + /// calls — the next STEP boundary inside the live turn — matching the + /// registry descriptor exactly (both must flip together; orchestration + /// v2 ticket 03). fn steering_mode(&self) -> SteeringMode { - SteeringMode::TurnBoundary + SteeringMode::StepBoundary } fn reasoning_levels(&self) -> &[ReasoningLevel] { &REASONING_LEVELS @@ -657,6 +722,9 @@ struct Session { /// The scratch dir holding the MCP bridge file for exactly this run /// (dropped after the pi child shuts down, when the file is closed). mcp_scratch: Option, + /// Send `set_steering_mode "all"` once pi answers `get_state` — armed + /// in `run_with_mode` only when no mode is configured (see there). + steering_mode_all: bool, interrupt_grace: Duration, kill_grace: Duration, startup_timeout: Duration, @@ -860,6 +928,7 @@ async fn run_session(session: Session) { // Held for the whole run: dropping it removes the bridge file, so it // must outlive the pi child (shut down before this function ends). mcp_scratch: _mcp_scratch, + steering_mode_all, interrupt_grace, kill_grace, startup_timeout, @@ -877,6 +946,21 @@ async fn run_session(session: Session) { // ---- startup: get_state (interruptible, bounded) ---------------------- let setup = async { let state = client.request("get_state", json!({})).await?; + // The steering mode rides the same startup: pi defaults to + // one-at-a-time and roboco's steers belong together. Best-effort — + // a pi old enough to lack the command logs and runs on its own + // default (the mode is a delivery-batching preference, never a + // run-shape contract). + if steering_mode_all + && let Err(e) = client + .request("set_steering_mode", json!({ "mode": "all" })) + .await + { + tracing::debug!( + target: "roboco_harness::pi", + "set_steering_mode all rejected (pi default runs): {e}" + ); + } // Apply the run's model and thinking level (pi clamps to the model's // own ladder; a rejected switch logs and the agent default runs, // pi-acp parity). Best-effort: an unavailable model or level is not @@ -1621,8 +1705,13 @@ async fn run_session(session: Session) { } "compaction_end" => { // A failed compaction that will not retry ends - // the run as an error; success (manual, threshold, - // overflow-retry) just continues or re-prompts. + // the run as an error; success (manual, + // threshold, overflow-retry) just continues or + // re-prompts. A compaction retry that SUCCEEDED + // recovered the turn: the error that triggered + // it (the overflow error message, an exhausted + // auto-retry) no longer rejects the prompt — the + // retried run may still complete it. let will_retry = event.get("willRetry").and_then(Value::as_bool).unwrap_or(false); let aborted = event.get("aborted").and_then(Value::as_bool).unwrap_or(false); let failed = event.get("result").map(Value::is_null).unwrap_or(true) @@ -1635,6 +1724,8 @@ async fn run_session(session: Session) { .unwrap_or("pi's compaction failed.") .to_owned(), ); + } else if will_retry && !failed && !aborted { + turn_error = None; } } "extension_ui_request" => { @@ -1777,10 +1868,11 @@ async fn run_session(session: Session) { if run_active || armed.is_some() { // Live turn: the prompt rides steer behavior — pi // queues it while streaming (delivered after the - // current assistant turn's tool calls) and starts a - // run when idle, atomically against settlement. The - // attachments' path refs ride the text; the parked - // delivery inlines them as images. + // current assistant turn's tool calls, all queued + // steers together in mode "all") and starts a run + // when idle, atomically against settlement. The + // staged attachments inline as images, exactly like + // a turn-start prompt. generation += 1; // the prompt goes out steer_calls.push(steer_request(&client, msg)); } else if probe.is_some() || !steer_calls.is_empty() || !driver_steers.is_empty() { @@ -1841,11 +1933,21 @@ async fn run_session(session: Session) { } let in_flight = run_active || armed.is_some() || saw_activity; if in_flight { - // abort() waits for idle before responding; do not block - // the loop on it — the agent_settled it triggers (or the - // signal escalation below) ends the run. + // clear_queue FIRST: steers pi accepted but never + // consumed must not survive into the abort — they are + // cancelled with the turn (the engine's at-least-once + // ledger drops them on the interrupted exit, their user + // entries already sit in the transcript), never + // delivered into the aborted run. abort() waits for + // idle before responding; neither request blocks the + // loop — the agent_settled abort triggers (or the + // signal escalation below) ends the run. The wire keeps + // the order: both ride the client's single writer. let client = client.clone(); tokio::spawn(async move { + if let Err(e) = client.request("clear_queue", json!({})).await { + tracing::debug!(target: "roboco_harness::pi", "clear_queue failed (escalation will reap): {e}"); + } if let Err(e) = client.request("abort", json!({})).await { tracing::debug!(target: "roboco_harness::pi", "abort failed (escalation will reap): {e}"); } @@ -1935,14 +2037,21 @@ fn prompt_acceptance( /// One mid-run steer: the same `prompt` with steer behavior (pi queues it /// while streaming and starts a run when idle). Carries the mailbox -/// message so a rejection can hand it back to the queue verbatim. +/// message so a rejection can hand it back to the queue verbatim, and +/// stages the message's attachments as inline images — a steer delivers +/// its images exactly like a turn-start prompt (the path refs ride the +/// text as a fallback either way). fn steer_request(client: &PiClient, msg: SteerMessage) -> SteerCall { let client = client.clone(); Box::pin(async move { - let params = json!({ + let images = load_image_contents(&msg.attachments).await; + let mut params = json!({ "message": msg.prompt.clone(), "streamingBehavior": "steer", }); + if !images.is_empty() { + params["images"] = Value::Array(images); + } let result = client.request("prompt", params).await; (msg, result) }) @@ -2092,3 +2201,67 @@ fn image_mime_type(path: &std::path::Path, bytes: &[u8]) -> Option<&'static str> _ => None, }) } + +#[cfg(test)] +mod tests { + use super::steering_mode_configured; + use std::path::Path; + + fn write_settings(dir: &Path, body: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("settings.json"), body).unwrap(); + } + + /// No settings anywhere: the driver selects the mode itself. + #[test] + fn steering_mode_is_unconfigured_without_settings() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + assert!(!steering_mode_configured( + Some(home.path()), + &project.path().display().to_string(), + )); + } + + /// A mode in the agent directory (the user's own, or `/steering` written + /// globally) is an explicit choice: never overwritten. + #[test] + fn agent_dir_settings_count_as_configured() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_settings(home.path(), r#"{"steeringMode": "one-at-a-time"}"#); + assert!(steering_mode_configured( + Some(home.path()), + &project.path().display().to_string(), + )); + } + + /// A mode in the project `.pi/settings.json` counts the same way. + #[test] + fn project_settings_count_as_configured() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_settings(&project.path().join(".pi"), r#"{"steeringMode": "all"}"#); + assert!(steering_mode_configured( + Some(home.path()), + &project.path().display().to_string(), + )); + } + + /// Settings without the key (or unreadable JSON) are not a choice. + #[test] + fn settings_without_the_key_are_not_configured() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + write_settings(home.path(), r#"{"defaultModel": "x/y"}"#); + assert!(!steering_mode_configured( + Some(home.path()), + &project.path().display().to_string(), + )); + write_settings(home.path(), "not json"); + assert!(!steering_mode_configured( + Some(home.path()), + &project.path().display().to_string(), + )); + } +} diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 9ee706cd44..ae2ddfe8a6 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -7,11 +7,14 @@ # (orchestration v2, ticket 02): prompt dispositions (started / queued / # handled / none for old pi), a rejection while pi streams, a slow # preflight, the settle probe (busy re-arm, failure, the settle race) and -# clear_queue strand recovery. +# clear_queue strand recovery — and the mid-turn steering surface +# (orchestration v2, ticket 03): set_steering_mode all at session start, +# steer bursts delivered together in order, images riding a steer, and +# clear_queue-before-abort when a Stop finds steers pi never consumed. # NOTE: the python below is embedded in a sh single-quoted string - it must # never contain an apostrophe (a stray one splits the script into argv). exec python3 -u -c ' -import json, os, signal, subprocess, sys, time +import base64, json, os, signal, subprocess, sys, time def emit(value): # CRLF on purpose: the driver must tolerate it (371d79b1 lessons). @@ -109,6 +112,7 @@ if session_file is not None: session_id = "pi-fixture-session" thinking_level_seen = None set_model_seen = None +steering_mode_seen = None steering_queue = [] # pi isStreaming flag, scripted by the scenarios that need a run in # flight (an extension-initiated run). @@ -323,6 +327,10 @@ def run_prompt(req): # pi order: the queue_update precedes the acknowledgement. emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) response(steer, {"disposition": "queued"}) + # The current assistant turn keeps running a while before the + # boundary delivers the steer (realistic pacing; also widens the + # window engine tests use to observe the steer landing mid-run). + time.sleep(0.4) emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) # pi delivers the steer at the boundary: queue drains, then the user # message arrives (the consumed signal), then the reply to it. @@ -331,6 +339,95 @@ def run_prompt(req): user_message(steer["message"]) turn_and_settle("reply-to-steer") return + if text == "steer-burst": + # Several quick steers (steering mode all): pi queues each while + # streaming and delivers them TOGETHER at the next step boundary, + # in send order — none dropped, none reordered. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["first part "]) + steers = [] + for _ in range(3): + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + steers.append(steer["message"]) + steering_queue.append(steer["message"]) + emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) + response(steer, {"disposition": "queued"}) + assert steers == ["burst one", "burst two", "burst three"], steers + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + del steering_queue[:] + emit({"type": "queue_update", "steering": [], "followUp": []}) + for message in steers: + user_message(message) + turn_and_settle("reply-to-burst") + return + if text == "steer-image": + # A steer carries its attached images: the prompt command must ride + # the same inline ImageContent blocks a turn-start prompt does. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["first part "]) + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + images = steer.get("images") + assert isinstance(images, list) and len(images) == 1, steer + assert images[0]["type"] == "image", images + assert images[0]["mimeType"] == "image/png", images + expected = b"\x89PNG\r\n\x1a\n" + b"roboco steer image" + assert base64.b64decode(images[0]["data"]) == expected, images + steering_queue.append(steer["message"]) + emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) + response(steer, {"disposition": "queued"}) + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + del steering_queue[:] + emit({"type": "queue_update", "steering": [], "followUp": []}) + user_message(steer["message"]) + turn_and_settle("reply-to-image-steer") + return + if text == "stop-clears-queue": + # A steer accepted into pi queue but never consumed when Stop lands: + # the driver must clear pi queue BEFORE the abort, so the steer is + # cancelled with the turn instead of delivering into the aborted run. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["working"]) + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + steering_queue.append(steer["message"]) + emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) + response(steer, {"disposition": "queued"}) + # The marker: the steer is accepted (pi queue holds it) and the run + # is still streaming — this is the Stop window. + assistant_chunks(["steer queued"]) + cleared = read_command() + assert cleared["type"] == "clear_queue", cleared + assert steering_queue == ["stranded steer"], steering_queue + del steering_queue[:] + response(cleared, {"steering": [], "followUp": []}) + abort = read_command() + assert abort["type"] == "abort", abort + emit({"type": "agent_settled"}) + response(abort) + return + if text == "compaction-recover": + # An overflow error triggers compaction with a retry; the compaction + # succeeds and the turn is retried: the run must end COMPLETED — + # the recovered error no longer rejects the prompt. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["doomed"], stop="error", error="context window overflow") + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "error"}, "toolResults": []}) + emit({"type": "compaction_start", "reason": "overflow"}) + emit({"type": "compaction_end", "reason": "overflow", + "result": {"summary": "s", "firstKeptEntryId": "e1", "tokensBefore": 10, "estimatedTokensAfter": 5}, + "aborted": False, "willRetry": True}) + emit({"type": "turn_start"}) + assistant_chunks(["recovered"]) + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + emit({"type": "agent_end", "messages": [], "willRetry": False}) + emit({"type": "agent_settled"}) + return if text == "steer-strand": # The steer is accepted mid-run but never delivered: pi queue # check happened before it landed, so the run settles with the @@ -532,6 +629,18 @@ while True: with open(session_file, "a") as handle: handle.write("thinking:" + command["level"] + "\n") response(command) + elif kind == "set_steering_mode": + # The driver selects mode all at session start unless the user + # configured one. Title runs never steer, so the sandbox must never + # touch the setting. Recorded in the session file so tests can + # prove the command was sent (or not, when configured). + assert not title_mode, "title runs never set a steering mode" + assert command["mode"] == "all", command + steering_mode_seen = command["mode"] + if session_file is not None: + with open(session_file, "a") as handle: + handle.write("steering-mode:" + command["mode"] + "\n") + response(command) elif kind == "get_session_stats": response(command, stats_data()) elif kind == "clear_queue": diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index ed3454ede3..5b1f48e5ed 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -162,7 +162,10 @@ fn pi_surfaces_match_registry_expectations() { assert_eq!(pi.id(), HarnessId::Pi); assert_eq!(pi.display_name(), "Pi"); assert!(pi.supports_steering()); - assert_eq!(pi.steering_mode(), SteeringMode::TurnBoundary); + // Step-boundary (orchestration v2 ticket 03): pi's steer queue delivers + // after the current assistant turn's tool calls — the engine registry + // descriptor must agree (its own tests check the pair). + assert_eq!(pi.steering_mode(), SteeringMode::StepBoundary); assert_eq!( pi.reasoning_levels(), &[ @@ -1188,3 +1191,209 @@ async fn old_pi_without_disposition_is_inferred_not_assumed() { assert_eq!(dones(&tail), vec![(DoneStatus::Completed, None)], "{tail:?}"); assert_eq!(text_of(&tail), "reply:revived"); } + +// --------------------------------------------------------------------------- +// Mid-turn steering (orchestration v2, ticket 03): pi is a step-boundary +// harness — steering mode "all" at session start, steers carrying images, +// clear_queue before abort, compaction recovery, bursts in order. +// --------------------------------------------------------------------------- + +/// The steering mode is set to "all" at session start unless the user +/// configured one: pi's one-at-a-time default would deliver a burst of +/// steers one assistant turn at a time; roboco's steers belong together. +#[tokio::test] +async fn steering_mode_defaults_to_all_at_session_start() { + let home = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end( + &harness().with_agent_dir(home.path()), + request_cwd("hello", &cwd.path().display().to_string()), + controls, + ) + .await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // The fixture records the wire selection in its session file. + let recorded = + std::fs::read_to_string(cwd.path().join(".pi-fixture-session.jsonl")).unwrap(); + assert!(recorded.contains("steering-mode:all"), "{recorded}"); +} + +/// A steering mode the user already configured (the agent directory's +/// settings — their own edit, or `/steering` picked globally) is never +/// overwritten: the driver sends no `set_steering_mode` at all. +#[tokio::test] +async fn a_configured_steering_mode_is_never_overwritten() { + let home = tempfile::tempdir().unwrap(); + std::fs::write( + home.path().join("settings.json"), + r#"{"steeringMode": "one-at-a-time"}"#, + ) + .unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end( + &harness().with_agent_dir(home.path()), + request_cwd("hello", &cwd.path().display().to_string()), + controls, + ) + .await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let recorded = + std::fs::read_to_string(cwd.path().join(".pi-fixture-session.jsonl")).unwrap(); + assert!(!recorded.contains("steering-mode:"), "{recorded}"); +} + +/// A mode configured in the project `.pi/settings.json` counts the same: +/// an explicit choice, never overwritten. +#[tokio::test] +async fn project_settings_count_as_a_configured_steering_mode() { + let home = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(cwd.path().join(".pi")).unwrap(); + std::fs::write( + cwd.path().join(".pi").join("settings.json"), + r#"{"steeringMode": "all"}"#, + ) + .unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end( + &harness().with_agent_dir(home.path()), + request_cwd("hello", &cwd.path().display().to_string()), + controls, + ) + .await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let recorded = + std::fs::read_to_string(cwd.path().join(".pi-fixture-session.jsonl")).unwrap(); + assert!(!recorded.contains("steering-mode:"), "{recorded}"); +} + +/// A mid-run steer carries its staged images inline — the same +/// `ImageContent` blocks a turn-start prompt rides — so steering with a +/// screenshot reaches pi. The fixture asserts the wire block itself. +#[tokio::test] +async fn steers_carry_their_attached_images() { + let image = tempfile::tempdir().unwrap(); + let png = image.path().join("shot.png"); + std::fs::write(&png, b"\x89PNG\r\n\x1a\nroboco steer image").unwrap(); + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("steer-image"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "first part ") + && !sent; + events.push(event); + if steer_now { + sent = true; + steer + .send(SteerMessage { + prompt: "look at this".into(), + message_id: None, + attachments: vec![png.display().to_string()], + config: None, + }) + .await + .unwrap(); + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Steered { .. })), "{events:?}"); + assert!(text_of(&events).contains("reply-to-image-steer"), "{events:?}"); +} + +/// Stop sends `clear_queue` before `abort`: steers pi queued but never +/// consumed are cancelled with the turn — never delivered into the aborted +/// run. The fixture asserts the wire order and that the cleared queue held +/// the stranded steer; here the run ends interrupted, exactly once. +#[tokio::test] +async fn stop_clears_pi_queue_before_abort() { + let (controls, steer, token) = controls(); + let mut stream = harness().run(request("stop-clears-queue"), controls).await.unwrap(); + let events = tokio::time::timeout(Duration::from_secs(10), async { + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = + matches!(&event, AgentEvent::TextDelta { text } if text == "working") && !sent; + let stop_now = matches!(&event, AgentEvent::TextDelta { text } if text == "steer queued"); + events.push(event); + if steer_now { + sent = true; + steer.send(SteerMessage::text("stranded steer")).await.unwrap(); + } + if stop_now { + token.cancel(); + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + events + }) + .await + .expect("stop must settle"); + assert_eq!(dones(&events), vec![(DoneStatus::Interrupted, None)], "{events:?}"); +} + +/// A run that recovered from an error through a successful compaction retry +/// ends completed, not errored: the recovered error no longer rejects the +/// prompt. +#[tokio::test] +async fn a_compaction_recovered_run_completes() { + let (controls, steer, _token) = controls(); + drop(steer); + let events = run_to_end(&harness(), request("compaction-recover"), controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // The overflow error surfaced (as a transient notice) and the retried + // turn still completed the run. + assert!(text_of(&events).contains("doomed"), "{events:?}"); + assert!(text_of(&events).contains("recovered"), "{events:?}"); + assert!( + events.iter().any(|e| matches!(e, AgentEvent::Error { message } if message.contains("overflow"))), + "{events:?}" + ); +} + +/// Several quick steers are all delivered, in order: pi (steering mode +/// "all") queues each while streaming and delivers them together at the +/// next step boundary. The fixture asserts the send order on the wire; +/// here every steer folds in with its own boundary and one turn ends. +#[tokio::test] +async fn a_burst_of_steers_is_delivered_in_order() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("steer-burst"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "first part ") + && !sent; + events.push(event); + if steer_now { + sent = true; + for text in ["burst one", "burst two", "burst three"] { + steer.send(SteerMessage::text(text)).await.unwrap(); + } + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let steered = events + .iter() + .filter(|e| matches!(e, AgentEvent::Steered { .. })) + .count(); + assert_eq!(steered, 3, "{events:?}"); + assert!(text_of(&events).contains("reply-to-burst"), "{events:?}"); +} diff --git a/docs/PARITY.md b/docs/PARITY.md index b08c4e118f..d83db03317 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -60,7 +60,7 @@ not built yet). | Devin (ACP) | done | Shared `AcpHarness` spec; `devin acp` (Cognition's native ACP server), turn-boundary steering, no effort ladder (effort rides the advertised model ids). Unattended mode via the `bypass` mode value. Subagent lifecycle and nested transcripts ride Devin's `cognition.ai/subagentSupport` extension. **Live-verified against the real `devin` CLI 3000.6.14** (2026-09-04): model discovery (193 rows), an edit + `cargo test` turn settled on the truthful `end_turn`, `run_subagent` rendered as a link chip with a nested child transcript, stop → `session/cancel` → `stopReason: cancelled`, and `session/load` resume. | | Grok (ACP) | done | Shared `AcpHarness` spec; `grok agent stdio`, turn-boundary steering. | | Hermes (ACP) | done | Shared `AcpHarness` spec; `hermes acp` (Nous Research's native ACP server), turn-boundary steering, no effort ladder yet. | -| Pi (native RPC) | done | `pi --mode rpc` — pi's first-party JSONL protocol (the maintainer-supported embedding path): id-correlated command responses, `agent_settled` + a state probe (not streaming, not compacting, zero pending messages, guarded by a settle generation) ends every turn shape deterministically, turn errors (assistant error / exhausted auto-retry / failed compaction) reject the prompt, usage/context stats from `get_session_stats` + `message_update.usage` (first-party replacements for the retired pi-acp fork's patches), every prompt — turn starts and mid-run steers alike — sent as `prompt` with `streamingBehavior: "steer"` (pi starts a run when idle, queues while streaming; the acknowledgement's `disposition` is read, and a rejected prompt never ends the session — the message re-queues), extension UI dialogs → the question flow, `--session ` durable resume. Turn-boundary steering, minimal→max thinking ladder, live model catalog from `get_available_models` (composite `/` rows, pi-acp spelling) with `set_model` applied per run, isolated title runs (`--no-tools` + shared title instructions as the system prompt). | +| Pi (native RPC) | done | `pi --mode rpc` — pi's first-party JSONL protocol (the maintainer-supported embedding path): id-correlated command responses, `agent_settled` + a state probe (not streaming, not compacting, zero pending messages, guarded by a settle generation) ends every turn shape deterministically, turn errors (assistant error / exhausted auto-retry / failed compaction) reject the prompt, usage/context stats from `get_session_stats` + `message_update.usage` (first-party replacements for the retired pi-acp fork's patches), every prompt — turn starts and mid-run steers alike — sent as `prompt` with `streamingBehavior: "steer"` (pi starts a run when idle, queues while streaming; the acknowledgement's `disposition` is read, and a rejected prompt never ends the session — the message re-queues), extension UI dialogs → the question flow, `--session ` durable resume. Step-boundary steering (mid-run messages reach pi at its next step boundary; steering mode `"all"` selected at session start unless the user configured one; steers carry their images; Stop clears pi's queue before `abort`; a compaction-recovered error ends the run completed), minimal→max thinking ladder, live model catalog from `get_available_models` (composite `/` rows, pi-acp spelling) with `set_model` applied per run, isolated title runs (`--no-tools` + shared title instructions as the system prompt). | | Mock harness | done | Scripted event replay; powers tests + the e2e smoke. | ## §5 Session doc schema From 41ff18d0343a8222500a0bc2fa306e1539899c0c Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 14:19:43 +0700 Subject: [PATCH 11/96] feat(forks): continue the real Codex thread from forked chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracer bullet for native forks (t3-orchestration-v2 ticket 17, spec stories 57, 58 and 66): forking a Codex chat now continues the source's real harness session instead of pasting history into a fresh one, with the budgeted transcript from ticket 01 as the fallback. - Transcript entries record an additive provider turn reference (harness, provider session id, turn ref). A new AgentEvent::TurnRef carries it: the Codex driver emits it whenever the app-server adopts a turn id (start response, turn/started, steer-as-new-turn), and the engine stamps it on the assistant entry when its segment finishes. - The ForkSideChat RPC records a native-fork intent on the new chat (doc meta, additive) when the boundary entry carries a ref, instead of only severing the session: source harness, provider session, and the inclusive boundary turn. - The fork's first dispatch injects the intent into RunRequest (additive providerFork). The Codex driver calls thread/fork {threadId, lastTurnId} instead of thread/resume; on success nothing is pasted and the reported session counts as holding the history. - Fallbacks: no ref (old chats), a changed harness, or a refused thread/fork all fall back to the budgeted history — the engine attaches the ticket-01 selection as the request's fallback prompt and the driver pastes it in front of the user message on the fresh thread it starts. The intent is consumed once. - transcript_budget gains budgeted_transcript_prefix (the selection without the user message) for the fallback; the paste path is byte-identical. - Wire: additive only (AgentEvent::TurnRef, RunRequest.providerFork, SessionMessageEntry.providerTurn; generated TS regenerated by hand in the wiregen layout). The codex fake app-server gains fork-success/fork-refused scenarios; the codex test suite is now wired into ui-tests.yml (it was not run by any workflow before). Tests: proto wire round-trips, doc stamp/meta round-trips and salvage, prefix composition, engine side-chat tests (ref-driven fork, consumed once, no-refs and changed-harness fallbacks, entry stamping), Codex harness tests (thread/fork params and no paste, refused-fork budgeted paste, one TurnRef per turn). Not run locally per the verification policy; CI is the signal. --- .github/workflows/ui-tests.yml | 5 + crates/doc/src/commands.rs | 1 + crates/doc/src/parts.rs | 6 +- crates/doc/src/rebuild.rs | 1 + crates/doc/src/schema.rs | 234 +++++++++++- crates/doc/src/transcript_delta.rs | 2 + crates/doc/src/transcript_resume.rs | 1 + crates/doc/tests/attachments_roundtrip.rs | 1 + crates/engine/examples/android_fixture.rs | 1 + crates/engine/src/doc_host.rs | 3 + crates/engine/src/rpc.rs | 26 ++ crates/engine/src/sessions.rs | 142 +++++++- crates/engine/src/titles.rs | 2 + crates/engine/src/transcript_budget.rs | 65 +++- crates/engine/src/transcript_history.rs | 1 + crates/engine/tests/acp_lifecycle.rs | 1 + crates/engine/tests/adapter_chaos_live.rs | 2 + crates/engine/tests/codex_subagents.rs | 1 + crates/engine/tests/cursor_live.rs | 1 + crates/engine/tests/e2e.rs | 5 + .../tests/m5c_accounts_uploads_titles.rs | 2 + crates/engine/tests/pi_resume.rs | 1 + crates/engine/tests/projectless.rs | 1 + crates/engine/tests/queued_attachments.rs | 1 + crates/engine/tests/restart_resume.rs | 8 + crates/engine/tests/rich_composer_delivery.rs | 1 + crates/engine/tests/runtime_survival.rs | 1 + crates/engine/tests/runtime_survival_live.rs | 1 + crates/engine/tests/self_continued_quiesce.rs | 1 + crates/engine/tests/side_chats.rs | 336 +++++++++++++++++- crates/engine/tests/subagent_idle_reap.rs | 1 + crates/engine/tests/transcript_resume.rs | 1 + crates/engine/tests/transcript_salvage.rs | 1 + crates/engine/tests/turn_quiesce.rs | 1 + crates/engine/tests/unarchive_on_send.rs | 1 + crates/engine/tests/worktree_on_run.rs | 1 + .../examples/cursor_stability_probe.rs | 4 + .../harness/examples/cursor_steering_probe.rs | 1 + crates/harness/examples/devin_models_probe.rs | 1 + .../harness/examples/grok_subagent_probe.rs | 1 + .../examples/opencode_subagent_probe.rs | 1 + .../harness/examples/opencode_turn_probe.rs | 1 + crates/harness/src/codex/mod.rs | 145 ++++++-- crates/harness/tests/acp.rs | 1 + crates/harness/tests/acp_quiet.rs | 1 + crates/harness/tests/acp_stall.rs | 1 + crates/harness/tests/claude.rs | 1 + crates/harness/tests/codex.rs | 106 ++++++ crates/harness/tests/cursor.rs | 1 + crates/harness/tests/fixtures/fake-codex.sh | 50 ++- crates/harness/tests/managed_install.rs | 1 + .../harness/tests/managed_install_failure.rs | 1 + crates/harness/tests/opencode.rs | 1 + crates/harness/tests/pi.rs | 1 + crates/harness/tests/real_acp_lifecycle.rs | 1 + crates/harness/tests/real_quiet_survey.rs | 1 + crates/harness/tests/windows_native.rs | 1 + crates/mobile-client/src/cache.rs | 1 + crates/mobile-client/src/session/commands.rs | 1 + crates/mobile-client/src/session/composer.rs | 1 + crates/mobile-client/src/session/mod.rs | 1 + .../mobile-client/src/session/transcript.rs | 1 + crates/mobile-client/tests/notify_engine.rs | 1 + .../mobile-client/tests/side_chats_engine.rs | 1 + crates/mobile/src/layout/mod.rs | 1 + crates/mobile/src/layout/tests.rs | 7 + crates/proto/src/agent.rs | 114 ++++++ crates/roboco-mcp/src/tools.rs | 1 + crates/roboco-mcp/src/transcript.rs | 1 + crates/ui/src/composer.rs | 6 + crates/ui/src/composer_dictation_tests.rs | 1 + crates/ui/src/files/sections.rs | 1 + crates/ui/src/rail.rs | 1 + crates/ui/src/shell.rs | 1 + crates/ui/src/state.rs | 3 + crates/ui/src/todo_panel.rs | 2 + crates/ui/src/transcript.rs | 2 + crates/wiregen/src/main.rs | 2 + .../proto/src/generated/AgentEvent.ts | 3 +- .../proto/src/generated/ProviderFork.ts | 26 ++ .../proto/src/generated/ProviderTurnRef.ts | 13 + .../proto/src/generated/RunRequest.ts | 12 + .../src/generated/SessionMessageEntry.ts | 12 +- web/packages/proto/src/generated/index.ts | 2 + 84 files changed, 1342 insertions(+), 58 deletions(-) create mode 100644 web/packages/proto/src/generated/ProviderFork.ts create mode 100644 web/packages/proto/src/generated/ProviderTurnRef.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 325ac22036..dce7ce9d7e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -53,6 +53,11 @@ jobs: run: | cargo test --locked -p roboco-harness --lib opencode:: cargo test --locked -p roboco-harness --test opencode + - name: Codex native driver integration tests + # t3-orchestration-v2 ticket 17: the fake app-server suite covers + # the native fork (thread/fork + fallback paste) and provider turn + # refs; it was previously not run by any workflow. + run: cargo test --locked -p roboco-harness --test codex - name: Pi native driver integration tests run: | cargo test --locked -p roboco-harness --test pi diff --git a/crates/doc/src/commands.rs b/crates/doc/src/commands.rs index f793606eeb..7b670dd43b 100644 --- a/crates/doc/src/commands.rs +++ b/crates/doc/src/commands.rs @@ -362,6 +362,7 @@ mod tests { worktree: None, mcp: None, resume: None, + provider_fork: None, } } } diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index d6c49073a9..b654638cf0 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -496,11 +496,15 @@ pub fn fold_event_into_parts(out: &mut Vec, event: &AgentEvent) { // AvailableCommands feeds the engine's per-harness command cache, not // the transcript. UserMessage becomes its own doc ENTRY (the engine's // subagent sink writes it), never a part of the assistant message. + // TurnRef names the provider turn the streaming entry belongs to — + // engine bookkeeping (stamped when the segment finishes), never a + // rendered part. AgentEvent::AssistantMessageCompleted { .. } | AgentEvent::Usage { .. } | AgentEvent::ContextUsage { .. } | AgentEvent::AvailableCommands { .. } - | AgentEvent::UserMessage { .. } => {} + | AgentEvent::UserMessage { .. } + | AgentEvent::TurnRef { .. } => {} } } diff --git a/crates/doc/src/rebuild.rs b/crates/doc/src/rebuild.rs index a2d31e11ce..2ceff364ad 100644 --- a/crates/doc/src/rebuild.rs +++ b/crates/doc/src/rebuild.rs @@ -190,6 +190,7 @@ mod tests { status: Some(MessageStatus::Complete), continuation_of: None, duration_ms: None, + provider_turn: None, } } diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index 998ae67a13..06dbafaced 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -4,7 +4,7 @@ //! - `meta`: LoroMap { chatId: string, schemaVersion: number } (host-only writer) //! - `messages`: LoroList of LoroMap { //! id, role, parts: LoroList, createdAt, deviceId, status?, -//! continuationOf?, durationMs? } +//! continuationOf?, durationMs?, providerTurn? } //! - `commands`: LoroList of LoroMap { //! id, kind, payload(json), issuedBy, issuedAt, basedOn?, expiresAt?, status, resolution? } //! - `queue`: LoroMovableList of LoroMap { @@ -60,6 +60,14 @@ pub struct SessionMessageEntry { /// the field existed. #[serde(default, skip_serializing_if = "Option::is_none")] pub duration_ms: Option, + /// The provider turn that produced this assistant entry (additive, + /// t3-orchestration-v2 ticket 17): harness, provider session id, and the + /// harness's own turn ref. A fork continues the provider session at + /// exactly this entry using it. Absent on user/system rows and on docs + /// written before the field existed — those fall back to the budgeted + /// paste. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_turn: Option, } /// The doc-resident flat part map (`DocMessagePart` in TS). Distinct from the app-layer @@ -395,6 +403,118 @@ impl SessionDoc { Ok(()) } + /// The chat's outstanding native-fork intent (additive meta, + /// t3-orchestration-v2 ticket 17): the source harness, provider session + /// id, and inclusive boundary recorded by the ForkSideChat RPC. The + /// fork's first dispatch consumes it — the driver then forks the source + /// provider session instead of resuming or starting fresh. + pub fn provider_fork(&self) -> Option { + let meta = self.doc.get_map("meta"); + let str_field = |key: &str| match meta.get(key) { + Some(loro::ValueOrContainer::Value(LoroValue::String(s))) => Some(s.to_string()), + _ => None, + }; + let fork = roboco_proto::ProviderFork { + session_id: str_field("forkSourceSession")?, + last_turn: str_field("forkSourceTurn")?, + fallback_history: None, + }; + (!fork.session_id.is_empty() && !fork.last_turn.is_empty()).then_some(fork) + } + + /// The recorded source harness of the native-fork intent, when present. + /// A run on any other harness must fall back to the budgeted paste. + pub fn provider_fork_harness(&self) -> Option { + match self.doc.get_map("meta").get("forkSourceHarness") { + Some(loro::ValueOrContainer::Value(LoroValue::String(s))) => { + serde_json::from_value(serde_json::Value::String(s.to_string())).ok() + } + _ => None, + } + } + + /// Record the native-fork intent: the source harness's provider session + /// and the inclusive boundary turn the fork continues at. One commit, so + /// a partial write is never observable. + pub fn set_provider_fork( + &self, + harness: roboco_proto::HarnessId, + fork: &roboco_proto::ProviderFork, + ) -> Result<(), DocError> { + let harness = serde_json::to_value(harness) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .ok_or_else(|| DocError::Schema("harness wire name not a string".into()))?; + let meta = self.doc.get_map("meta"); + meta.insert("forkSourceHarness", harness)?; + meta.insert("forkSourceSession", fork.session_id.as_str())?; + meta.insert("forkSourceTurn", fork.last_turn.as_str())?; + self.doc.commit(); + Ok(()) + } + + /// Consume the native-fork intent for a run on `harness`: the recorded + /// spec when the source harness matches, `None` otherwise (the intent + /// is spent either way — a changed harness falls back to the budgeted + /// paste and never comes back to the native path). + pub fn take_provider_fork( + &self, + harness: roboco_proto::HarnessId, + ) -> Option { + let fork = self.provider_fork(); + let recorded = self.provider_fork_harness(); + self.clear_provider_fork(); + (recorded == Some(harness)).then_some(fork).flatten() + } + + /// Drop the native-fork intent (consumed, mismatched, or superseded). + /// No-op when nothing is recorded — dispatch consults the intent on + /// every side-chat send, and a doc commit per dispatch would be pure + /// write amplification. + pub fn clear_provider_fork(&self) { + if self.provider_fork().is_none() && self.provider_fork_harness().is_none() { + return; + } + let meta = self.doc.get_map("meta"); + for key in ["forkSourceHarness", "forkSourceSession", "forkSourceTurn"] { + let _ = meta.delete(key); + } + self.doc.commit(); + } + + /// Stamp a provider turn reference onto a transcript entry (additive): + /// the harness, provider session id and per-harness turn ref that + /// produced it. A fork uses the boundary entry's ref to continue the + /// provider session at exactly that turn. No-op for a missing entry — + /// a segment that never streamed has nothing to stamp. + pub fn stamp_provider_turn( + &self, + entry_id: &str, + reference: &roboco_proto::ProviderTurnRef, + ) -> Result<(), DocError> { + let messages = self.doc.get_list("messages"); + for index in 0..messages.len() { + let Some(loro::ValueOrContainer::Container(loro::Container::Map(map))) = + messages.get(index) + else { + continue; + }; + let matches = matches!(map.get("id"), + Some(loro::ValueOrContainer::Value(LoroValue::String(s))) + if s.as_str() == entry_id); + if !matches { + continue; + } + map.insert( + "providerTurn", + loro_value_from_json(&serde_json::to_value(reference)?), + )?; + self.doc.commit(); + return Ok(()); + } + Ok(()) + } + pub fn chat_id(&self) -> Option { match self.doc.get_map("meta").get("chatId") { Some(loro::ValueOrContainer::Value(LoroValue::String(s))) => Some(s.to_string()), @@ -815,6 +935,12 @@ fn write_entry_scalar_fields(map: &LoroMap, entry: &SessionMessageEntry) -> Resu if let Some(duration_ms) = entry.duration_ms { map.insert("durationMs", duration_ms)?; } + if let Some(reference) = &entry.provider_turn { + map.insert( + "providerTurn", + loro_value_from_json(&serde_json::to_value(reference)?), + )?; + } Ok(()) } @@ -914,6 +1040,8 @@ fn entry_from_json(v: serde_json::Value) -> Result, #[serde(default)] duration_ms: Option, + #[serde(default)] + provider_turn: Option, } match serde_json::from_value::(v.clone()) { Ok(raw) => Ok(SessionMessageEntry { @@ -925,6 +1053,7 @@ fn entry_from_json(v: serde_json::Value) -> Result SegmentWriter<'a> { status: Some(MessageStatus::Streaming), continuation_of: None, duration_ms: None, + provider_turn: None, }, )?; map.insert_container("parts", LoroList::new())?; @@ -1409,6 +1542,7 @@ mod tests { }; doc.push_message(&SessionMessageEntry { duration_ms: None, + provider_turn: None, id: "fork:side".into(), role: MessageRole::System, parts: vec![seam.clone()], @@ -1424,6 +1558,100 @@ mod tests { assert_eq!(entries[0].parts, vec![seam]); } + #[test] + fn provider_turn_round_trips_and_stamps_only_existing_entries() { + let doc = SessionDoc::init("refs").unwrap(); + let reference = roboco_proto::ProviderTurnRef { + harness: roboco_proto::HarnessId::Codex, + session_id: "th-1".into(), + turn_id: "t-1".into(), + }; + // A stamped assistant entry keeps its ref; an untouched sibling does + // not; a missing id is a no-op, never an error. + let mut entry = user_entry("u1", "question"); + entry.role = MessageRole::Assistant; + doc.push_message(&entry).unwrap(); + doc.stamp_provider_turn("u1", &reference).unwrap(); + doc.stamp_provider_turn("gone", &reference).unwrap(); + let mut plain = user_entry("u2", "unmarked"); + plain.role = MessageRole::Assistant; + doc.push_message(&plain).unwrap(); + let entries = doc.read_entries().unwrap(); + assert_eq!(entries[0].provider_turn.as_ref(), Some(&reference)); + assert_eq!(entries[1].provider_turn, None); + // A snapshot import keeps the ref (the fork boundary survives a + // device sync). + let imported = LoroDoc::new(); + imported + .import(&doc.export_snapshot().unwrap()) + .unwrap(); + let loaded = SessionDoc::from_doc(imported); + assert_eq!( + loaded.read_entries().unwrap()[0].provider_turn.as_ref(), + Some(&reference) + ); + } + + #[test] + fn provider_turn_survives_salvage_of_a_torn_entry() { + // A strict parse that fails on an unrelated field (a missing + // deviceId) must still keep the ref — the 2026-08-10 incident rule. + let torn = serde_json::json!({ + "id": "a1", + "role": "assistant", + "parts": [], + "createdAt": 1, + "providerTurn": { + "harness": "codex", + "sessionId": "th-1", + "turnId": "t-9" + } + }); + let salvaged = + salvage_entry(torn, serde_json::from_str::("not json").unwrap_err()) + .unwrap(); + let reference = roboco_proto::ProviderTurnRef { + harness: roboco_proto::HarnessId::Codex, + session_id: "th-1".into(), + turn_id: "t-9".into(), + }; + assert_eq!(salvaged.provider_turn.as_ref(), Some(&reference)); + } + + #[test] + fn provider_fork_intent_is_recorded_and_consumed_once() { + let doc = SessionDoc::init("fork-intent").unwrap(); + assert_eq!(doc.provider_fork(), None); + let fork = roboco_proto::ProviderFork { + session_id: "th-source".into(), + last_turn: "turn-7".into(), + fallback_history: None, + }; + doc.set_provider_fork(roboco_proto::HarnessId::Codex, &fork) + .unwrap(); + assert_eq!(doc.provider_fork_harness(), Some(roboco_proto::HarnessId::Codex)); + assert_eq!(doc.provider_fork(), Some(fork)); + // Consumed by a run on the recorded harness. + assert_eq!(doc.take_provider_fork(roboco_proto::HarnessId::Codex), Some(fork)); + // Consumed once: the next dispatch — same harness or not — has + // nothing left, so it falls back to the budgeted paste. + assert_eq!(doc.take_provider_fork(roboco_proto::HarnessId::Codex), None); + assert_eq!(doc.provider_fork(), None); + // A mismatched harness spends the intent too, without ever taking + // the native path. + doc.set_provider_fork(roboco_proto::HarnessId::Codex, &fork) + .unwrap(); + assert_eq!(doc.take_provider_fork(roboco_proto::HarnessId::Mock), None); + assert_eq!(doc.provider_fork(), None); + // A partial write (session without turn) never reads as an intent. + doc.doc + .get_map("meta") + .insert("forkSourceSession", "th-source") + .unwrap(); + doc.doc.commit(); + assert_eq!(doc.provider_fork(), None); + } + #[test] fn generated_image_persists_updates_and_salvages() { let doc = SessionDoc::init("images").unwrap(); @@ -1479,6 +1707,7 @@ mod tests { status: Some(MessageStatus::Complete), continuation_of: None, duration_ms: None, + provider_turn: None, } } @@ -1500,6 +1729,7 @@ mod tests { status: Some(MessageStatus::Complete), continuation_of: (segment > 0).then(|| "segment-0".into()), duration_ms: None, + provider_turn: None, }) .unwrap(); } @@ -1746,6 +1976,7 @@ mod tests { status: Some(MessageStatus::Aborted), continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap(); assert!(!doc.resolve_input("nope").unwrap()); @@ -2009,6 +2240,7 @@ mod tests { status: Some(MessageStatus::Complete), continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap(); let entries = doc.read_entries().unwrap(); diff --git a/crates/doc/src/transcript_delta.rs b/crates/doc/src/transcript_delta.rs index 1e7395911f..293cd590df 100644 --- a/crates/doc/src/transcript_delta.rs +++ b/crates/doc/src/transcript_delta.rs @@ -371,6 +371,7 @@ mod tests { status: None, continuation_of: None, duration_ms: None, + provider_turn: None, } } @@ -556,6 +557,7 @@ mod context_update_tests { status: Some(crate::MessageStatus::Streaming), continuation_of: None, duration_ms: None, + provider_turn: None, }; let baseline = TranscriptBaseline::capture(&[entry.clone()]); assert!(baseline.covers(&entry)); diff --git a/crates/doc/src/transcript_resume.rs b/crates/doc/src/transcript_resume.rs index e24b45d7c9..af136d2272 100644 --- a/crates/doc/src/transcript_resume.rs +++ b/crates/doc/src/transcript_resume.rs @@ -356,6 +356,7 @@ mod tests { status: None, continuation_of: None, duration_ms: None, + provider_turn: None, } } diff --git a/crates/doc/tests/attachments_roundtrip.rs b/crates/doc/tests/attachments_roundtrip.rs index 595acb0893..1f0c5fcb92 100644 --- a/crates/doc/tests/attachments_roundtrip.rs +++ b/crates/doc/tests/attachments_roundtrip.rs @@ -19,6 +19,7 @@ fn run_request_attachments_survive_command_round_trip() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; doc.queue_command(&SessionCommandEntry { id: "c1".into(), diff --git a/crates/engine/examples/android_fixture.rs b/crates/engine/examples/android_fixture.rs index 299784a2d9..3ef9217614 100644 --- a/crates/engine/examples/android_fixture.rs +++ b/crates/engine/examples/android_fixture.rs @@ -578,6 +578,7 @@ fn seed_request(repo: &Path, chat: &SeedChat) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 9d18e6538b..a4045dcc60 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -455,6 +455,7 @@ impl ChatDocHandle { device_id: self.device_id.clone(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) } @@ -3171,6 +3172,7 @@ impl DocHost { auto_approve: false, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, }) @@ -3281,6 +3283,7 @@ mod transcript_revisit_tests { device_id: "remote".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 7c5021b167..a8d6c5d7fd 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1588,6 +1588,7 @@ impl RpcService for EngineRpc { .doc() .push_message(&roboco_doc::SessionMessageEntry { duration_ms: None, + provider_turn: None, id: marker_id.clone(), role: roboco_doc::MessageRole::System, parts: vec![roboco_doc::MessagePart::Fork { @@ -1602,6 +1603,28 @@ impl RpcService for EngineRpc { }) .map_err(|e| RpcError::Failed(e.to_string()))?; } + // Native fork intent (t3-orchestration-v2 ticket 17): when the + // boundary entry carries a provider turn ref, the fork records + // the source provider session and the boundary on the new chat — + // its first run asks the harness to fork the source session at + // that turn (Codex `thread/fork {threadId, lastTurnId}`) + // instead of resuming it, so the copied history rides the + // provider and nothing is pasted. No ref — old chats, or a + // harness that records none — keeps today's behavior: a fresh + // session plus the budgeted paste. + if let Some(reference) = entries[boundary].provider_turn.clone() { + target + .doc() + .set_provider_fork( + reference.harness, + &roboco_proto::ProviderFork { + session_id: reference.session_id, + last_turn: reference.turn_id, + fallback_history: None, + }, + ) + .map_err(|e| RpcError::Failed(e.to_string()))?; + } self.doc_host.persist_fork(&target).map_err(failed)?; self.workspace.import_chat_row(&chat).map_err(failed)?; RpcReply::value(&chat) @@ -3321,6 +3344,7 @@ mod tests { status: None, continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap(); // Hold publication blocked: the opening must not await the full mirror. @@ -3516,6 +3540,7 @@ mod context_usage_tests { status: Some(roboco_doc::MessageStatus::Streaming), continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap() }; @@ -3649,6 +3674,7 @@ mod context_usage_tests { status: Some(roboco_doc::MessageStatus::Streaming), continuation_of: None, duration_ms: None, + provider_turn: None, }; handle.doc().push_message(&entry("local-before")).unwrap(); source.update_context_usage(Some(10), Some(100)).unwrap(); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 19279d88e2..7a2bc75702 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -631,6 +631,19 @@ impl SessionsEngine { request.resume = self.inner.resume_for(chat_id, &request.cwd); resume_injected = request.resume.is_some(); } + // A fork's first run continues the SOURCE's provider session + // natively (t3-orchestration-v2 ticket 17): the ForkSideChat RPC + // recorded the fork source and boundary on the chat, and the driver + // forks the provider session there — the copied history rides the + // provider, so nothing is pasted. The intent is consumed once; a + // missing ref (old chats), a changed harness, or a refused fork + // falls back to the budgeted transcript from ticket 01. Consumed + // even when the harness mismatches: the native path never re-arms. + if request.provider_fork.is_none() && request.resume.is_none() { + if let Some(fork) = handle.doc().take_provider_fork(harness_id) { + request.provider_fork = Some(fork); + } + } lock(&self.inner.last_requests).insert(chat_id.to_string(), request.clone()); let run_id = new_id(); @@ -1145,6 +1158,7 @@ impl SessionsEngine { auto_approve: false, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, }) @@ -1617,6 +1631,24 @@ impl Inner { prompt: &str, current: Option<&str>, provider: ProviderSession<'_>, + ) -> Option { + self.fork_history_prefix(chat_id, doc, harness_id, prompt, current, provider) + .map(|prefix| format!("{prefix}{prompt}")) + } + + /// The transcript prefix [`Self::fork_history_prompt`] would deliver — + /// the budgeted selection alone, without the prompt appended. The + /// native-fork path (ticket 17) rides this as the request's fallback + /// history: the driver pastes it in front of the prompt only when the + /// harness refuses the fork, so a successful fork delivers nothing. + fn fork_history_prefix( + &self, + chat_id: &str, + doc: &SessionDoc, + harness_id: HarnessId, + prompt: &str, + current: Option<&str>, + provider: ProviderSession<'_>, ) -> Option { if native_command(prompt, harness_id) || !self @@ -1657,7 +1689,7 @@ impl Inner { .rev() .find_map(fork_seam_source) .unwrap_or_else(|| chat_id.to_owned()); - crate::transcript_budget::budgeted_transcript_prompt(&entries[..end], Some(&source), prompt) + crate::transcript_budget::budgeted_transcript_prefix(&entries[..end], Some(&source)) } } @@ -1828,6 +1860,7 @@ impl SubagentSink { status: Some(MessageStatus::Complete), continuation_of: None, duration_ms: None, + provider_turn: None, }; if let Err(err) = self.doc.push_message(&entry) { tracing::warn!(doc = %self.doc_id, error = %err, "subagent steer write failed"); @@ -1952,6 +1985,7 @@ fn sync_segment<'a>( Ok(()) } +#[allow(clippy::too_many_arguments)] fn finish_segment<'a>( doc: &'a SessionDoc, writer: Option>, @@ -1960,15 +1994,26 @@ fn finish_segment<'a>( started_at: i64, folded: &[MessagePart], status: MessageStatus, + turn_ref: Option<&roboco_proto::ProviderTurnRef>, ) -> Result<(), DocError> { let rendered = render_parts(folded); - match writer { + let outcome = match writer { Some(w) => w.finish(&rendered, status), None if !folded.is_empty() => { SegmentWriter::begin(doc, entry_id, device_id, started_at)?.finish(&rendered, status) } None => Ok(()), + }?; + // The provider turn the finished entry belongs to (ticket 17): stamped + // after the write, a no-op for an entry that never streamed. A failure + // here costs the ref (a fork falls back to the budgeted paste), never + // the segment. + if let Some(reference) = turn_ref + && let Err(err) = doc.stamp_provider_turn(entry_id, reference) + { + tracing::warn!(chat = %doc.chat_id().unwrap_or_default(), error = %err, "provider turn ref stamp failed"); } + Ok(outcome) } /// Resume bookkeeping for one run task: which user entry the run answers (so @@ -2076,7 +2121,9 @@ async fn drive_run( } // Kept whole for the startup-crash retry (same user entry; dispatch // re-injects the stored resume id). Option so the retry branch (inside - // the event loop) can take ownership. + // the event loop) can take ownership. The native-fork directive spreads + // in from `request` — a startup crash is transient, so the retry + // re-attempts the fork and re-derives the fallback history. let mut retry_request = Some(RunRequest { resume: None, ..request.clone() @@ -2084,23 +2131,51 @@ async fn drive_run( // A side chat owns a fresh provider session. Bootstrap it from the frozen // conversation, never resume (and mutate) the parent's provider session. let mut carried_history = false; - let provider = match request.resume.as_deref() { - None => ProviderSession::Fresh, - resumed => ProviderSession::Continued(resumed), - }; - if let Some(prompt) = inner.fork_history_prompt( - &chat_id, - &doc, - harness_id, - &request.prompt, - Some(&resume_state.user_message_id), - provider, - ) { - request.prompt = prompt; + if let Some(fork) = request.provider_fork.as_mut() { + // A native fork (t3-orchestration-v2 ticket 17): the driver forks the + // source provider session at the boundary, so the copied history + // rides the provider and the delivered prompt carries nothing. The + // budgeted transcript still rides the request — the driver pastes it + // in front of the prompt on the fresh session it starts when the + // harness refuses the fork. Either way the session this run reports + // holds the copied history, so warm sends never re-deliver it. + if fork.fallback_history.is_none() { + // The Fresh selection: a session the fork refused to continue + // knows nothing of the chat, so the fallback is the whole + // budgeted transcript (the seam and everything after it are + // structure, not transcript). + fork.fallback_history = inner.fork_history_prefix( + &chat_id, + &doc, + harness_id, + &request.prompt, + Some(&resume_state.user_message_id), + ProviderSession::Fresh, + ); + } carried_history = true; resume_state .fork_history_sent .store(true, std::sync::atomic::Ordering::Release); + } else { + let provider = match request.resume.as_deref() { + None => ProviderSession::Fresh, + resumed => ProviderSession::Continued(resumed), + }; + if let Some(prompt) = inner.fork_history_prompt( + &chat_id, + &doc, + harness_id, + &request.prompt, + Some(&resume_state.user_message_id), + provider, + ) { + request.prompt = prompt; + carried_history = true; + resume_state + .fork_history_sent + .store(true, std::sync::atomic::Ordering::Release); + } } // Startup can stop before the SDK saves user text, with no new session // ID or receipt. Bridge that unacknowledged tail from our transcript; @@ -2142,6 +2217,17 @@ async fn drive_run( if !matches!(harness_id, HarnessId::Cursor | HarnessId::Opencode) { wire_request.prompt = roboco_proto::invocation::harness_prompt(&wire_request.prompt, harness_id); + // The fork fallback rides the same conversion the + // pasted prompt would get (Codex: file mentions), so a + // refused fork's fresh session reads a transcript as + // close to the native paste as the driver can deliver. + if let Some(fork) = wire_request.provider_fork.as_mut() + && let Some(history) = fork.fallback_history.clone() + { + fork.fallback_history = Some( + roboco_proto::invocation::harness_prompt(&history, harness_id), + ); + } } harness.run(wire_request, controls).await } @@ -2183,6 +2269,8 @@ async fn drive_run( now_ms(), &parts, MessageStatus::Complete, + // The run never started: no provider turn ever streamed. + None, ) { tracing::warn!(chat = %chat_id, error = %err, "start failure entry failed"); } @@ -2230,6 +2318,11 @@ async fn drive_run( let mut prepared_events = std::collections::VecDeque::new(); let mut entry_id = new_id(); let mut segment_started = now_ms(); + // The provider turn the entry currently streaming belongs to (ticket 17): + // set by a TurnRef event, stamped onto the entry when its segment + // finishes, so a fork can continue the provider session at exactly that + // turn. Survives steer boundaries — a steer mid-turn keeps the turn. + let mut turn_ref: Option = None; let mut writer: Option> = None; let mut dirty = false; let mut flush_at = tokio::time::Instant::now(); @@ -2546,6 +2639,7 @@ async fn drive_run( segment_started, &folded, MessageStatus::Complete, + turn_ref.as_ref(), ) { tracing::warn!(chat = %chat_id, error = %err, "quiesce segment finish failed"); } @@ -2972,6 +3066,11 @@ async fn drive_run( segment_started, &folded, MessageStatus::Complete, + // The segment closes at the steer, but the provider turn it + // belongs to carries on: the ref survives the rotation so the + // NEXT segment of the same turn keeps it (a new turn's TurnRef + // replaces it before that segment finishes). + turn_ref.as_ref(), ) { tracing::warn!(chat = %chat_id, error = %err, "segment finish failed"); } @@ -3013,6 +3112,13 @@ async fn drive_run( inner.remember_harness_session(&chat_id, session_id, cwd); note_fork_history_session(&doc, carried_history, session_id); } + AgentEvent::TurnRef { reference } => { + // The provider turn now streaming into `entry_id`: held for + // the stamp at segment finish (below). Like SessionStarted, + // it is engine bookkeeping — the fold below renders nothing + // for it. + turn_ref = Some(reference.clone()); + } AgentEvent::Done { session_id: Some(session_id), .. @@ -3088,6 +3194,7 @@ async fn drive_run( segment_started, &folded, message_status, + turn_ref.as_ref(), ) { tracing::warn!(chat = %chat_id, error = %err, "final segment finish failed"); } @@ -3386,6 +3493,7 @@ mod tests { status: None, continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap(); let current = format!("Current {skill}\nKeep **Markdown**"); @@ -3427,6 +3535,7 @@ mod tests { status: None, continuation_of: None, duration_ms: None, + provider_turn: None, }) .unwrap(); } @@ -3608,6 +3717,7 @@ mod tests { sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume: None, + provider_fork: None, attachments: Vec::new(), worktree: None, mcp: None, diff --git a/crates/engine/src/titles.rs b/crates/engine/src/titles.rs index 99984d5b69..2894b43e98 100644 --- a/crates/engine/src/titles.rs +++ b/crates/engine/src/titles.rs @@ -210,6 +210,7 @@ impl TitleGenerator { auto_approve: false, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, }; @@ -380,6 +381,7 @@ mod tests { resume: None, attachments: vec![], worktree: None, + provider_fork: None, mcp: None, }; let result = collect_text(&harness, request, None).await; diff --git a/crates/engine/src/transcript_budget.rs b/crates/engine/src/transcript_budget.rs index e5aba64233..c00b67e7b8 100644 --- a/crates/engine/src/transcript_budget.rs +++ b/crates/engine/src/transcript_budget.rs @@ -28,10 +28,11 @@ //! //! One engine piece on purpose: every path that must hand a chat's prior //! transcript to a session that lacks it reuses -//! [`budgeted_transcript_prompt`] — the fork bootstrap (ticket 01), the -//! account-failover fallback (ticket 09), the native-fork fallbacks -//! (17–20), forks into another harness (22) and the one-way harness switch -//! (24). +//! [`budgeted_transcript_prompt`] (or its prefix form, +//! [`budgeted_transcript_prefix`], which the native-fork fallback rides) — +//! the fork bootstrap (ticket 01), the account-failover fallback (ticket +//! 09), the native-fork fallbacks (17–20), forks into another harness (22) +//! and the one-way harness switch (24). use roboco_doc::{join_continuation_entries, MessagePart, MessageRole, SessionMessageEntry}; use roboco_proto::{ToolCall, ToolDiff}; @@ -75,6 +76,20 @@ pub fn budgeted_transcript_prompt( entries: &[SessionMessageEntry], source_chat_id: Option<&str>, prompt: &str, +) -> Option { + budgeted_transcript_prefix(entries, source_chat_id) + .map(|prefix| format!("{prefix}{prompt}")) +} + +/// The budgeted transcript alone (ticket 17): everything +/// [`budgeted_transcript_prompt`] would put in front of the new user +/// message, without the message. The native-fork path rides this as the +/// request's fallback history — the driver pastes it in front of the prompt +/// only when the harness refuses the fork, so a successful fork pastes +/// nothing at all. +pub fn budgeted_transcript_prefix( + entries: &[SessionMessageEntry], + source_chat_id: Option<&str>, ) -> Option { let items = transcript_items(entries); let lines: Vec = items @@ -88,7 +103,7 @@ pub fn budgeted_transcript_prompt( let transcript = lines.join("\n"); if wrapper_fixed + transcript.len() <= TRANSCRIPT_BUDGET_BYTES { // The whole transcript fits: complete, verbatim, no coverage note. - return Some(wrap(None, &lines, prompt)); + return Some(wrap(None, &lines)); } // Selection mode. The header's length depends on the omitted count, so @@ -142,10 +157,10 @@ pub fn budgeted_transcript_prompt( // Unreachable with the shipped budget (three capped priorities // always fit); degrade to the verbatim transcript rather than send // an empty block. - return Some(wrap(None, &lines, prompt)); + return Some(wrap(None, &lines)); } let header = coverage_header(omitted, source_chat_id); - Some(wrap(Some(header), &selected, prompt)) + Some(wrap(Some(header), &selected)) } /// Render transcript entries into handoff items. Continuation entries are @@ -328,9 +343,10 @@ fn coverage_header_max(items: usize, source_chat_id: Option<&str>) -> usize { + 1 } -/// Assemble the delivered prompt: lead, optional coverage header, the -/// transcript block, then the new user message — whole, always last. -fn wrap(header: Option, lines: &[String], prompt: &str) -> String { +/// Assemble the budgeted transcript: lead, optional coverage header, and +/// the transcript block — ending with the blank line that separates it +/// from whatever follows (the new user message, whole and always last). +fn wrap(header: Option, lines: &[String]) -> String { let mut out = String::with_capacity(LEAD.len() + TRANSCRIPT_OPEN.len() + TRANSCRIPT_CLOSE.len() + 256); out.push_str(LEAD); @@ -342,7 +358,6 @@ fn wrap(header: Option, lines: &[String], prompt: &str) -> String { out.push_str(TRANSCRIPT_OPEN); out.push_str(&lines.join("\n")); out.push_str(TRANSCRIPT_CLOSE); - out.push_str(prompt); out } @@ -354,6 +369,7 @@ mod tests { fn entry(id: &str, role: MessageRole, parts: Vec) -> SessionMessageEntry { SessionMessageEntry { duration_ms: None, + provider_turn: None, id: id.into(), role, parts, @@ -710,4 +726,31 @@ mod tests { assert!(budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).is_none()); assert!(budgeted_transcript_prompt(&[], Some("main"), NEW_MESSAGE).is_none()); } + + #[test] + fn the_prefix_is_the_prompt_without_its_tail() { + // The native-fork fallback (ticket 17) rides the prefix: the driver + // appends the user message itself, so the prefix must be exactly the + // delivered prompt minus the message — same selection, same header, + // same trailing blank line, never counting the message. + let mut entries = vec![ + user("u0", "ORIGINAL: build the parser"), + assistant("a0", "on it"), + ]; + for i in 1..=20 { + entries.push(user(&format!("u{i}"), &format!("fill-{i}: {}", "x".repeat(900)))); + entries.push(assistant(&format!("a{i}"), &format!("note-{i}: {}", "y".repeat(90)))); + } + let prefix = budgeted_transcript_prefix(&entries, Some("main")).unwrap(); + let prompt = budgeted_transcript_prompt(&entries, Some("main"), NEW_MESSAGE).unwrap(); + assert!(prompt.starts_with(&prefix), "prefix must head the prompt"); + assert!(prompt.ends_with(NEW_MESSAGE)); + assert_eq!(&prompt[..prefix.len()], &prefix); + assert!(prefix.ends_with("\n\n")); + assert!(!prefix.contains(NEW_MESSAGE)); + assert!(prefix.contains("read_chat")); + assert!(prefix.len() <= TRANSCRIPT_BUDGET_BYTES); + // An undeliverable transcript has no prefix either. + assert!(budgeted_transcript_prefix(&[], Some("main")).is_none()); + } } diff --git a/crates/engine/src/transcript_history.rs b/crates/engine/src/transcript_history.rs index 63945e6162..d6651db140 100644 --- a/crates/engine/src/transcript_history.rs +++ b/crates/engine/src/transcript_history.rs @@ -151,6 +151,7 @@ mod tests { device_id: "host".into(), status: Some(MessageStatus::Streaming), continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/engine/tests/acp_lifecycle.rs b/crates/engine/tests/acp_lifecycle.rs index 076f2ea756..8f61ee06af 100644 --- a/crates/engine/tests/acp_lifecycle.rs +++ b/crates/engine/tests/acp_lifecycle.rs @@ -68,6 +68,7 @@ async fn quiet_acp_prompt_stays_working_until_response() { worktree: None, mcp: None, resume: None, + provider_fork: None, }, }, ); diff --git a/crates/engine/tests/adapter_chaos_live.rs b/crates/engine/tests/adapter_chaos_live.rs index 3b2eb24cd2..39dc5b49aa 100644 --- a/crates/engine/tests/adapter_chaos_live.rs +++ b/crates/engine/tests/adapter_chaos_live.rs @@ -151,6 +151,7 @@ impl Suite { attachments: vec![], worktree: None, resume: None, + provider_fork: None, } } @@ -1215,6 +1216,7 @@ fn restart_request(name: &str, cwd: &str, prompt: &str) -> RunRequest { attachments: vec![], worktree: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/codex_subagents.rs b/crates/engine/tests/codex_subagents.rs index 83ecc5ef3b..2a21f745fd 100644 --- a/crates/engine/tests/codex_subagents.rs +++ b/crates/engine/tests/codex_subagents.rs @@ -65,6 +65,7 @@ async fn check_persistence( worktree: None, mcp: None, resume: None, + provider_fork: None, }; core.sessions .dispatch( diff --git a/crates/engine/tests/cursor_live.rs b/crates/engine/tests/cursor_live.rs index 3c9010145b..b067cd9e80 100644 --- a/crates/engine/tests/cursor_live.rs +++ b/crates/engine/tests/cursor_live.rs @@ -84,6 +84,7 @@ fn start(core: &EngineCore, cwd: &std::path::Path, prompt: String) { worktree: None, mcp: None, resume: None, + provider_fork: None, }, }, ) diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 2ca2aa62aa..9927e48eed 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -39,6 +39,7 @@ fn run_request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } @@ -1237,6 +1238,7 @@ async fn recover_stale_journal_stamps_aborted_on_boot() { device_id: device_id.into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -1339,6 +1341,7 @@ async fn recover_stale_journal_settles_chips_in_completed_local_entries() { device_id: device.into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -1407,6 +1410,7 @@ async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { device_id: core.device_id.clone(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -2439,6 +2443,7 @@ async fn real_claude_sees_uploaded_image_inline() { auto_approve: false, attachments: vec![path], resume: None, + provider_fork: None, worktree: None, mcp: None, }; diff --git a/crates/engine/tests/m5c_accounts_uploads_titles.rs b/crates/engine/tests/m5c_accounts_uploads_titles.rs index 4781895067..50ea4d0486 100644 --- a/crates/engine/tests/m5c_accounts_uploads_titles.rs +++ b/crates/engine/tests/m5c_accounts_uploads_titles.rs @@ -967,6 +967,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; core.sessions .dispatch(chat_id, HarnessId::Mock, request, None) @@ -1013,6 +1014,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; core.sessions .dispatch(chat_id, HarnessId::Mock, request, None) diff --git a/crates/engine/tests/pi_resume.rs b/crates/engine/tests/pi_resume.rs index 3772d43bc3..7bda087a65 100644 --- a/crates/engine/tests/pi_resume.rs +++ b/crates/engine/tests/pi_resume.rs @@ -36,6 +36,7 @@ async fn pi_idle_crash_next_dispatch_resumes_the_stored_session() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; core.sessions .dispatch(chat, HarnessId::Pi, req, None) diff --git a/crates/engine/tests/projectless.rs b/crates/engine/tests/projectless.rs index 5cd755cf16..40e906a345 100644 --- a/crates/engine/tests/projectless.rs +++ b/crates/engine/tests/projectless.rs @@ -293,6 +293,7 @@ async fn exercise_projectless(command_first: bool) { worktree: None, mcp: None, resume: None, + provider_fork: None, }, message_id: message_id.into(), }, diff --git a/crates/engine/tests/queued_attachments.rs b/crates/engine/tests/queued_attachments.rs index cec1131141..3cc872fc66 100644 --- a/crates/engine/tests/queued_attachments.rs +++ b/crates/engine/tests/queued_attachments.rs @@ -132,6 +132,7 @@ fn run_payload(message_id: &str, pending_ref: &str) -> SessionCommandPayload { worktree: None, mcp: None, resume: None, + provider_fork: None, }, message_id: message_id.into(), } diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index 76114d2c4c..71e60377c5 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -49,6 +49,7 @@ fn run_request(prompt: &str, cwd: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } @@ -344,6 +345,7 @@ async fn kill_crash_recovers_resume_from_journal_and_stamps_aborted() { device_id: "dev-crash".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -358,6 +360,7 @@ async fn kill_crash_recovers_resume_from_journal_and_stamps_aborted() { device_id: "dev-crash".into(), status: Some(MessageStatus::Streaming), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -592,6 +595,7 @@ async fn fresh_crash_auto_resumes_and_notes_the_interruption() { device_id: "dev-crash".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -606,6 +610,7 @@ async fn fresh_crash_auto_resumes_and_notes_the_interruption() { device_id: "dev-crash".into(), status: Some(MessageStatus::Streaming), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -856,6 +861,7 @@ async fn real_claude_remembers_codeword_across_engine_restart() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let assemble_real = || { EngineCore::assemble( @@ -1008,6 +1014,7 @@ async fn a_crash_before_the_reply_reached_disk_still_auto_resumes() { device_id: "dev-crash".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); @@ -1108,6 +1115,7 @@ async fn a_crash_recovers_the_conversation_through_a_symlinked_folder() { device_id: "dev-crash".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) .unwrap(); diff --git a/crates/engine/tests/rich_composer_delivery.rs b/crates/engine/tests/rich_composer_delivery.rs index a7fbb0851c..c73b010c42 100644 --- a/crates/engine/tests/rich_composer_delivery.rs +++ b/crates/engine/tests/rich_composer_delivery.rs @@ -159,6 +159,7 @@ fn request(prompt: &str) -> RunRequest { sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume: None, + provider_fork: None, attachments: vec![], worktree: None, mcp: None, diff --git a/crates/engine/tests/runtime_survival.rs b/crates/engine/tests/runtime_survival.rs index 30993e12c3..88fb7e0246 100644 --- a/crates/engine/tests/runtime_survival.rs +++ b/crates/engine/tests/runtime_survival.rs @@ -244,6 +244,7 @@ fn request(prompt: &str) -> RunRequest { attachments: Vec::new(), worktree: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/runtime_survival_live.rs b/crates/engine/tests/runtime_survival_live.rs index 667b1f93e7..88c44263da 100644 --- a/crates/engine/tests/runtime_survival_live.rs +++ b/crates/engine/tests/runtime_survival_live.rs @@ -111,6 +111,7 @@ fn run( attachments: vec![], worktree: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/self_continued_quiesce.rs b/crates/engine/tests/self_continued_quiesce.rs index 707d38ee12..0e1ba99568 100644 --- a/crates/engine/tests/self_continued_quiesce.rs +++ b/crates/engine/tests/self_continued_quiesce.rs @@ -60,6 +60,7 @@ fn run_request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index ee922fd7b7..c9ddf6c146 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -15,7 +15,16 @@ //! provider session, never in front of a native command; //! - a fork whose first turn was a native command owes its copied history to //! the first ordinary send — warm dispatch, steer, or a cold resume — -//! exactly once, recorded durably per provider session. +//! exactly once, recorded durably per provider session; +//! - the fork of a chat whose boundary entry carries a provider turn ref +//! continues the SOURCE's provider session natively instead +//! (t3-orchestration-v2 ticket 17): the RPC records the fork source and +//! boundary, the first run's request carries them (Codex `thread/fork +//! {threadId, lastTurnId}`), nothing is pasted, and the budgeted +//! transcript rides the request as the refused-fork fallback. A missing +//! ref or a changed harness keeps the ticket-01 budgeted paste. The turn +//! refs themselves ride assistant entries via a harness event the engine +//! stamps at segment finish. use async_trait::async_trait; use futures::StreamExt; @@ -83,6 +92,7 @@ fn message(id: &str, role: MessageRole, text: &str, status: MessageStatus) -> Se created_at: 1, device_id: "device".into(), status: Some(status), + provider_turn: None, continuation_of: None, } } @@ -188,6 +198,7 @@ async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_sessi sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume: None, + provider_fork: None, attachments: vec![], worktree: None, }, @@ -263,6 +274,326 @@ async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_sessi restarted.shutdown().await; } +/// A harness that reports the provider turn ref the Codex driver emits +/// (ticket 17): one turn whose events name the provider session and turn. +struct RefCapture(Arc>>); +#[async_trait] +impl Harness for RefCapture { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "RefCapture" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result>, HarnessError> { + self.0.lock().unwrap().push(request); + Ok(futures::stream::iter(vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "ref-provider-session".into(), + assistant_message_id: uuid::Uuid::new_v4().to_string(), + }), + Ok(AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "ref-provider-session".into(), + turn_id: "turn-42".into(), + }, + }), + Ok(AgentEvent::TextDelta { + text: "Ref answer".into(), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("ref-provider-session".into()), + }), + ]) + .boxed()) + } +} + +/// Codex assistant entries carry a provider turn reference (ticket 17): the +/// harness event rides the stream and the engine stamps it on the entry the +/// turn streamed into, so a later fork finds its boundary there. +#[tokio::test] +async fn assistant_entries_record_the_provider_turn_ref() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(RefCapture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "chat", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.sessions + .dispatch( + "chat", + HarnessId::Mock, + RunRequest { + mcp: None, + prompt: "turn one".into(), + harness: Some(HarnessId::Mock), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + resume: None, + provider_fork: None, + attachments: vec![], + worktree: None, + }, + Some("u1".into()), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while requests.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while core + .sessions + .session_status("chat") + .is_some_and(|s| s.status != roboco_proto::SessionStatus::Idle) + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let entries = core + .doc_host + .open("chat") + .unwrap() + .doc() + .read_entries() + .unwrap(); + let assistant: Vec<_> = entries + .iter() + .filter(|e| e.role == MessageRole::Assistant) + .collect(); + assert_eq!(assistant.len(), 1, "{entries:?}"); + assert_eq!( + assistant[0].provider_turn.as_ref(), + Some(&roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "ref-provider-session".into(), + turn_id: "turn-42".into(), + }), + "the turn ref rides the entry it streamed into: {entries:?}" + ); + core.shutdown().await; +} + +/// The boundary entry's ref drives the fork: the RPC records the source +/// provider session and boundary, the first dispatch carries them to the +/// harness (Codex's `thread/fork {threadId, lastTurnId}`), nothing is +/// pasted, and the budgeted transcript rides the request as the refused-fork +/// fallback. +#[tokio::test] +async fn a_fork_with_a_provider_turn_ref_forks_the_source_session() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + // The boundary entry: complete, and carrying the provider turn ref a + // Codex run would have stamped (harness = the chat's own harness here, + // so the dispatch below takes the native path). + let mut boundary = message( + "a1", + MessageRole::Assistant, + "I remember PINEAPPLE", + MessageStatus::Complete, + ); + boundary.provider_turn = Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "parent-provider-session".into(), + turn_id: "turn-1".into(), + }); + source.doc().push_message(&boundary).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side", "sourceChatId": "main" }), + ) + .await + .unwrap(); + + // The fork's first run: the native-fork directive rides the request and + // the delivered prompt carries no transcript. + let request = side_turn(&core, &requests, "side", "What did I ask?", "s1").await; + assert_eq!( + request.resume, None, + "the fork never resumes the source thread" + ); + let fork = request + .provider_fork + .expect("native fork directive rides the run"); + assert_eq!(fork.session_id, "parent-provider-session"); + assert_eq!(fork.last_turn, "turn-1"); + // Nothing pasted: the forked session carries the history natively. + assert_eq!(request.prompt, "What did I ask?", "{}", request.prompt); + // The budgeted transcript still rides the request as the refused-fork + // fallback (what the driver pastes onto a fresh session). + let fallback = fork.fallback_history.expect("fallback history attached"); + assert!(fallback.contains("PINEAPPLE"), "{fallback}"); + assert!(fallback.ends_with("\n\n"), "{fallback}"); + // The copied boundary keeps its ref, so a later fork of the fork still + // finds a boundary. + let copied = core + .doc_host + .open("side") + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert_eq!( + copied.iter().find(|e| e.id == "a1").unwrap().provider_turn, + boundary.provider_turn + ); + + // The second run: the intent is spent, the fork's own session resumes, + // and nothing is pasted (the session already holds the history). + let request = side_turn(&core, &requests, "side", "And now?", "s2").await; + assert_eq!(request.provider_fork, None); + assert_eq!(request.resume.as_deref(), Some("side-provider-session")); + assert_eq!(request.prompt, "And now?", "{}", request.prompt); + core.shutdown().await; +} + +/// Old chats (no refs) and a changed harness fall back to the budgeted paste +/// from ticket 01: no native-fork directive, history in front of the prompt. +#[tokio::test] +async fn forks_without_refs_or_with_a_changed_harness_paste_the_budget() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "plain", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("plain").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&message( + "a1", + MessageRole::Assistant, + "I remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + // An old chat: no provider turn ref on the boundary entry. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "old-fork", "sourceChatId": "plain" }), + ) + .await + .unwrap(); + let request = side_turn(&core, &requests, "old-fork", "What did I ask?", "o1").await; + assert_eq!(request.provider_fork, None, "no ref, no native fork"); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.ends_with("What did I ask?")); + + // A ref recorded for a DIFFERENT harness: the run's harness spends the + // intent without ever taking the native path. + let mut boundary = message( + "a2", + MessageRole::Assistant, + "answer with a foreign ref", + MessageStatus::Complete, + ); + boundary.provider_turn = Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::Codex, + session_id: "codex-thread".into(), + turn_id: "turn-9".into(), + }); + source.doc().push_message(&boundary).unwrap(); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "switched-fork", "sourceChatId": "plain" }), + ) + .await + .unwrap(); + let request = side_turn(&core, &requests, "switched-fork", "What did I ask?", "c1").await; + assert_eq!(request.provider_fork, None, "a changed harness never forks"); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + core.shutdown().await; +} + #[tokio::test] async fn cannot_fork_an_empty_chat_or_overwrite_a_main_chat() { let dir = tempfile::tempdir().unwrap(); @@ -434,6 +765,7 @@ async fn side_turn( sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume, + provider_fork: None, attachments: vec![], worktree: None, }, @@ -642,6 +974,7 @@ async fn warm_side_chat_sends_owed_fork_history_once() { sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume: None, + provider_fork: None, attachments: vec![], worktree: None, }; @@ -827,6 +1160,7 @@ async fn orphaned_history_steer_still_owes_the_history() { sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, resume: None, + provider_fork: None, attachments: vec![], worktree: None, }; diff --git a/crates/engine/tests/subagent_idle_reap.rs b/crates/engine/tests/subagent_idle_reap.rs index a08e3efed6..06220f1071 100644 --- a/crates/engine/tests/subagent_idle_reap.rs +++ b/crates/engine/tests/subagent_idle_reap.rs @@ -52,6 +52,7 @@ fn run_request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/transcript_resume.rs b/crates/engine/tests/transcript_resume.rs index 51378ee602..b9094ced75 100644 --- a/crates/engine/tests/transcript_resume.rs +++ b/crates/engine/tests/transcript_resume.rs @@ -35,6 +35,7 @@ fn assistant_entry(id: &str, text: &str) -> SessionMessageEntry { device_id: "host".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/engine/tests/transcript_salvage.rs b/crates/engine/tests/transcript_salvage.rs index a36cb9396f..18a025caa5 100644 --- a/crates/engine/tests/transcript_salvage.rs +++ b/crates/engine/tests/transcript_salvage.rs @@ -35,6 +35,7 @@ fn entry( device_id: "lost-device".into(), status, continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/engine/tests/turn_quiesce.rs b/crates/engine/tests/turn_quiesce.rs index f523603920..68c4618f29 100644 --- a/crates/engine/tests/turn_quiesce.rs +++ b/crates/engine/tests/turn_quiesce.rs @@ -61,6 +61,7 @@ fn run_request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/engine/tests/unarchive_on_send.rs b/crates/engine/tests/unarchive_on_send.rs index 8bd6b0e79e..91c4755743 100644 --- a/crates/engine/tests/unarchive_on_send.rs +++ b/crates/engine/tests/unarchive_on_send.rs @@ -112,6 +112,7 @@ fn run_payload(message_id: &str) -> SessionCommandPayload { worktree: None, mcp: None, resume: None, + provider_fork: None, }, message_id: message_id.into(), } diff --git a/crates/engine/tests/worktree_on_run.rs b/crates/engine/tests/worktree_on_run.rs index 700fbb1ee5..c5b8482366 100644 --- a/crates/engine/tests/worktree_on_run.rs +++ b/crates/engine/tests/worktree_on_run.rs @@ -121,6 +121,7 @@ fn run_payload(message_id: &str, repo_path: &str, space_id: Option<&str>) -> Ses auto_approve: true, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: Some(WorktreeSpec { repo_path: repo_path.into(), base: "main".into(), diff --git a/crates/harness/examples/cursor_stability_probe.rs b/crates/harness/examples/cursor_stability_probe.rs index 2ee8a7c4bb..dd14aa8b90 100644 --- a/crates/harness/examples/cursor_stability_probe.rs +++ b/crates/harness/examples/cursor_stability_probe.rs @@ -45,6 +45,7 @@ async fn turn( worktree: None, mcp: None, resume: session.clone(), + provider_fork: None, }; let mut stream = harness .run(request, controls) @@ -166,6 +167,7 @@ async fn parked(harness: &CursorHarness, count: usize) { attachments: vec![], worktree: None, mcp: None, + provider_fork: None, resume: None, }; let mut stream = harness.run(request, controls).await.unwrap(); @@ -264,6 +266,7 @@ async fn history(harness: &CursorHarness, count: usize) { attachments: vec![], worktree: None, mcp: None, + provider_fork: None, resume: None, }; let mut stream = harness.run(request, controls).await.unwrap(); @@ -385,6 +388,7 @@ async fn burst(harness: &CursorHarness, count: usize, cancel: bool) { attachments: vec![], worktree: None, mcp: None, + provider_fork: None, resume: seed, }; let mut stream = harness.run(request, controls).await.unwrap(); diff --git a/crates/harness/examples/cursor_steering_probe.rs b/crates/harness/examples/cursor_steering_probe.rs index 12560cbf15..4bfad8cd78 100644 --- a/crates/harness/examples/cursor_steering_probe.rs +++ b/crates/harness/examples/cursor_steering_probe.rs @@ -19,6 +19,7 @@ async fn main() -> anyhow::Result<()> { harness: None, model: Some(model), reasoning: None, model_options: Default::default(), cwd: workspace.path().to_string_lossy().into_owned(), sandbox: SandboxLevel::DangerFullAccess, auto_approve: true, + provider_fork: None, attachments: vec![], worktree: None, resume: None, mcp: None, }; let mut stream = CursorHarness::new() diff --git a/crates/harness/examples/devin_models_probe.rs b/crates/harness/examples/devin_models_probe.rs index 9fce6133fa..a6b3720ac0 100644 --- a/crates/harness/examples/devin_models_probe.rs +++ b/crates/harness/examples/devin_models_probe.rs @@ -50,6 +50,7 @@ async fn main() -> anyhow::Result<()> { worktree: None, mcp: None, resume: None, + provider_fork: None, }; println!("Running real Devin ACP with {model}"); let mut stream = harness.run(request, controls).await?; diff --git a/crates/harness/examples/grok_subagent_probe.rs b/crates/harness/examples/grok_subagent_probe.rs index e6437be2ac..8f862926ac 100644 --- a/crates/harness/examples/grok_subagent_probe.rs +++ b/crates/harness/examples/grok_subagent_probe.rs @@ -51,6 +51,7 @@ async fn main() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let mut stream = AcpHarness::grok() .run(request, controls) diff --git a/crates/harness/examples/opencode_subagent_probe.rs b/crates/harness/examples/opencode_subagent_probe.rs index 28355f0a7d..4965b9a263 100644 --- a/crates/harness/examples/opencode_subagent_probe.rs +++ b/crates/harness/examples/opencode_subagent_probe.rs @@ -61,6 +61,7 @@ async fn main() { auto_approve: true, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, }; diff --git a/crates/harness/examples/opencode_turn_probe.rs b/crates/harness/examples/opencode_turn_probe.rs index 7c2f2fa7b9..0950209867 100644 --- a/crates/harness/examples/opencode_turn_probe.rs +++ b/crates/harness/examples/opencode_turn_probe.rs @@ -33,6 +33,7 @@ async fn main() { auto_approve: true, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, }; diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index ce4bebb636..e418764df1 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -9,9 +9,13 @@ //! //! - `initialize` handshake (clientInfo + `capabilities.experimentalApi`) then //! the `initialized` notification; unknown notification methods tolerated. -//! - `thread/start` (or `thread/resume` with a fresh-start fallback) → +//! - `thread/start` (or `thread/resume` with a fresh-start fallback; or +//! `thread/fork` with a fresh-start + budgeted-history fallback when the +//! engine sends a native-fork directive, t3-orchestration-v2 ticket 17) → //! `SessionStarted`; `turn/start` carries the prompt, model, effort, -//! `sandboxPolicy`, and approval policy. +//! `sandboxPolicy`, and approval policy. Every adopted turn id also emits +//! an [`AgentEvent::TurnRef`] naming the thread and turn, for the +//! transcript's provider turn refs. //! - Notifications map to [`AgentEvent`]s: agentMessage/reasoning deltas (both //! `delta`/`textDelta` spellings), item lifecycles → typed ToolCall/ToolResult, //! `thread/tokenUsage/updated` → Usage, turn/completed|failed|aborted → Done. @@ -843,19 +847,24 @@ impl TurnRouter { self.completed.iter().any(|c| c == id) } - fn note_started(&mut self, id: String) { + /// Note a turn the app-server announced (`turn/started`), returning the + /// id when it became the turn in flight — a repeat announcement of the + /// running turn, or one already completed, reports nothing new. + fn note_started(&mut self, id: String) -> Option { if id.is_empty() || self.is_completed(&id) { - return; + return None; } // A replacement `turn/started` is authoritative evidence that a stale // active turn is over, even if its completion notification was lost. + let changed = self.active.as_deref() != Some(id.as_str()); if let Some(prev) = self.active.take() && prev != id { self.remember_completed(prev); } - self.active = Some(id); + self.active = Some(id.clone()); self.announced = true; + changed.then_some(id) } fn note_completed(&mut self, id: &str) { @@ -872,13 +881,15 @@ impl TurnRouter { /// Adopt a turn id from a `turn/start` RESPONSE (the notification is /// allowed to beat it). A turn already running keeps its id: a follow-up /// `turn/start` folds into it and answers with that same id anyway, and - /// an announced turn is authoritative. - fn adopt_started(&mut self, id: String) { + /// an announced turn is authoritative. Returns the id when it was newly + /// adopted, so the caller emits its turn ref exactly once. + fn adopt_started(&mut self, id: String) -> Option { if self.active.is_some() || id.is_empty() || self.is_completed(&id) { - return; + return None; } - self.active = Some(id); + self.active = Some(id.clone()); self.announced = false; + Some(id) } /// Whether a `turn/completed` (or failed/aborted) for `id` ends the turn @@ -908,6 +919,20 @@ fn rotate(id: &mut String) -> (String, String) { (prev, id.clone()) } +/// The provider turn reference for a turn that just started +/// (t3-orchestration-v2 ticket 17): the engine records it on the transcript +/// entry streaming into this turn, so a fork can continue the thread at +/// exactly that turn (`thread/fork {threadId, lastTurnId}`). +fn turn_ref_event(thread_id: &str, turn_id: &str) -> AgentEvent { + AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Codex, + session_id: thread_id.to_owned(), + turn_id: turn_id.to_owned(), + }, + } +} + async fn send(tx: &mpsc::Sender>, ev: AgentEvent) -> bool { tx.send(Ok(ev)).await.is_ok() } @@ -1090,6 +1115,9 @@ async fn run_session(session: Session) { }; // ---- handshake + thread + first turn (interruptible) ------------------ + // The budgeted transcript the engine attached for a refused fork; the + // interrupted-start path leaves it unset (nothing was attempted). + let mut fork_fallback: Option = None; let setup = async { client .request( @@ -1123,7 +1151,33 @@ async fn run_session(session: Session) { } } } - let thread = if let Some(resume) = &request.resume { + // A native fork (t3-orchestration-v2 ticket 17): fork the source + // thread at the inclusive boundary instead of resuming it — the + // forked thread carries the copied history, so nothing is pasted + // into the prompt. A refused fork falls back to a fresh thread, and + // the engine's budgeted transcript then rides in front of the prompt + // (`fork_fallback`, applied to the first turn below). + let thread = if let Some(fork) = request.provider_fork.clone() { + let mut p = start_params.clone(); + p.insert("threadId".into(), Value::String(fork.session_id.clone())); + p.insert("lastTurnId".into(), Value::String(fork.last_turn.clone())); + match client.request("thread/fork", Value::Object(p)).await { + Ok(thread) => thread, + // A fork the app-server refuses (experimental API off, a + // boundary it no longer knows) is a fallback, not an error: + // start fresh and deliver the budgeted history. + Err(e) => { + tracing::debug!( + target: "roboco_harness::codex", + "thread/fork refused (starting fresh with the budgeted history): {e}" + ); + fork_fallback = fork.fallback_history; + client + .request("thread/start", Value::Object(start_params.clone())) + .await? + } + } + } else if let Some(resume) = &request.resume { let mut p = start_params.clone(); p.insert("threadId".into(), Value::String(resume.clone())); match client.request("thread/resume", Value::Object(p)).await { @@ -1182,6 +1236,15 @@ async fn run_session(session: Session) { } }; + // A refused fork's fresh thread still owes the chat its prior context: + // the budgeted transcript rides in front of the prompt, exactly as the + // engine would have pasted it for a fresh session. The prompt itself + // still governs native-command routing below (a native command never + // carries a fallback — the engine attaches none). + let delivered_prompt = match &fork_fallback { + Some(history) => format!("{history}{request.prompt}"), + None => request.prompt.clone(), + }; // Model, effort and service tier ride every `turn/start` (each sticks // for later turns): a steer's in-place `config` takes effect from the // next turn the runtime starts. @@ -1228,8 +1291,18 @@ async fn run_session(session: Session) { } let mut router = TurnRouter::default(); - match start_turn(&client, turn_params(&live, &request.prompt)).await { - Ok(id) => router.adopt_started(id), + match start_turn(&client, turn_params(&live, &delivered_prompt)).await { + Ok(id) => { + // Emit the first turn's ref right after SessionStarted and + // before any item deltas: the entry about to stream belongs to + // this turn. + if let Some(started) = router.adopt_started(id) + && !send(&event_tx, turn_ref_event(&thread_id, &started)).await + { + shutdown_child(&mut child, kill_grace).await; + return; + } + } Err(e) => { let _ = event_tx .send(Ok(AgentEvent::Done { @@ -1343,7 +1416,15 @@ async fn run_session(session: Session) { break 'main; } } - router.note_started(id); + // The turn ref rides the boundary: the entry the + // app-server is about to stream into belongs to this + // turn, whatever started it (a steer, a native + // handoff, a background wake). + if let Some(started) = router.note_started(id) { + if !send(&event_tx, turn_ref_event(&thread_id, &started)).await { + break 'main; + } + } if std::mem::take(&mut stop_when_announced) && let Some(active) = router.active.clone() { @@ -1584,6 +1665,7 @@ async fn run_session(session: Session) { command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( &client, + &thread_id, turn_params(&live, &text), &mut router, &event_tx, @@ -1792,7 +1874,7 @@ async fn run_session(session: Session) { } else { current_native = command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( - &client, turn_params(&live, &text), &mut router, &event_tx, + &client, &thread_id, turn_params(&live, &text), &mut router, &event_tx, &mut assistant_message_id, &mut done_current, ).await { break 'main; } } @@ -1801,7 +1883,7 @@ async fn run_session(session: Session) { } else { current_native = command_request(&text, &thread_id).ok().flatten().is_some(); if !steer_as_new_turn( - &client, turn_params(&live, &text), &mut router, &event_tx, + &client, &thread_id, turn_params(&live, &text), &mut router, &event_tx, &mut assistant_message_id, &mut done_current, ).await { break 'main; } } @@ -1982,6 +2064,7 @@ fn send_turn_interrupt(client: &RpcClient, thread_id: &str, turn: String) { /// when the loop should end (turn/start failed or the consumer hung up). async fn steer_as_new_turn( client: &RpcClient, + thread_id: &str, params: Value, router: &mut TurnRouter, event_tx: &mpsc::Sender>, @@ -1990,17 +2073,27 @@ async fn steer_as_new_turn( ) -> bool { match start_turn(client, params).await { Ok(id) => { - router.adopt_started(id); *done_current = false; let (prev, next) = rotate(assistant_message_id); - send( + // The boundary first (the entry rotates), then the new turn's + // ref for the entry that is about to stream. + let steered = send( event_tx, AgentEvent::Steered { assistant_message_id: Some(prev), next_assistant_message_id: Some(next), }, ) - .await + .await; + if !steered { + return false; + } + match router.adopt_started(id) { + Some(started) => { + send(event_tx, turn_ref_event(thread_id, &started)).await + } + None => steered, + } } Err(e) => { let _ = send( @@ -2308,19 +2401,25 @@ mod tests { let mut r = TurnRouter::default(); r.note_completed("t-1"); // The turn/start response arriving after turn/completed must not - // resurrect the turn. - r.adopt_started("t-1".into()); + // resurrect the turn — and adopts nothing. + assert_eq!(r.adopt_started("t-1".into()), None); assert_eq!(r.active, None); // Nor may a late turn/started notification. - r.note_started("t-1".into()); + assert_eq!(r.note_started("t-1".into()), None); assert_eq!(r.active, None); - r.note_started("t-2".into()); + // A first announcement reports the turn it started. + assert_eq!(r.note_started("t-2".into()), Some("t-2".into())); assert_eq!(r.active.as_deref(), Some("t-2")); + // Re-announcing the running turn reports nothing new. + assert_eq!(r.note_started("t-2".into()), None); // A replacement started turn retires the stale one. - r.note_started("t-3".into()); + assert_eq!(r.note_started("t-3".into()), Some("t-3".into())); assert_eq!(r.active.as_deref(), Some("t-3")); assert!(r.is_completed("t-2")); + // An adoption while a turn runs folds into it and adopts nothing. + assert_eq!(r.adopt_started("t-4".into()), None); + assert_eq!(r.active.as_deref(), Some("t-3")); } } diff --git a/crates/harness/tests/acp.rs b/crates/harness/tests/acp.rs index d5943c1115..966035a886 100644 --- a/crates/harness/tests/acp.rs +++ b/crates/harness/tests/acp.rs @@ -48,6 +48,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/harness/tests/acp_quiet.rs b/crates/harness/tests/acp_quiet.rs index 3b592d57bb..7ff0bfd5d0 100644 --- a/crates/harness/tests/acp_quiet.rs +++ b/crates/harness/tests/acp_quiet.rs @@ -53,6 +53,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/harness/tests/acp_stall.rs b/crates/harness/tests/acp_stall.rs index b9526c1037..b007c62934 100644 --- a/crates/harness/tests/acp_stall.rs +++ b/crates/harness/tests/acp_stall.rs @@ -60,6 +60,7 @@ async fn silent_agent_errors_via_the_prompt_stall_watchdog() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let harness = AcpHarness::grok().with_executable(fixture_path()); let stream = harness.run(request, controls).await.expect("run starts"); diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index c60ee893d2..a16d01ab9b 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -49,6 +49,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/harness/tests/codex.rs b/crates/harness/tests/codex.rs index df7885409f..557848647e 100644 --- a/crates/harness/tests/codex.rs +++ b/crates/harness/tests/codex.rs @@ -104,6 +104,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } @@ -204,6 +205,25 @@ async fn happy_path_maps_deltas_items_usage_and_done() { assert_eq!(cwd, "/tmp"); assert_eq!(session_id, "th-1"); + // The turn ref rides the stream once per turn (ticket 17): the engine + // stamps it on the transcript entry so a fork can continue this thread + // at exactly that turn. + assert_eq!( + events + .iter() + .filter(|e| matches!(e, AgentEvent::TurnRef { .. })) + .count(), + 1, + "turn ref emitted exactly once: {events:?}" + ); + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Codex, + session_id: "th-1".into(), + turn_id: "t-1".into(), + } + })); + // Deltas — both wire spellings accepted. assert!(events.contains(&AgentEvent::TextDelta { text: "Hello".into() @@ -353,6 +373,92 @@ async fn happy_path_maps_deltas_items_usage_and_done() { ); } +#[tokio::test] +async fn a_native_fork_forks_the_source_thread_and_pastes_no_history() { + let (controls, _steer, _token) = controls("Yes"); + let mut req = request("scenario:fork-success"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-source".into(), + last_turn: "turn-7".into(), + // What the engine would have pasted for a fresh session — the driver + // must NOT use it when the fork succeeds (the fake fails the turn if + // any of it reaches the input). + fallback_history: Some( + "Continue this side chat using the following prior transcript as context.\n\ + \nuser: Remember PINEAPPLE\n\n\n" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + // The run continues the FORKED thread (thread/fork's new id), and its + // turn ref names that thread and the turn that actually ran. + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "th-forked" + )), + "session continues the forked thread: {events:?}" + ); + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Codex, + session_id: "th-forked".into(), + turn_id: "t-f1".into(), + } + })); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "forked thread answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("th-forked".into()), + }) + ); +} + +#[tokio::test] +async fn a_refused_fork_falls_back_to_a_fresh_thread_with_the_budgeted_history() { + let (controls, _steer, _token) = controls("Yes"); + let mut req = request("scenario:fork-refused"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-refused".into(), + last_turn: "turn-7".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context.\n\ + \nuser: Remember PINEAPPLE\n\n\n" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + // The fresh fallback thread serves the turn (the fake fails it unless + // the input carries the budgeted history AND the user message). + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "th-fresh" + )), + "refused fork fell back to a fresh thread: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "fresh thread answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("th-fresh".into()), + }) + ); +} + #[tokio::test] async fn steering_uses_turn_steer_with_expected_turn_id() { let (controls, steer, _token) = controls("Yes"); diff --git a/crates/harness/tests/cursor.rs b/crates/harness/tests/cursor.rs index d9a0c0e19b..230e33f388 100644 --- a/crates/harness/tests/cursor.rs +++ b/crates/harness/tests/cursor.rs @@ -43,6 +43,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/harness/tests/fixtures/fake-codex.sh b/crates/harness/tests/fixtures/fake-codex.sh index d61a9fc88b..7fd4f748bb 100755 --- a/crates/harness/tests/fixtures/fake-codex.sh +++ b/crates/harness/tests/fixtures/fake-codex.sh @@ -2,8 +2,8 @@ # Fake Codex app-server for roboco-harness tests. # # Speaks scripted JSON-RPC 2.0 over stdio: initialize handshake, thread -# start/resume, then a scenario picked from the turn/start prompt text. Driven -# by crates/harness/tests/codex.rs. +# start/resume/fork, then a scenario picked from the turn/start prompt text. +# Driven by crates/harness/tests/codex.rs. emit() { printf '%s\n' "$1"; } rid() { printf '%s' "$1" | sed 's/.*"id":\([0-9]*\).*/\1/'; } @@ -49,7 +49,22 @@ if has "$line" '"method":"model/list"'; then emit "{\"id\":$(rid "$line"),\"result\":{\"data\":[{\"id\":\"gpt-5.6-sol\",\"model\":\"gpt-5.6-sol\",\"displayName\":\"GPT-5.6-Sol\",\"description\":\"Reliable agentic workhorse for everyday tasks.\",\"hidden\":false,\"supportedReasoningEfforts\":[{\"reasoningEffort\":\"low\"},{\"reasoningEffort\":\"ultra\"}],\"additionalSpeedTiers\":[],\"serviceTiers\":[],\"defaultServiceTier\":null,\"isDefault\":false}],\"nextCursor\":null}}" exec sleep 30 fi -if has "$line" '"method":"thread/resume"'; then +if has "$line" '"method":"thread/fork"'; then + if has "$line" '"threadId":"fork-source"' && has "$line" '"lastTurnId":"turn-7"'; then + # Native fork (ticket 17): a new thread id; the copied history rides the + # provider, so the first turn's input must carry no transcript. + emit "{\"id\":$(rid "$line"),\"result\":{\"thread\":{\"id\":\"th-forked\"}}}" + elif has "$line" '"threadId":"fork-refused"'; then + # A refused fork: fall back to thread/start; the first turn's input must + # then carry the budgeted history in front of the prompt. + emit "{\"id\":$(rid "$line"),\"error\":{\"code\":-32600,\"message\":\"fork not allowed\"}}" + read -r line || exit 1 + has "$line" '"method":"thread/start"' || exit 1 + emit "{\"id\":$(rid "$line"),\"result\":{\"thread\":{\"id\":\"th-fresh\"}}}" + else + exit 1 + fi +elif has "$line" '"method":"thread/resume"'; then if has "$line" '"threadId":"resume-with-child-v1"'; then emit "{\"id\":$(rid "$line"),\"result\":{\"thread\":{\"id\":\"th-resumed\",\"turns\":[{\"items\":[{\"type\":\"collabAgentToolCall\",\"id\":\"spawn-alpha\",\"tool\":\"spawnAgent\",\"status\":\"completed\",\"receiverThreadIds\":[\"child-alpha\"]}]}]}}}" elif has "$line" '"threadId":"resume-with-child-v2"'; then @@ -360,6 +375,35 @@ case "$turnline" in cat >/dev/null ;; +*scenario:fork-success*) + # The native fork delivered: the turn runs on the forked thread, its input + # is the bare prompt (no transcript block, no copied history). + has "$turnline" '"threadId":"th-forked"' || { fail_turn "$tid" "turn not on the forked thread"; exit 0; } + for bad in '' 'PINEAPPLE'; do + if has "$turnline" "$bad"; then + fail_turn "$tid" "history pasted into a native fork: $bad"; exit 0 + fi + done + has "$turnline" 'scenario:fork-success' || { fail_turn "$tid" "prompt missing"; exit 0; } + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-f1\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-forked","turn":{"id":"t-f1"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-forked","itemId":"m1","delta":"forked thread answer"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-forked","turn":{"id":"t-f1","status":"completed"}}}' + ;; + +*scenario:fork-refused*) + # The refused fork fell back to a fresh thread: the input must carry the + # budgeted history in front of the user message. + has "$turnline" '"threadId":"th-fresh"' || { fail_turn "$tid" "turn not on the fresh thread"; exit 0; } + for want in 'PINEAPPLE' 'scenario:fork-refused'; do + has "$turnline" "$want" || { fail_turn "$tid" "fallback prompt missing: $want"; exit 0; } + done + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-f2\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-fresh","turn":{"id":"t-f2"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-fresh","itemId":"m1","delta":"fresh thread answer"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-fresh","turn":{"id":"t-f2","status":"completed"}}}' + ;; + # NOTE: steer-race before steer — `case` takes the first matching glob. *scenario:steer-race*) emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" diff --git a/crates/harness/tests/managed_install.rs b/crates/harness/tests/managed_install.rs index 9378d875cf..9aba54d65b 100644 --- a/crates/harness/tests/managed_install.rs +++ b/crates/harness/tests/managed_install.rs @@ -48,6 +48,7 @@ async fn managed_install_reaches_session_started() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let mut stream = tokio::time::timeout( diff --git a/crates/harness/tests/managed_install_failure.rs b/crates/harness/tests/managed_install_failure.rs index 441a9d9072..91ca3d42a2 100644 --- a/crates/harness/tests/managed_install_failure.rs +++ b/crates/harness/tests/managed_install_failure.rs @@ -60,6 +60,7 @@ async fn silent_npm_enoent_death_surfaces_decoded_error() { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let err = match harness.run(request, controls).await { diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index 2c229736a2..8b73f67133 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -270,6 +270,7 @@ fn request(prompt: &str) -> RunRequest { auto_approve: true, attachments: Vec::new(), resume: None, + provider_fork: None, worktree: None, mcp: None, } diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 4cb5332944..fb1f0c8615 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -54,6 +54,7 @@ fn request_cwd(prompt: &str, cwd: &str) -> RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/harness/tests/real_acp_lifecycle.rs b/crates/harness/tests/real_acp_lifecycle.rs index 724a76c245..1f590c2cad 100644 --- a/crates/harness/tests/real_acp_lifecycle.rs +++ b/crates/harness/tests/real_acp_lifecycle.rs @@ -33,6 +33,7 @@ async fn live_run(cancel: bool) { harness: None, model: None, reasoning: None, model_options: serde_json::Map::new(), cwd: cwd.path().display().to_string(), sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, + provider_fork: None, attachments: Vec::new(), worktree: None, resume: None, mcp: None, }; let mut stream = PiHarness::new() diff --git a/crates/harness/tests/real_quiet_survey.rs b/crates/harness/tests/real_quiet_survey.rs index 451f239731..a2734ca72a 100644 --- a/crates/harness/tests/real_quiet_survey.rs +++ b/crates/harness/tests/real_quiet_survey.rs @@ -68,6 +68,7 @@ async fn probe_once(harness: AcpHarness) -> ProbeOutcome { worktree: None, mcp: None, resume: None, + provider_fork: None, }; let mut stream = match harness.run(req, controls).await { Ok(s) => s, diff --git a/crates/harness/tests/windows_native.rs b/crates/harness/tests/windows_native.rs index 8f57b14078..f1ae70aa36 100644 --- a/crates/harness/tests/windows_native.rs +++ b/crates/harness/tests/windows_native.rs @@ -215,6 +215,7 @@ fn request(cwd: &Path, prompt: &str, resume: Option<&str>) -> RunRequest { worktree: None, mcp: None, resume: resume.map(str::to_owned), + provider_fork: None, } } diff --git a/crates/mobile-client/src/cache.rs b/crates/mobile-client/src/cache.rs index b0a95cb786..767fa95e03 100644 --- a/crates/mobile-client/src/cache.rs +++ b/crates/mobile-client/src/cache.rs @@ -360,6 +360,7 @@ mod tests { device_id: String::new(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }], ) diff --git a/crates/mobile-client/src/session/commands.rs b/crates/mobile-client/src/session/commands.rs index 027098031b..21cb2f1db0 100644 --- a/crates/mobile-client/src/session/commands.rs +++ b/crates/mobile-client/src/session/commands.rs @@ -220,6 +220,7 @@ impl SessionCore { // background subagents. auto_approve: false, resume: None, + provider_fork: None, attachments: paths.to_vec(), worktree: request.worktree.clone(), mcp: None, diff --git a/crates/mobile-client/src/session/composer.rs b/crates/mobile-client/src/session/composer.rs index d36000f5f5..d1ae048e47 100644 --- a/crates/mobile-client/src/session/composer.rs +++ b/crates/mobile-client/src/session/composer.rs @@ -412,6 +412,7 @@ mod tests { device_id: String::new(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }); let question = |id: &str| roboco_proto::UserInputQuestion { diff --git a/crates/mobile-client/src/session/mod.rs b/crates/mobile-client/src/session/mod.rs index e78d75a743..a2116678ad 100644 --- a/crates/mobile-client/src/session/mod.rs +++ b/crates/mobile-client/src/session/mod.rs @@ -1692,6 +1692,7 @@ mod tests { device_id: "dev".into(), status: Some(roboco_doc::MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }) } diff --git a/crates/mobile-client/src/session/transcript.rs b/crates/mobile-client/src/session/transcript.rs index ef9c4a328b..26e7351d5c 100644 --- a/crates/mobile-client/src/session/transcript.rs +++ b/crates/mobile-client/src/session/transcript.rs @@ -545,6 +545,7 @@ mod tests { device_id: String::new(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/mobile-client/tests/notify_engine.rs b/crates/mobile-client/tests/notify_engine.rs index 1c1add72fe..7bc3ae6302 100644 --- a/crates/mobile-client/tests/notify_engine.rs +++ b/crates/mobile-client/tests/notify_engine.rs @@ -328,6 +328,7 @@ fn run_request(prompt: &str) -> roboco_proto::RunRequest { worktree: None, mcp: None, resume: None, + provider_fork: None, } } diff --git a/crates/mobile-client/tests/side_chats_engine.rs b/crates/mobile-client/tests/side_chats_engine.rs index a3bc8aba8a..090463a889 100644 --- a/crates/mobile-client/tests/side_chats_engine.rs +++ b/crates/mobile-client/tests/side_chats_engine.rs @@ -701,6 +701,7 @@ async fn the_subagent_doc_streams_and_counts_while_it_runs() { created_at: 2_000, device_id: rig.core.device_id.clone(), status: Some(roboco_doc::MessageStatus::Complete), + provider_turn: None, continuation_of: None, }) .unwrap(); diff --git a/crates/mobile/src/layout/mod.rs b/crates/mobile/src/layout/mod.rs index 5f41b03c78..3d0c78ebc5 100644 --- a/crates/mobile/src/layout/mod.rs +++ b/crates/mobile/src/layout/mod.rs @@ -551,6 +551,7 @@ pub(crate) fn debug_input(entries: Vec, working: bool) -> Transcript device_id: String::new(), status: Some(if e.streaming { MessageStatus::Streaming } else { MessageStatus::Complete }), continuation_of: None, + provider_turn: None, duration_ms: None, }) }) diff --git a/crates/mobile/src/layout/tests.rs b/crates/mobile/src/layout/tests.rs index f7964d1be2..68aca1ef22 100644 --- a/crates/mobile/src/layout/tests.rs +++ b/crates/mobile/src/layout/tests.rs @@ -251,6 +251,7 @@ fn thinking_renders_styled_markdown_not_markers() { device_id: String::new(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, })], ..Default::default() @@ -291,6 +292,7 @@ fn thinking_renders_styled_markdown_not_markers() { device_id: String::new(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, })], ..Default::default() @@ -324,6 +326,7 @@ fn streaming_thought_markdown_settles_to_the_fresh_parse() { device_id: String::new(), status: Some(status), continuation_of: None, + provider_turn: None, duration_ms: None, }) }; @@ -374,6 +377,7 @@ fn thought_input(text: &str, streaming: bool) -> TranscriptInput { device_id: String::new(), status: Some(if streaming { MessageStatus::Streaming } else { MessageStatus::Complete }), continuation_of: None, + provider_turn: None, duration_ms: None, })], ..Default::default() @@ -516,6 +520,7 @@ fn bench_layout_passes() { device_id: String::new(), status: Some(MessageStatus::Streaming), continuation_of: None, + provider_turn: None, duration_ms: None, })); w.input = TranscriptInput { entries: e, pending: vec![], working: true, working_since_ms: None, streaming: true }; @@ -649,6 +654,7 @@ fn running_subagent_shows_a_spinner_after_its_spawn_resolves() { device_id: String::new(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, })], ..Default::default() @@ -700,6 +706,7 @@ fn spawn_cards_with_a_doc_ref_become_subagent_links() { device_id: String::new(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, })], ..Default::default() diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index b743f02728..b0b72ad39a 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -232,6 +232,16 @@ pub struct RunRequest { pub auto_approve: bool, /// Harness-native session id to resume, if any. pub resume: Option, + /// A native fork to perform instead of a plain resume or fresh start + /// (t3-orchestration-v2 ticket 17): the driver forks the source provider + /// session at `last_turn` (inclusive) — Codex `thread/fork {threadId, + /// lastTurnId}` — so the forked session carries the copied history and + /// nothing is pasted into the prompt. Only the engine sets it: the fork's + /// first dispatch injects it from the intent the ForkSideChat RPC + /// recorded. Additive + serde-defaulted for wire compat — an old host + /// ignores it and starts fresh (the budgeted-history fallback). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_fork: Option, /// Absolute paths of image attachments already staged on the run device /// (composer uploads: UploadChunk/UploadCommit → durable path). The same /// paths also ride the prompt text as `Attached images (local files …)` @@ -256,6 +266,41 @@ pub struct RunRequest { pub mcp: Option, } +/// One side of a native fork riding [`RunRequest`]: the source provider +/// session, the inclusive boundary to fork at, and the budgeted transcript +/// to deliver instead when the harness refuses the fork (ticket 01's +/// builder produces it; the driver pastes it in front of the prompt on the +/// fresh session it starts). The engine fills `fallback_history` at run +/// time — it is never persisted in the command payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderFork { + /// The provider session being forked (the fork's source). + pub session_id: String, + /// The per-harness turn ref of the last copied assistant entry — the + /// boundary the fork continues the source session at, inclusive (Codex + /// `lastTurnId`). + pub last_turn: String, + /// The budgeted transcript prefix delivered in front of the prompt only + /// when the harness refuses the fork. Additive + serde-defaulted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_history: Option, +} + +/// A provider turn reference (t3-orchestration-v2 ticket 17): the harness, +/// the provider session the turn belongs to, and the harness's own turn ref +/// (Codex: the turn id; Claude/Pi/OpenCode later). Transcript entries +/// record it so a fork can continue the provider session at exactly the +/// turn that produced the entry. The session id is part of the ref because +/// a chat's provider session can change (fresh-start fallbacks). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ProviderTurnRef { + pub harness: HarnessId, + pub session_id: String, + pub turn_id: String, +} + /// A stdio MCP server the harness should add to the agent's session, on top /// of whatever the user configured. Each driver spells it in its own dialect /// (Claude `--mcp-config`, ACP `session/new` `mcpServers`, Codex @@ -655,6 +700,17 @@ pub enum AgentEvent { UserMessage { text: String, }, + /// The provider turn reference for the assistant entry currently + /// streaming (t3-orchestration-v2 ticket 17): the engine records it on + /// the transcript entry when the segment finishes, so a fork can + /// continue the provider session at exactly that turn. Codex emits it + /// when the app-server adopts a turn id; other harnesses follow. A + /// consumer that does not know the variant drops it — the pre-fork + /// behavior. Additive on the wire. + #[serde(rename_all = "camelCase")] + TurnRef { + reference: ProviderTurnRef, + }, /// An event belonging to a SUBAGENT's nested transcript, attributed to /// the spawning tool call (`parent_tool_use_id` = the parent-feed /// `ToolCall::id` that launched it). Never folded into the parent chat @@ -874,6 +930,64 @@ mod tests { assert!(encoded.get("spaceId").is_none()); } + #[test] + fn run_request_provider_fork_default_and_round_trip() { + // Old-wire JSON without the field parses (additive compat)… + let old = r#"{"prompt":"p","model":null,"reasoning":null,"cwd":".","sandbox":"workspace-write","resume":null}"#; + let req: RunRequest = serde_json::from_str(old).unwrap(); + assert!(req.provider_fork.is_none()); + // …and `None` serializes away (old readers never see it). + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("providerFork").is_none()); + // A populated fork round-trips camelCased, boundary and fallback; a + // fork without a fallback parses (the driver then has none to paste). + let req = RunRequest { + provider_fork: Some(ProviderFork { + session_id: "th-source".into(), + last_turn: "turn-7".into(), + fallback_history: Some("…".into()), + }), + ..req + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["providerFork"]["sessionId"], "th-source"); + assert_eq!(json["providerFork"]["lastTurn"], "turn-7"); + assert_eq!(json["providerFork"]["fallbackHistory"], "…"); + let round: RunRequest = serde_json::from_value(json).unwrap(); + assert_eq!(round.provider_fork, req.provider_fork); + let bare = serde_json::json!({ "sessionId": "s", "lastTurn": "t" }); + let fork: ProviderFork = serde_json::from_value(bare).unwrap(); + assert_eq!(fork.fallback_history, None); + } + + #[test] + fn turn_ref_event_round_trips_and_must_be_droppable_by_old_consumers() { + let event = AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Codex, + session_id: "th-1".into(), + turn_id: "t-1".into(), + }, + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["type"], "turnRef"); + assert_eq!(json["reference"]["harness"], "codex"); + assert_eq!(json["reference"]["sessionId"], "th-1"); + assert_eq!(json["reference"]["turnId"], "t-1"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + event + ); + // Additive compat: the same shape a future harness may extend parses. + let parsed: ProviderTurnRef = serde_json::from_value(serde_json::json!({ + "harness": "claude-code", + "sessionId": "s", + "turnId": "uuid" + })) + .unwrap(); + assert_eq!(parsed.harness, HarnessId::ClaudeCode); + } + #[test] fn harness_id_uses_kebab_case() { assert_eq!( diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 104b320582..23d66a4c7a 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1120,6 +1120,7 @@ impl Tools { .unwrap_or(SandboxLevel::WorkspaceWrite), auto_approve: false, resume: None, + provider_fork: None, attachments: Vec::new(), worktree: None, }; diff --git a/crates/roboco-mcp/src/transcript.rs b/crates/roboco-mcp/src/transcript.rs index 2d82c3c562..ddad55cd82 100644 --- a/crates/roboco-mcp/src/transcript.rs +++ b/crates/roboco-mcp/src/transcript.rs @@ -232,6 +232,7 @@ mod tests { created_at: 1_700_000_000_000, device_id: "dev-a".into(), status: Some(MessageStatus::Complete), + provider_turn: None, continuation_of: None, } } diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index b047338059..66efc14ac3 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8260,6 +8260,7 @@ impl Composer { device_id: "local".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; self.launching_new_chat = is_new; @@ -8426,6 +8427,7 @@ impl Composer { device_id: "local".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; let echo_chat_id = chat_id.clone(); @@ -8634,6 +8636,7 @@ impl Composer { sandbox: SandboxLevel::WorkspaceWrite, auto_approve: false, resume: None, + provider_fork: None, attachments: attachment_paths, worktree: run_worktree.clone(), mcp: None, @@ -15139,6 +15142,7 @@ mod tests { device_id: "d".into(), status, continuation_of: None, + provider_turn: None, duration_ms: None, }; // Streaming entry with unresolved input → panel. @@ -15176,6 +15180,7 @@ mod tests { device_id: "d".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }, ]; @@ -15209,6 +15214,7 @@ mod tests { device_id: "d".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, }; let t = vec![ diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs index ca962d9727..c5ff9f028f 100644 --- a/crates/ui/src/composer_dictation_tests.rs +++ b/crates/ui/src/composer_dictation_tests.rs @@ -639,6 +639,7 @@ fn dictation_question_takeover_stops_capture_without_losing_draft(cx: &mut TestA header: "Choose".into(), question: "Continue?".into(), options: vec!["Yes".into()], + provider_turn: None, multi_select: false, }], }], diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs index 18f88f3d57..55e55faf12 100644 --- a/crates/ui/src/files/sections.rs +++ b/crates/ui/src/files/sections.rs @@ -1186,6 +1186,7 @@ mod tests { created_at: (Utc::now() - chrono::Duration::minutes(3)).timestamp_millis(), device_id: "dev".into(), status: Some(MessageStatus::Complete), + provider_turn: None, continuation_of: None, } } diff --git a/crates/ui/src/rail.rs b/crates/ui/src/rail.rs index 36f5abdaef..4d331ca990 100644 --- a/crates/ui/src/rail.rs +++ b/crates/ui/src/rail.rs @@ -590,6 +590,7 @@ mod tests { device_id: "d".into(), status: Some(MessageStatus::Complete), continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 657375d463..edd94c0454 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -2405,6 +2405,7 @@ impl Shell { device_id: "local".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; state.update(cx, |s, cx| { diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index ae06385237..60a2f656ed 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -3993,6 +3993,7 @@ mod tests { device_id: "dev".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, } } @@ -5334,6 +5335,7 @@ mod tests { device_id: "local".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; let row = |id: &str| roboco_doc::QueuedMessage { @@ -5372,6 +5374,7 @@ mod tests { device_id: "local".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; state.push_echo("c1", echo.clone()); diff --git a/crates/ui/src/todo_panel.rs b/crates/ui/src/todo_panel.rs index fa39792dfb..b0aee48b5e 100644 --- a/crates/ui/src/todo_panel.rs +++ b/crates/ui/src/todo_panel.rs @@ -711,6 +711,7 @@ mod tests { device_id: "d".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, } } @@ -991,6 +992,7 @@ mod composer_tests { device_id: "d".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, } } diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 27ce9d98de..1dd760ba7f 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -11874,6 +11874,7 @@ mod tests { device_id: "dev".into(), status: Some(status), continuation_of: None, + provider_turn: None, duration_ms: None, } } @@ -15612,6 +15613,7 @@ mod tests { device_id: "dev".into(), status: None, continuation_of: None, + provider_turn: None, duration_ms: None, }; let rows = rows_for_entry(&user, true, false, &mut parse); diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index dfab1ab9fb..8c0b5f333e 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -223,9 +223,11 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::Model, roboco_proto::ModelOption, roboco_proto::ModelOptionChoice, + roboco_proto::ProviderFork, roboco_proto::RunRequest, roboco_proto::McpServer, roboco_proto::WorktreeSpec, + roboco_proto::ProviderTurnRef, roboco_proto::ToolCall, roboco_proto::TodoItem, roboco_proto::SlashCommand, diff --git a/web/packages/proto/src/generated/AgentEvent.ts b/web/packages/proto/src/generated/AgentEvent.ts index 03804ee644..1b31cde3b9 100644 --- a/web/packages/proto/src/generated/AgentEvent.ts +++ b/web/packages/proto/src/generated/AgentEvent.ts @@ -2,6 +2,7 @@ import type { DoneStatus } from "./DoneStatus"; import type { HarnessId } from "./HarnessId"; +import type { ProviderTurnRef } from "./ProviderTurnRef"; import type { SlashCommand } from "./SlashCommand"; import type { ToolCall } from "./ToolCall"; import type { ToolDiff } from "./ToolDiff"; @@ -26,4 +27,4 @@ output?: string | null, /** * Inline file diff for edit-shaped tools (ACP `Diff` content). */ -diff?: ToolDiff | null, } | { "type": "contextUsage", tokens: number | null, window: number | null, } | { "type": "usage", inputTokens: number, outputTokens: number, } | { "type": "availableCommands", commands: Array, } | { "type": "error", message: string, } | { "type": "inputRequested", requestId: string, questions: Array, } | { "type": "inputResolved", requestId: string, } | { "type": "steered", assistantMessageId: string | null, nextAssistantMessageId: string | null, } | { "type": "done", status: DoneStatus, result: string | null, error: string | null, sessionId: string | null, } | { "type": "userMessage", text: string, } | { "type": "subagent", parentToolUseId: string, event: AgentEvent, }; +diff?: ToolDiff | null, } | { "type": "contextUsage", tokens: number | null, window: number | null, } | { "type": "usage", inputTokens: number, outputTokens: number, } | { "type": "availableCommands", commands: Array, } | { "type": "error", message: string, } | { "type": "inputRequested", requestId: string, questions: Array, } | { "type": "inputResolved", requestId: string, } | { "type": "steered", assistantMessageId: string | null, nextAssistantMessageId: string | null, } | { "type": "done", status: DoneStatus, result: string | null, error: string | null, sessionId: string | null, } | { "type": "userMessage", text: string, } | { "type": "turnRef", reference: ProviderTurnRef, } | { "type": "subagent", parentToolUseId: string, event: AgentEvent, }; diff --git a/web/packages/proto/src/generated/ProviderFork.ts b/web/packages/proto/src/generated/ProviderFork.ts new file mode 100644 index 0000000000..f030714b58 --- /dev/null +++ b/web/packages/proto/src/generated/ProviderFork.ts @@ -0,0 +1,26 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * One side of a native fork riding [`RunRequest`]: the source provider + * session, the inclusive boundary to fork at, and the budgeted transcript + * to deliver instead when the harness refuses the fork (ticket 01's + * builder produces it; the driver pastes it in front of the prompt on the + * fresh session it starts). The engine fills `fallback_history` at run + * time — it is never persisted in the command payload. + */ +export type ProviderFork = { +/** + * The provider session being forked (the fork's source). + */ +sessionId: string, +/** + * The per-harness turn ref of the last copied assistant entry — the + * boundary the fork continues the source session at, inclusive (Codex + * `lastTurnId`). + */ +lastTurn: string, +/** + * The budgeted transcript prefix delivered in front of the prompt only + * when the harness refuses the fork. Additive + serde-defaulted. + */ +fallbackHistory?: string | null, }; diff --git a/web/packages/proto/src/generated/ProviderTurnRef.ts b/web/packages/proto/src/generated/ProviderTurnRef.ts new file mode 100644 index 0000000000..d19ab6b4a6 --- /dev/null +++ b/web/packages/proto/src/generated/ProviderTurnRef.ts @@ -0,0 +1,13 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +import type { HarnessId } from "./HarnessId"; + +/** + * A provider turn reference (t3-orchestration-v2 ticket 17): the harness, + * the provider session the turn belongs to, and the harness's own turn ref + * (Codex: the turn id; Claude/Pi/OpenCode later). Transcript entries + * record it so a fork can continue the provider session at exactly the + * turn that produced the entry. The session id is part of the ref because + * a chat's provider session can change (fresh-start fallbacks). + */ +export type ProviderTurnRef = { harness: HarnessId, sessionId: string, turnId: string, }; diff --git a/web/packages/proto/src/generated/RunRequest.ts b/web/packages/proto/src/generated/RunRequest.ts index c7e7204dad..0e4a1ae9b1 100644 --- a/web/packages/proto/src/generated/RunRequest.ts +++ b/web/packages/proto/src/generated/RunRequest.ts @@ -2,6 +2,7 @@ import type { HarnessId } from "./HarnessId"; import type { McpServer } from "./McpServer"; +import type { ProviderFork } from "./ProviderFork"; import type { ReasoningLevel } from "./ReasoningLevel"; import type { SandboxLevel } from "./SandboxLevel"; import type { WorktreeSpec } from "./WorktreeSpec"; @@ -22,6 +23,17 @@ modelOptions: Record, cwd: string, sandbox: SandboxLevel, autoA * Harness-native session id to resume, if any. */ resume: string | null, +/** + * A native fork to perform instead of a plain resume or fresh start + * (t3-orchestration-v2 ticket 17): the driver forks the source provider + * session at `last_turn` (inclusive) — Codex `thread/fork {threadId, + * lastTurnId}` — so the forked session carries the copied history and + * nothing is pasted into the prompt. Only the engine sets it: the fork's + * first dispatch injects it from the intent the ForkSideChat RPC + * recorded. Additive + serde-defaulted for wire compat — an old host + * ignores it and starts fresh (the budgeted-history fallback). + */ +providerFork?: ProviderFork | null, /** * Absolute paths of image attachments already staged on the run device * (composer uploads: UploadChunk/UploadCommit → durable path). The same diff --git a/web/packages/proto/src/generated/SessionMessageEntry.ts b/web/packages/proto/src/generated/SessionMessageEntry.ts index 0273eeaffc..96a36ae69a 100644 --- a/web/packages/proto/src/generated/SessionMessageEntry.ts +++ b/web/packages/proto/src/generated/SessionMessageEntry.ts @@ -4,6 +4,7 @@ import type { MessagePart } from "./MessagePart"; import type { MessageRole } from "./MessageRole"; import type { MessageStatus } from "./MessageStatus"; +import type { ProviderTurnRef } from "./ProviderTurnRef"; /** * One entry in the doc's `messages` list (`SessionMessageEntry` in TS). @@ -18,4 +19,13 @@ createdAt: number, deviceId: string, status?: MessageStatus | null, continuation * finishes. Absent on user rows, live streams, and docs written before * the field existed. */ -durationMs?: number | null, }; +durationMs?: number | null, +/** + * The provider turn that produced this assistant entry (additive, + * t3-orchestration-v2 ticket 17): harness, provider session id, and the + * harness's own turn ref. A fork continues the provider session at + * exactly this entry using it. Absent on user/system rows and on docs + * written before the field existed — those fall back to the budgeted + * paste. + */ +providerTurn?: ProviderTurnRef | null, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 1774ec3248..eb292c2ac3 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -80,6 +80,8 @@ export * from "./ProjectActionDraft"; export * from "./ProjectActionIcon"; export * from "./ProjectActionRun"; export * from "./ProjectActionsSnapshot"; +export * from "./ProviderFork"; +export * from "./ProviderTurnRef"; export * from "./QueueDeliveryGate"; export * from "./QueuedMessage"; export * from "./ReadWorkspaceFileRequest"; From e5cf72a30f822e35a60a91fb5e259db450f5f2fe Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 14:54:24 +0700 Subject: [PATCH 12/96] fix(pi): bind the new error kind in the compaction test --- crates/harness/tests/pi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index fc7636bb65..9d3da4d28e 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -1359,7 +1359,7 @@ async fn a_compaction_recovered_run_completes() { assert!(text_of(&events).contains("doomed"), "{events:?}"); assert!(text_of(&events).contains("recovered"), "{events:?}"); assert!( - events.iter().any(|e| matches!(e, AgentEvent::Error { message } if message.contains("overflow"))), + events.iter().any(|e| matches!(e, AgentEvent::Error { message, .. } if message.contains("overflow"))), "{events:?}" ); } From 745839cb94b629d1ee075ae46d2f77fcdd341dbf Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 15:14:09 +0700 Subject: [PATCH 13/96] fix(ui): move the misfiled provider_turn into the entry literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bulk field insertion anchored on the last field-looking line of each SessionMessageEntry literal, which for the dictation test's literal was the nested UserInputQuestion's options line — so provider_turn landed inside UserInputQuestion (which has no such field) instead of the entry. Re-audited every RunRequest/SessionMessageEntry literal the insert touched with a brace-depth-aware scanner (innermost enclosing literal must be the owning type; every non-spread literal must carry its field): this was the only misplacement. --- crates/ui/src/composer_dictation_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs index c5ff9f028f..fdede76ae7 100644 --- a/crates/ui/src/composer_dictation_tests.rs +++ b/crates/ui/src/composer_dictation_tests.rs @@ -639,10 +639,10 @@ fn dictation_question_takeover_stops_capture_without_losing_draft(cx: &mut TestA header: "Choose".into(), question: "Continue?".into(), options: vec!["Yes".into()], - provider_turn: None, multi_select: false, }], }], + provider_turn: None, }]; cx.notify(); }); From ac471e317aa0fef7683d1865857d9aa0f8e96ff4 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 15:21:00 +0700 Subject: [PATCH 14/96] fix(pi): stop shadowing the controls helper in the old-pi test --- crates/harness/tests/pi.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 9d3da4d28e..d75f81b358 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -1173,9 +1173,9 @@ async fn a_failing_settle_probe_ends_the_turn_errored() { /// the turn instead of sticking on Working forever. #[tokio::test] async fn old_pi_without_disposition_is_inferred_not_assumed() { - let (controls, steer, _token) = controls(); + let (first, steer, _token) = controls(); drop(steer); - let events = run_to_end(&harness(), request("old-pi"), controls).await; + let events = run_to_end(&harness(), request("old-pi"), first).await; assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); assert_eq!(text_of(&events), "reply:old-pi"); From bb6aff2bacaffd5d8e8c9f207dee2476c9287a73 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 15:24:25 +0700 Subject: [PATCH 15/96] feat(accounts): harness accounts show whether they are signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each harness account row gains two additive wire fields (ADR 0005): sign_in_state (signed-in / signed-out / unknown) and limited_until (the latest reset among exhausted windows). Older payloads parse as unknown. Engine (agent_accounts): - ProbeError::SignedOut is the hard-rejection signal: a saved Claude or Grok slot whose own refresh was refused (400 invalid_grant), a fresh token the provider still rejects, or a Devin key refusal (keys never lapse). It persists in the usage cache and marks the account signed out; every other failure (expired-but-CLI-refreshes, rate limit, network) leaves the state alone — ambiguous, never signed-out. - UsageEntry records the sign-in evidence; a successful probe flips it to signed-in. A signed-out mark recorded against credentials the slot no longer holds reads back as unknown, and fresh credentials lift the 30s freshness gate, so the reload right after a sign-in re-probes. - mark_account_signed_out records a run's structured signed-out error (ticket 05's kind; the engine wiring calls this) durably, offline. - The probe's failure reason stays on the row beside cached windows - stale meters no longer hide a sign-out (the display bug). Desktop (Settings -> Accounts and the composer account popover): - Signed-out rows carry a danger mark, show the reason under/beside their meters, and offer Sign in again, which runs the provider's own login flow (inline in the popover: browser + poll, or the paste-code fallback) for the account's provider group. - Switching to a signed-out account confirms first. Wiregen: AgentSignInState joins the export manifest; the generated web types are hand-updated to match ts-rs byte-for-byte (no local builds per the ticket override; the CI gate verifies). Tests: proto wire-shape pin (additivity both directions), usage-entry state transitions and staleness, the reason mapping, limited_until, and mock-server flows for the refused refresh, the ambiguous active login, the sign-in-again round trip, the run-error mark, and Devin revocation. All not run locally per the ticket override; CI on the PR is the signal. --- crates/engine/src/agent_accounts.rs | 318 +++++++++- .../src/agent_accounts/provider_tests.rs | 349 ++++++++++- crates/engine/src/agent_accounts/usage.rs | 37 +- crates/proto/src/entities.rs | 101 +++ crates/ui/src/account_usage.rs | 577 +++++++++++++++++- crates/ui/src/settings/accounts.rs | 411 +++++++++++-- crates/wiregen/src/main.rs | 1 + web/packages/app/tests/account-row.test.ts | 1 + web/packages/app/tests/account-usage.test.ts | 1 + web/packages/app/tests/accounts-view.test.ts | 1 + .../proto/src/generated/AgentAccount.ts | 18 +- .../proto/src/generated/AgentSignInState.ts | 12 + web/packages/proto/src/generated/index.ts | 1 + 13 files changed, 1725 insertions(+), 103 deletions(-) create mode 100644 web/packages/proto/src/generated/AgentSignInState.ts diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 512bf8c0b0..7a23bb4a0e 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -77,6 +77,19 @@ //! (shown instead of "Usage unavailable"), and backs off — honouring //! `Retry-After` — before that account is probed again. Only 401/403 counts //! as a rejected token (and only then is a saved Claude slot refreshed). +//! +//! Sign-in state (ticket 08): every account row reports whether its login is +//! known to work — `signed-in` after the last successful probe, `signed-out` +//! after a HARD rejection, `unknown` otherwise. A rejection is hard only when +//! it proves the login is dead: the slot's own refresh was refused (Claude, +//! Grok: 400 invalid_grant) or the just-refreshed token was rejected again, or +//! a provider whose keys never lapse refused one (Devin). An expired access +//! token the CLI refreshes itself stays `unknown` — ambiguous, never +//! signed-out. The engine also marks an account signed out when a run fails +//! with ticket 05's structured `signed-out` error +//! ([`AgentAccounts::mark_account_signed_out`]); a later successful probe or +//! sign-in clears the mark. Each row also carries the latest reset among its +//! exhausted windows (`limited_until`), the moment a limit is known to lift. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -92,7 +105,8 @@ use sha2::{Digest, Sha256}; use roboco_proto::{ AgentAccount, AgentAccountWarning, AgentAccountsSnapshot, AgentAuthKind, AgentLoginMode, - AgentLoginPoll, AgentLoginStart, AgentLoginStatus, AgentUsageWindow, HarnessId, + AgentLoginPoll, AgentLoginStart, AgentLoginStatus, AgentSignInState, AgentUsageWindow, + HarnessId, }; use crate::repos::home_dir; @@ -495,6 +509,16 @@ enum ProbeError { /// 401/403 — the token was rejected; the ONLY status worth a refresh. #[serde(rename_all = "camelCase")] Unauthorized { status: u16 }, + /// A rejection that PROVES the login is dead, not merely stale: the + /// slot's own refresh was refused (Claude, Grok: 400 invalid_grant) or + /// the just-refreshed token was rejected too, or a provider whose keys + /// never lapse on their own refused one (Devin: a rejection means the + /// key was revoked). Distinct from [`ProbeError::Unauthorized`], which an + /// expired access token also produces and only the CLI's own refresh can + /// settle — this is the hard signal that marks the account signed out + /// until a sign-in or a successful probe clears it. + #[serde(rename_all = "camelCase")] + SignedOut { status: u16 }, /// 429 — `retry_after_secs` from the `Retry-After` header when sent. #[serde(rename_all = "camelCase")] RateLimited { retry_after_secs: Option }, @@ -535,6 +559,7 @@ impl ProbeError { fn class(&self) -> &'static str { match self { ProbeError::Unauthorized { .. } => "unauthorized", + ProbeError::SignedOut { .. } => "signed-out", ProbeError::RateLimited { .. } => "rate-limited", ProbeError::Http { status, .. } if *status >= 500 => "server-error", ProbeError::Http { .. } => "http-error", @@ -548,7 +573,9 @@ impl ProbeError { fn status(&self) -> Option { match self { - ProbeError::Unauthorized { status } | ProbeError::Http { status, .. } => Some(*status), + ProbeError::Unauthorized { status } + | ProbeError::SignedOut { status } + | ProbeError::Http { status, .. } => Some(*status), ProbeError::RateLimited { .. } => Some(429), _ => None, } @@ -575,6 +602,7 @@ impl ProbeError { // Lifted early when the slot's credentials change (re-login, the // CLI refreshing its token) — see `UsageEntry::probe_due`. ProbeError::Unauthorized { .. } + | ProbeError::SignedOut { .. } | ProbeError::NoCredentials { .. } | ProbeError::UntrustedEndpoint => Duration::from_secs(10 * 60), } @@ -596,6 +624,13 @@ struct UsageEntry { /// The last probe's failure; cleared by the next success. #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, + /// Hard sign-in evidence, surviving ambiguous failures: `SignedIn` after + /// the last successful probe, `SignedOut` after a hard rejection (see + /// [`ProbeError::SignedOut`]) or a run's signed-out error (see + /// [`AgentAccounts::mark_account_signed_out`]). `None` = unknown — never + /// probed, or only ambiguous outcomes. + #[serde(default, skip_serializing_if = "Option::is_none")] + sign_in: Option, /// Last probe attempt, success or failure. #[serde(default)] checked_at: i64, @@ -610,7 +645,13 @@ struct UsageEntry { impl UsageEntry { /// Whether a forced list should probe this account now. fn probe_due(&self, credentials: &str, now: i64) -> bool { - if now - self.checked_at < FORCED_MIN_INTERVAL.as_millis() as i64 { + // The last failure was about OTHER credentials (a sign-in landed, the + // CLI rotated its token): the entry knows nothing about the current + // ones, so even the freshness gate lifts — the reload right after a + // sign-in must re-establish the account's state, not replay the old + // rejection. Same principle the backoff arm below encodes. + let credentials_changed = self.credentials.as_deref().is_some_and(|c| c != credentials); + if !credentials_changed && now - self.checked_at < FORCED_MIN_INTERVAL.as_millis() as i64 { return false; } match (self.retry_at, &self.error) { @@ -621,9 +662,10 @@ impl UsageEntry { matches!( error, ProbeError::Unauthorized { .. } + | ProbeError::SignedOut { .. } | ProbeError::NoCredentials { .. } | ProbeError::UntrustedEndpoint - ) && self.credentials.as_deref() != Some(credentials) + ) && credentials_changed } _ => true, } @@ -638,14 +680,41 @@ impl UsageEntry { self.error = None; self.retry_at = None; self.credentials = None; + // A probe that reached the provider and came back green is + // proof the login works — it clears any earlier signed-out + // mark (the display fix's "later successful probe"). + self.sign_in = Some(AgentSignInState::SignedIn); } Err(error) => { self.retry_at = Some(now + error.backoff().as_millis() as i64); self.error = Some(error); self.credentials = Some(credentials); + // Only a HARD rejection flips the state to signed out; every + // other failure (expired-but-CLI-refreshes, rate limit, + // network) says nothing about the sign-in and leaves the last + // hard evidence standing. + if matches!(error, ProbeError::SignedOut { .. }) { + self.sign_in = Some(AgentSignInState::SignedOut); + } } } } + + /// The account's sign-in state as of this list. A signed-out mark was + /// recorded against the credentials the failure saw; when the slot holds + /// different ones now (a sign-in landed, the CLI refreshed the token), + /// the mark is stale and reads unknown until the next probe settles it. + fn sign_in_state(&self, credentials: &str) -> AgentSignInState { + match self.sign_in { + Some(AgentSignInState::SignedOut) + if self.credentials.as_deref().is_some_and(|c| c != credentials) => + { + AgentSignInState::Unknown + } + Some(state) => state, + None => AgentSignInState::Unknown, + } + } } #[derive(Debug, Default, Serialize, Deserialize)] @@ -1013,6 +1082,7 @@ impl AgentAccounts { let active = active_set.is_some_and(|keys| keys.contains(&slot.account_key)); let entry = usage.get(&usage_key(harness, &slot.account_key)); let snapshot = entry.and_then(|entry| entry.usage.as_ref()); + let credentials = credentials_fingerprint(&slot.credentials); accounts.push(AgentAccount { id: slot.id.clone(), harness, @@ -1038,6 +1108,10 @@ impl AgentAccounts { now, ) }), + sign_in_state: entry + .map(|entry| entry.sign_in_state(&credentials)) + .unwrap_or(AgentSignInState::Unknown), + limited_until: snapshot.and_then(|usage| limited_until(&usage.windows)), display_name: slot.profile.display_name.clone(), organization: slot.profile.organization.clone(), auth_kind: Some(slot.profile.auth_kind), @@ -1062,6 +1136,9 @@ impl AgentAccounts { usage_windows: Vec::new(), usage_fetched_at: None, usage_error: None, + // The secret couldn't be read, so nothing was ever probed. + sign_in_state: AgentSignInState::Unknown, + limited_until: None, display_name: u.profile.display_name.clone(), organization: u.profile.organization.clone(), auth_kind: Some(u.profile.auth_kind), @@ -1408,6 +1485,59 @@ impl AgentAccounts { self.list_locked(false).await } + // ── sign-in state ────────────────────────────────────────────────────── + + /// Mark one saved account signed out from OUTSIDE the usage probe — a + /// run that failed with a signed-out error (ticket 05's structured + /// `AgentErrorKind::SignedOut`, on the harness's active account), or an + /// account-failover resume whose sign-in failed (ticket 09). The mark is + /// durable (the usage cache is rewritten, so it survives restarts) and + /// keyed to the slot's current credentials: a sign-in that lands on the + /// account, or a later successful probe, clears it. + /// + /// No network: this only records evidence. The wiring that watches runs + /// for signed-out errors resolves the active account and calls this. + pub fn mark_account_signed_out( + &self, + harness: HarnessId, + account_id: &str, + ) -> Result<(), EngineError> { + let slot = self + .read_slots(harness) + .into_iter() + .find(|s| s.id == account_id) + .ok_or_else(|| EngineError::Other("Unknown account.".into()))?; + let key = usage_key(harness, &slot.account_key); + let credentials = credentials_fingerprint(&slot.credentials); + { + let mut usage = lock(&self.inner.usage); + let entry = usage.entry(key).or_default(); + entry.sign_in = Some(AgentSignInState::SignedOut); + // Anchor the mark to these credentials so a re-login (or the + // CLI rotating its token) reads back as unknown — see + // [`UsageEntry::sign_in_state`]. + entry.credentials = Some(credentials); + let file = UsageCacheFile { + entries: usage.clone(), + }; + let persisted = serde_json::to_vec_pretty(&file) + .map_err(|e| EngineError::Other(e.to_string())) + .and_then(|json| { + self.inner.config.private_root()?; + write_file_atomic(&self.inner.config.usage_cache_file(), &json, true) + }); + if let Err(err) = persisted { + tracing::warn!(error = %err, "agent usage cache write failed"); + } + } + tracing::info!( + provider = harness_slug(harness), + slot = %account_id, + "harness account marked signed out" + ); + Ok(()) + } + // ── add-account OAuth flows ───────────────────────────────────────────── pub async fn start_login(&self, harness: HarnessId) -> Result { @@ -2722,11 +2852,26 @@ impl AgentAccounts { Err(ProbeError::Unauthorized { status }) if !is_active && self.inner.endpoints.allow_slot_refresh => { - match self.refresh_claude_slot(slot).await? { - Some(fresh) => self.claude_usage_request(&fresh).await, + match self.refresh_claude_slot(slot).await { + Ok(Some(fresh)) => match self.claude_usage_request(&fresh).await { + // The token this slot JUST rotated to was rejected + // too: the login is dead, not stale. + Err(ProbeError::Unauthorized { status }) => { + Err(ProbeError::SignedOut { status }) + } + result => result, + }, // Refresh in flight elsewhere (single-flight) — report // the original rejection; the next probe sees the result. - None => Err(ProbeError::Unauthorized { status }), + Ok(None) => Err(ProbeError::Unauthorized { status }), + // A refused refresh (400 invalid_grant: revoked/used + // token) means this login is dead — the hard signed-out + // signal, not an ambiguous expired token. Every other + // refresh failure (network, 5xx) stays what it was. + Err(ProbeError::Unauthorized { status }) => { + Err(ProbeError::SignedOut { status }) + } + Err(other) => Err(other), } } result => result, @@ -3049,6 +3194,10 @@ impl AntigravityLogin { usage_windows: Vec::new(), usage_fetched_at: None, usage_error: None, + // Roboco never reads Antigravity's token: no probe ever ran, so + // its sign-in state stays unknown. + sign_in_state: AgentSignInState::Unknown, + limited_until: None, display_name: Some(label.to_string()), organization: None, auth_kind: Some(kind), @@ -3411,6 +3560,19 @@ fn credentials_fingerprint(credentials: &serde_json::Value) -> String { crate::repos::hex(&Sha256::digest(credentials.to_string().as_bytes()))[..16].to_string() } +/// The latest reset among the account's EXHAUSTED windows (100% used): the +/// moment every blocked window has lifted — while it is in the future, the +/// account is still limited. Windows that aren't full, or that don't know +/// their reset, contribute nothing; `None` = not limited by any cached +/// window. Pure (the row's `limited_until`). +fn limited_until(windows: &[AgentUsageWindow]) -> Option> { + windows + .iter() + .filter(|window| window.used_fraction >= 1.0) + .filter_map(|window| window.resets_at) + .max() +} + /// Send one probe request and decode its JSON body, classifying every /// failure. Logs provider/step/status/class/Retry-After — never the request /// (bearer tokens) or the response body (it can echo account details). @@ -3532,12 +3694,10 @@ fn usage_error_message( .map(|at| format!(" — retrying in {}", short_duration(at - now))) .unwrap_or_default(); Some(match error { + // A hard rejection — the account is signed out regardless of which + // login it is (this is what flipped the sign-in state). + ProbeError::SignedOut { .. } => "Signed out — sign in again".to_string(), ProbeError::RateLimited { .. } => format!("Rate limited by {provider}{retry}"), - // Devin's key never expires on its own: a rejection means it was - // revoked, whichever login it is. - ProbeError::Unauthorized { .. } if harness == HarnessId::Devin => { - "Signed out — sign in again".to_string() - } ProbeError::Unauthorized { .. } if active => { // The CLI owns (and refreshes) the live token; it just hasn't yet. format!( @@ -4203,6 +4363,7 @@ fn stage_file_atomic( #[cfg(test)] mod tests { use super::*; + use chrono::{TimeDelta, TimeZone}; #[test] fn plan_labels() { @@ -4685,6 +4846,21 @@ mod probe_tests { .as_deref(), Some("Signed out — sign in again") ); + // A hard rejection reads as signed out on every login, even the + // active one (a run error or a refused refresh marked it). + assert_eq!( + message(HarnessId::ClaudeCode, true, &ProbeError::SignedOut { status: 400 }).as_deref(), + Some("Signed out — sign in again") + ); + assert_eq!( + message(HarnessId::Devin, false, &ProbeError::SignedOut { status: 403 }).as_deref(), + Some("Signed out — sign in again") + ); + // The ambiguous arms keep their text. + assert_eq!( + message(HarnessId::ClaudeCode, true, &ProbeError::Unauthorized { status: 401 }).as_deref(), + Some("Session expired — it refreshes the next time claude runs") + ); assert_eq!( message( HarnessId::Codex, @@ -4709,8 +4885,126 @@ mod probe_tests { let entry = &back.entries["claude-code:acct"]; assert_eq!(entry.usage, Some(snapshot())); assert_eq!(entry.fetched_at, Some(5)); + assert_eq!(entry.sign_in, Some(AgentSignInState::SignedIn)); // The fingerprint is a digest, never the credential blob. assert!(!json.contains("accessToken")); + // A cache from before the sign-in state existed still parses, as + // unknown — and a signed-out mark survives the round trip. + let legacy: UsageCacheFile = + serde_json::from_str(r#"{"entries":{"codex:acct":{"checkedAt":9}}}"#).unwrap(); + assert_eq!(legacy.entries["codex:acct"].sign_in, None); + let mut marked = UsageEntry::default(); + marked.sign_in = Some(AgentSignInState::SignedOut); + marked.credentials = Some("f".into()); + let file = UsageCacheFile { + entries: HashMap::from([("grok:acct".to_string(), marked)]), + }; + let back: UsageCacheFile = + serde_json::from_str(&serde_json::to_string(&file).unwrap()).unwrap(); + assert_eq!(back.entries["grok:acct"].sign_in, Some(AgentSignInState::SignedOut)); + } + + /// The sign-in state moves only on hard evidence: a hard rejection + /// marks the account signed out, a success marks it signed in, and + /// every other failure (an expired token, a rate limit, a network + /// blip) leaves the last hard evidence standing. + #[test] + fn only_hard_rejections_and_successes_move_the_sign_in_state() { + let mut entry = UsageEntry::default(); + assert_eq!(entry.sign_in, None, "never probed is unknown"); + // An ambiguous rejection (the CLI refreshes such tokens itself). + entry.record( + Err(ProbeError::Unauthorized { status: 401 }), + "c".into(), + 1_000, + ); + assert_eq!(entry.sign_in, None, "an expired token is not a sign-out"); + // A rate limit says nothing about the login. + entry.record( + Err(ProbeError::RateLimited { retry_after_secs: None }), + "c".into(), + 2_000, + ); + assert_eq!(entry.sign_in, None); + // A hard rejection — the slot's refresh was refused. + entry.record( + Err(ProbeError::SignedOut { status: 400 }), + "c".into(), + 3_000, + ); + assert_eq!(entry.sign_in, Some(AgentSignInState::SignedOut)); + // Later ambiguous failures must not resurrect it as healthy. + entry.record( + Err(ProbeError::RateLimited { retry_after_secs: None }), + "c".into(), + 4_000, + ); + assert_eq!(entry.sign_in, Some(AgentSignInState::SignedOut)); + // A successful probe clears it. + entry.record(Ok(snapshot()), "c".into(), 5_000); + assert_eq!(entry.sign_in, Some(AgentSignInState::SignedIn)); + } + + /// A signed-out mark is about the credentials it was recorded against: + /// once the slot holds different ones (a sign-in landed, the CLI rotated + /// its token), the mark reads back as unknown — and the next forced list + /// probes right away instead of replaying the old rejection. + #[test] + fn a_signed_out_mark_for_old_credentials_reads_unknown_and_reprobes() { + let mut entry = UsageEntry::default(); + entry.record( + Err(ProbeError::SignedOut { status: 400 }), + "old".into(), + 1_000, + ); + assert_eq!(entry.sign_in_state("old"), AgentSignInState::SignedOut); + assert_eq!(entry.sign_in_state("new"), AgentSignInState::Unknown); + // Fresh credentials lift even the 30s freshness gate … + let soon = 1_000 + FORCED_MIN_INTERVAL.as_millis() as i64 - 1; + assert!(entry.probe_due("new", soon)); + // … while the same credentials keep it (the backoff arm also holds). + assert!(!entry.probe_due("old", soon)); + assert!(entry.probe_due("old", 1_000 + FORCED_MIN_INTERVAL.as_millis() as i64 + 1)); + // A mark with no recorded credentials (never failed a probe) reads + // as recorded for any credentials. + let mut bare = UsageEntry::default(); + bare.sign_in = Some(AgentSignInState::SignedOut); + assert_eq!(bare.sign_in_state("anything"), AgentSignInState::SignedOut); + } + + /// `limited_until` is the LATEST reset among the exhausted windows — + /// when it is in the future the account is still limited. Not-full + /// windows and resets already past count for nothing. + #[test] + fn limited_until_is_the_latest_reset_of_exhausted_windows() { + let at = |hours: i64| Utc.with_ymd_and_hms(2026, 10, 17, 9, 0, 0).unwrap() + TimeDelta::hours(hours); + let window = |label: &str, used: f32, reset: Option>| AgentUsageWindow { + label: label.into(), + used_fraction: used, + resets_at: reset, + }; + // No windows, or none exhausted. + assert_eq!(limited_until(&[]), None); + assert_eq!( + limited_until(&[window("Session", 0.99, Some(at(1))), window("Week", 0.5, Some(at(3)))]), + None + ); + // One exhausted window with no reset: nothing knowable. + assert_eq!(limited_until(&[window("Session", 1.0, None)]), None); + // The latest reset of the exhausted ones wins — not the latest of all. + assert_eq!( + limited_until(&[ + window("Session", 1.0, Some(at(1))), + window("Week", 1.0, Some(at(3))), + window("Day", 0.1, Some(at(8))), + ]), + Some(at(3)) + ); + // Exhausted at 100% exactly — 99% is not exhausted. + assert_eq!( + limited_until(&[window("Session", 1.0, Some(at(2))), window("Backup", 0.99, Some(at(5)))]), + Some(at(2)) + ); } /// A transient DNS failure during the usage probe must surface as a diff --git a/crates/engine/src/agent_accounts/provider_tests.rs b/crates/engine/src/agent_accounts/provider_tests.rs index 1069698b46..6f82e78646 100644 --- a/crates/engine/src/agent_accounts/provider_tests.rs +++ b/crates/engine/src/agent_accounts/provider_tests.rs @@ -1,6 +1,9 @@ //! Grok / Devin / OpenCode / Pi / Hermes accounts against fake credential //! stores in temp dirs, fake CLIs and a local mock of every provider -//! endpoint — never a real login, token or network call. +//! endpoint — never a real login, token or network call. The Claude +//! sign-in-state tests live here too: they need the same mocked usage / +//! token endpoints to drive a hard (refused-refresh) rejection against a +//! saved slot. use std::sync::atomic::{AtomicUsize, Ordering}; @@ -33,10 +36,12 @@ fn chatgpt_access(email: &str, account_id: &str, plan: &str) -> String { })) } -type Handler = dyn Fn(&str, &str, &str) -> (u16, String) + Send + Sync; +type Handler = dyn Fn(&str, &str, &str, &str) -> (u16, String) + Send + Sync; /// A local stand-in for provider endpoints: `handler(method, path+query, -/// body)` answers every request; `hits` records `"METHOD path"`. +/// body, bearer)` answers every request (the bearer is the Authorization +/// header's credential, `""` when none was sent); `hits` records +/// `"METHOD path"`. struct MockServer { base: String, hits: Arc>>, @@ -45,7 +50,7 @@ struct MockServer { impl MockServer { async fn start( - handler: impl Fn(&str, &str, &str) -> (u16, String) + Send + Sync + 'static, + handler: impl Fn(&str, &str, &str, &str) -> (u16, String) + Send + Sync + 'static, ) -> Self { let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) .await @@ -89,8 +94,19 @@ impl MockServer { let mut first = head.lines().next().unwrap_or("").split_whitespace(); let method = first.next().unwrap_or("").to_string(); let path = first.next().unwrap_or("").to_string(); + // The bearer's credential, when the request carried one — + // handlers branch on WHICH account probed (hyper sends + // lowercase header names). + let bearer = head + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("authorization:")) + .and_then(|l| l.split_once(':')) + .map(|(_, v)| v.trim()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_string) + .unwrap_or_default(); lock(&hits).push(format!("{method} {path}")); - let (status, reply) = handler(&method, &path, &body); + let (status, reply) = handler(&method, &path, &body, &bearer); let response = http_response( &format!("{status} X"), &[("Content-Type", "application/json")], @@ -415,7 +431,7 @@ async fn forgetting_a_live_login_signs_out_only_that_login() { async fn a_rejected_saved_grok_token_refreshes_once_against_its_issuer() { let issuer_hits = Arc::new(AtomicUsize::new(0)); let hits = issuer_hits.clone(); - let server = MockServer::start(move |method, path, body| match (method, path) { + let server = MockServer::start(move |method, path, body, _| match (method, path) { ("GET", p) if p.starts_with("/v1/billing") => { // Only the refreshed token is accepted. (200, r#"{"config":{"creditUsagePercent":30}}"#.to_string()) @@ -432,7 +448,7 @@ async fn a_rejected_saved_grok_token_refreshes_once_against_its_issuer() { }) .await; // Billing rejects every token but "fresh". - let billing = MockServer::start(|_, _, _| (401, String::new())).await; + let billing = MockServer::start(|_, _, _, _| (401, String::new())).await; let tmp = tempfile::tempdir().unwrap(); let mut endpoints = mocked(&server.base); endpoints.grok_usage = format!("{}/v1/billing?format=credits", billing.base); @@ -459,9 +475,11 @@ async fn a_rejected_saved_grok_token_refreshes_once_against_its_issuer() { // A saved one is — once — and the rotated pair lands in its slot. let saved = accounts.grok_usage(&slot, false).await; assert_eq!(issuer_hits.load(Ordering::SeqCst), 1); + // The issuer minted a fresh token and billing STILL rejects it: the + // login is dead — the hard signed-out signal, not an ambiguous expiry. assert!( - matches!(saved, Err(ProbeError::Unauthorized { .. })), - "billing mock rejects all" + matches!(saved, Err(ProbeError::SignedOut { .. })), + "billing mock rejects all: {saved:?}" ); let stored = accounts.read_slot(HarnessId::Grok, &slot.id).unwrap(); assert_eq!(stored.credentials["key"], "fresh"); @@ -576,7 +594,7 @@ fn devin_usage_inverts_remaining_quota_and_names_the_account() { #[tokio::test] async fn devin_swaps_and_learns_its_identity_from_the_usage_probe() { - let server = MockServer::start(|method, path, body| { + let server = MockServer::start(|method, path, body, _| { assert_eq!(method, "POST"); assert!( path.ends_with("SeatManagementService/GetUserStatus"), @@ -785,7 +803,7 @@ async fn pi_swaps_under_its_lockfile_and_groups_rows_per_provider() { #[tokio::test] async fn an_opaque_pi_claude_token_is_identified_once_then_matched_by_token() { - let server = MockServer::start(|_, path, _| match path { + let server = MockServer::start(|_, path, _, _| match path { "/api/oauth/profile" => ( 200, serde_json::json!({ @@ -826,7 +844,7 @@ async fn an_opaque_pi_claude_token_is_identified_once_then_matched_by_token() { #[tokio::test] async fn an_unidentifiable_live_token_is_listed_but_not_switchable() { - let server = MockServer::start(|_, _, _| (401, String::new())).await; + let server = MockServer::start(|_, _, _, _| (401, String::new())).await; let tmp = tempfile::tempdir().unwrap(); let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); write( @@ -850,7 +868,7 @@ async fn an_unidentifiable_live_token_is_listed_but_not_switchable() { #[tokio::test] async fn chatgpt_sign_in_for_pi_lands_on_the_loopback_and_connects_the_first_login() { - let server = MockServer::start(|method, path, body| match (method, path) { + let server = MockServer::start(|method, path, body, _| match (method, path) { ("POST", "/oauth/token") => { assert!(body.contains("grant_type=authorization_code"), "{body}"); assert!(body.contains("code=good-code"), "{body}"); @@ -936,7 +954,7 @@ async fn chatgpt_sign_in_for_pi_lands_on_the_loopback_and_connects_the_first_log /// stores too). #[tokio::test] async fn re_signing_in_the_live_chatgpt_account_replaces_its_dead_tokens() { - let server = MockServer::start(|method, path, _| match (method, path) { + let server = MockServer::start(|method, path, _, _| match (method, path) { ("POST", "/oauth/token") => ( 200, serde_json::json!({ @@ -991,7 +1009,7 @@ async fn re_signing_in_the_live_chatgpt_account_replaces_its_dead_tokens() { async fn a_new_login_never_replaces_an_unidentified_live_one() { let user_calls = Arc::new(AtomicUsize::new(0)); let calls = user_calls.clone(); - let server = MockServer::start(move |method, path, _| match (method, path) { + let server = MockServer::start(move |method, path, _, _| match (method, path) { ("POST", "/login/device/code") => ( 200, r#"{"device_code":"dc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","interval":1}"#.into(), @@ -1063,7 +1081,7 @@ async fn a_new_chatgpt_sign_in_supersedes_one_holding_the_same_port() { async fn copilot_device_sign_in_for_opencode_shows_the_code_and_connects() { let polls = Arc::new(AtomicUsize::new(0)); let polled = polls.clone(); - let server = MockServer::start(move |method, path, body| match (method, path) { + let server = MockServer::start(move |method, path, body, _| match (method, path) { ("POST", "/login/device/code") => { assert!(body.contains("client_id=Ov23li8tweQw6odWQebz"), "{body}"); ( @@ -1148,7 +1166,7 @@ fn copilot_usage_reads_metered_quotas_and_the_plan() { #[tokio::test] async fn keyed_logins_probe_the_vendor_behind_them() { - let server = MockServer::start(|method, path, _| match (method, path) { + let server = MockServer::start(|method, path, _, _| match (method, path) { ("GET", "/backend-api/wham/usage") => ( 200, r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":18000}}}"#.into(), @@ -1222,7 +1240,7 @@ fn hermes_auth() -> serde_json::Value { #[tokio::test] async fn hermes_lists_its_own_pool_read_only() { - let server = MockServer::start(|_, path, _| match path { + let server = MockServer::start(|_, path, _, _| match path { "/api/oauth/account" => ( 200, r#"{"user":{"email":"nous@example.com"},"subscription":{"plan":"plus","monthly_credits":100,"credits_remaining":25,"current_period_end":"2030-02-01T00:00:00Z"}}"#.into(), @@ -1610,7 +1628,7 @@ fn devin_sign_in_urls_must_be_devins_own_pages() { #[tokio::test] async fn untrusted_endpoints_in_credentials_are_never_sent_a_secret() { - let server = MockServer::start(|_, _, _| (200, "{}".into())).await; + let server = MockServer::start(|_, _, _, _| (200, "{}".into())).await; let tmp = tempfile::tempdir().unwrap(); // Production endpoints: a loopback url in a credential is NOT trusted. let (accounts, config) = accounts_with(tmp.path(), ProbeEndpoints::default()); @@ -1721,7 +1739,7 @@ fn the_accounts_root_and_sign_in_homes_are_owner_only() { /// any network call, and restored byte-for-byte after switching away. #[tokio::test] async fn a_self_hosted_ghes_login_switches_away_and_back_without_the_network() { - let server = MockServer::start(|_, path, _| match path { + let server = MockServer::start(|_, path, _, _| match path { "/user" => ( 200, r#"{"id":42,"login":"octo","email":"octo@example.com"}"#.into(), @@ -1898,3 +1916,294 @@ async fn a_failed_sign_ins_output_is_redacted_before_the_ui_sees_it() { "failed at https://auth.x.ai/device?… with code [code] token=[redacted]" ); } + +// ── Claude: the harness account sign-in state (ticket 08) ─────────────────── + +/// Write a Claude login (config + credentials) for `uuid` with `token`. +fn write_claude_login(config: &AgentAccountsConfig, uuid: &str, email: &str, token: &str) { + write( + &config.claude_config_file, + &serde_json::json!({ + "oauthAccount": { + "accountUuid": uuid, + "emailAddress": email, + "displayName": "Test User", + }, + }) + .to_string(), + ); + write( + &config.claude_creds_file(), + &serde_json::json!({ + "claudeAiOauth": { + "accessToken": token, + "refreshToken": format!("refresh-{token}"), + // Far-future expiry: probes must never try to rotate it. + "expiresAt": 4_102_444_800_000i64, + } + }) + .to_string(), + ); +} + +/// Claude's `/api/oauth/usage` reply for two windows at the given percents. +fn claude_usage_body(session: f64, week: f64) -> String { + serde_json::json!({ + "five_hour": { "utilization": session, "resets_at": "2026-10-18T08:00:00Z" }, + "seven_day": { "utilization": week, "resets_at": "2026-10-20T16:00:00Z" }, + }) + .to_string() +} + +/// A live login that a run refused: an expired access token the CLI refreshes +/// itself is AMBIGUOUS — unknown, never signed out — and no refresh is ever +/// attempted for the live login. +#[tokio::test] +async fn an_expired_active_claude_login_is_unknown_not_signed_out() { + let server = MockServer::start(|_method, path, _body, _bearer| match path { + "/api/oauth/usage" => (401, String::new()), + _ => (404, String::new()), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].sign_in_state, AgentSignInState::Unknown); + assert_eq!( + rows[0].usage_error.as_deref(), + Some("Session expired — it refreshes the next time claude runs") + ); + // The active login is the CLI's to rotate: no refresh was attempted. + assert_eq!(server.hits("POST /v1/oauth/token"), 0); +} + +/// A hard rejection — the saved slot's refresh was refused (invalid_grant) — +/// marks the account signed out, and the reason shows BESIDE the cached +/// meters instead of being hidden by them (the display bug: stale windows +/// made a signed-out account look healthy). +#[tokio::test] +async fn a_refused_claude_refresh_marks_the_slot_signed_out_beside_cached_windows() { + let server = MockServer::start(|_method, path, _body, bearer| match path { + "/api/oauth/usage" => { + if bearer == "alice-token" { + (200, claude_usage_body(6.0, 35.0)) + } else { + (401, String::new()) + } + } + // The refused refresh: 400 invalid_grant means the login is dead. + "/v1/oauth/token" => (400, r#"{"error":"invalid_grant"}"#.into()), + _ => (404, String::new()), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); + + // Alice is live; Bob is a saved, inactive slot. + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + accounts.list(false).await.unwrap(); + write_claude_login(&config, "uuid-bob", "bob@example.com", "bob-token"); + accounts.list(false).await.unwrap(); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + accounts.list(false).await.unwrap(); + + // Bob's meters were right a while ago: the cache serves them until a + // probe lands. Age the entry so the forced list below re-probes him. + lock(&accounts.inner.usage).insert( + usage_key(HarnessId::ClaudeCode, "uuid-bob"), + UsageEntry { + usage: Some(UsageSnapshot { + windows: vec![AgentUsageWindow { + label: "Session".into(), + used_fraction: 0.4, + resets_at: None, + }], + plan_label: None, + }), + fetched_at: Some(1), + checked_at: 1, + ..Default::default() + }, + ); + + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + let bob = rows + .iter() + .find(|a| a.email.as_deref() == Some("bob@example.com")) + .unwrap(); + assert_eq!(bob.sign_in_state, AgentSignInState::SignedOut); + // The stale meters survive the failure — and no longer hide the reason. + assert!(!bob.usage_windows.is_empty()); + assert_eq!(bob.usage_error.as_deref(), Some("Signed out — sign in again")); + // The live account's own probe is green. + let alice = rows + .iter() + .find(|a| a.email.as_deref() == Some("alice@example.com")) + .unwrap(); + assert_eq!(alice.sign_in_state, AgentSignInState::SignedIn); + assert!(alice.usage_error.is_none()); +} + +/// "Sign in again": completing the login replaces the dead slot's +/// credentials, the next forced list re-probes them right away (fresh +/// credentials lift the freshness gate), and the account reads signed in — +/// with its exhausted window's reset as `limited_until`. +#[tokio::test] +async fn signing_in_again_marks_the_account_signed_in() { + let server = MockServer::start(|_method, path, body, bearer| match path { + "/api/oauth/usage" => match bearer.as_str() { + "alice-token" => (200, claude_usage_body(6.0, 35.0)), + "bob-fresh" => (200, claude_usage_body(100.0, 35.0)), + _ => (401, String::new()), + }, + "/v1/oauth/token" => { + // The slot-refresh POST (JSON: grant_type refresh_token) is + // refused; the sign-in's code exchange succeeds. + if body.contains("refresh_token") { + (400, r#"{"error":"invalid_grant"}"#.into()) + } else { + ( + 200, + serde_json::json!({ + "access_token": "bob-fresh", + "refresh_token": "refresh-bob-fresh", + "expires_in": 3600, + "account": { + "email_address": "bob@example.com", + "uuid": "uuid-bob", + }, + }) + .to_string(), + ) + } + } + _ => (404, String::new()), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); + + // Alice live, Bob a saved slot whose probe is hard-rejected. + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + accounts.list(false).await.unwrap(); + write_claude_login(&config, "uuid-bob", "bob@example.com", "bob-token"); + accounts.list(false).await.unwrap(); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + let bob = rows + .iter() + .find(|a| a.email.as_deref() == Some("bob@example.com")) + .unwrap(); + assert_eq!(bob.sign_in_state, AgentSignInState::SignedOut); + + // "Sign in again" — the existing login flow, finishing in the browser. + let start = accounts.start_login(HarnessId::ClaudeCode).await.unwrap(); + let state = query_param(&start.url, "state"); + let port = start.callback_port.unwrap(); + let page = browser_get(port, &format!("/callback?code=good-code&state={state}")).await; + assert!(page.starts_with("HTTP/1.1 302"), "{page}"); + let polls = settle(&accounts, &start.login_id).await; + assert_eq!(polls.last().unwrap().status, AgentLoginStatus::Done); + + // The post-login reload re-probes Bob immediately: his slot now holds + // fresh credentials, so the old rejection no longer applies. + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + let bob = rows + .iter() + .find(|a| a.email.as_deref() == Some("bob@example.com")) + .unwrap(); + assert_eq!(bob.sign_in_state, AgentSignInState::SignedIn); + assert!(bob.usage_error.is_none()); + assert!(!bob.usage_windows.is_empty()); + // His session window is exhausted (100%): the account is limited until + // that window's reset — the latest reset among exhausted windows. + assert_eq!( + bob.limited_until, + Some( + DateTime::parse_from_rfc3339("2026-10-18T08:00:00Z") + .unwrap() + .with_timezone(&Utc) + ) + ); + // Alice stays signed in too. + let alice = rows + .iter() + .find(|a| a.email.as_deref() == Some("alice@example.com")) + .unwrap(); + assert_eq!(alice.sign_in_state, AgentSignInState::SignedIn); + assert_eq!(alice.limited_until, None); +} + +/// The run-error path's recording seam: `mark_account_signed_out` writes a +/// durable mark for a slot (ticket 05's wiring calls it with the structured +/// signed-out error's harness), and a later successful probe clears it. +/// An unknown account is refused. +#[tokio::test] +async fn a_run_error_sign_out_mark_lands_and_a_later_probe_clears_it() { + let server = MockServer::start(|_method, path, _body, _bearer| match path { + "/api/oauth/usage" => (200, claude_usage_body(6.0, 35.0)), + _ => (404, String::new()), + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + let alice_id = rows(&accounts.list(false).await.unwrap(), HarnessId::ClaudeCode) + .remove(0) + .id; + + // A run failed with a signed-out error on the active account. + accounts + .mark_account_signed_out(HarnessId::ClaudeCode, &alice_id) + .unwrap(); + let rows = rows(&accounts.list(false).await.unwrap(), HarnessId::ClaudeCode); + assert_eq!(rows[0].sign_in_state, AgentSignInState::SignedOut); + assert!(rows[0].usage_error.is_none(), "no probe ran to fail"); + assert_eq!(server.hits("GET /api/oauth/usage"), 0, "the mark is offline"); + + // The next successful probe re-establishes the account. + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + assert_eq!(rows[0].sign_in_state, AgentSignInState::SignedIn); + + // An unknown slot id is refused, not silently ignored. + let error = accounts + .mark_account_signed_out(HarnessId::ClaudeCode, "0123456789abcdef") + .unwrap_err(); + assert!(error.to_string().contains("Unknown account")); +} + +/// Devin's keys never lapse: a rejected status call is a revocation — the +/// hard signed-out signal — and reads as such on the row. +#[tokio::test] +async fn a_rejected_devin_key_marks_the_account_signed_out() { + let server = MockServer::start(|method, path, _body, _bearer| { + assert_eq!(method, "POST"); + assert!( + path.ends_with("SeatManagementService/GetUserStatus"), + "{path}" + ); + (401, String::new()) + }) + .await; + let tmp = tempfile::tempdir().unwrap(); + let (accounts, config) = accounts_with(tmp.path(), mocked(&server.base)); + write( + &config.devin_credentials_file, + &format!( + "windsurf_api_key = \"key-a\" +api_server_url = \"{}\" +", + server.base + ), + ); + + let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::Devin); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].sign_in_state, AgentSignInState::SignedOut); + assert_eq!(rows[0].usage_error.as_deref(), Some("Signed out — sign in again")); +} diff --git a/crates/engine/src/agent_accounts/usage.rs b/crates/engine/src/agent_accounts/usage.rs index 8f708ea8b2..60badc01b5 100644 --- a/crates/engine/src/agent_accounts/usage.rs +++ b/crates/engine/src/agent_accounts/usage.rs @@ -9,7 +9,9 @@ //! the CLI's to rotate. //! - **Devin**: Connect-JSON `SeatManagementService/GetUserStatus` (what //! `devin auth status` calls) — daily/weekly quota, plus the identity the -//! key file lacks, written back into the slot. Keys don't refresh. +//! key file lacks, written back into the slot. Keys don't refresh and never +//! lapse on their own, so a rejection (401/403) means the key was revoked: +//! the hard signed-out signal. //! - **ChatGPT logins** (OpenCode `openai`, Pi `openai-codex`, Hermes //! `openai-codex`): the Codex usage endpoint — the same token kind. //! - **Claude logins** (Pi `anthropic`): Claude's `/api/oauth/usage`. @@ -51,9 +53,24 @@ impl AgentAccounts { Err(ProbeError::Unauthorized { status }) if !is_active && self.inner.endpoints.allow_slot_refresh => { - match self.refresh_grok_slot(slot).await? { - Some(fresh) => self.grok_usage_request(&fresh).await, - None => Err(ProbeError::Unauthorized { status }), + match self.refresh_grok_slot(slot).await { + Ok(Some(fresh)) => match self.grok_usage_request(&fresh).await { + // The issuer refused the token it just minted: the + // login is dead, not stale. + Err(ProbeError::Unauthorized { status }) => { + Err(ProbeError::SignedOut { status }) + } + result => result, + }, + // Refresh in flight elsewhere (single-flight) — the + // original rejection; the next probe sees the result. + Ok(None) => Err(ProbeError::Unauthorized { status }), + // A refused refresh (invalid_grant: revoked/used token) + // is the hard signed-out signal. + Err(ProbeError::Unauthorized { status }) => { + Err(ProbeError::SignedOut { status }) + } + Err(other) => Err(other), } } result => result, @@ -162,7 +179,7 @@ impl AgentAccounts { ) .ok_or(ProbeError::UntrustedEndpoint)?; let server = server.as_str(); - let body = probe_json( + let body = match probe_json( "devin", "usage", self.inner @@ -186,7 +203,15 @@ impl AgentAccounts { } })), ) - .await?; + .await + { + // Devin keys never lapse on their own: a rejection means the key + // was revoked — the hard signed-out signal, on any login. + Err(ProbeError::Unauthorized { status }) => { + return Err(ProbeError::SignedOut { status }) + } + other => other?, + }; let (snapshot, identity) = devin_usage_snapshot(&body).ok_or_else(|| schema_error("devin", &body))?; // Adopt the probed identity (best-effort; a failed write re-probes). diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index c7cabf4b3c..f6980f5535 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -940,6 +940,19 @@ pub struct AgentAccount { /// beside stale windows. `None` when the last probe succeeded or none ran. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_error: Option, + /// Whether the login is known to work: `SignedIn` after the last usage + /// probe (or sign-in) succeeded, `SignedOut` after a hard rejection (a + /// refused refresh, a signed-out run error). Ambiguous "session expired" + /// outcomes never claim `SignedOut` — they stay `Unknown`, as does an + /// account never probed. Additive (ADR 0005): older payloads default to + /// `Unknown`. + #[serde(default)] + pub sign_in_state: AgentSignInState, + /// The latest reset among the account's exhausted (100% used) windows — + /// when it is in the future, the account is still limited. `None` when + /// no window is exhausted (or none knows its reset). Additive (ADR 0005). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limited_until: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -973,6 +986,27 @@ pub enum AgentAuthKind { ApiKey, } +/// Whether a harness account's login is known to work. `Unknown` is a real +/// state, not an absence: an account that was never probed, or whose last +/// probe was ambiguous (an expired token the CLI refreshes itself, a +/// rate-limited probe), is `Unknown` — never `SignedOut`. The engine flips +/// the state only on hard evidence: a successful probe or sign-in → +/// `SignedIn`, a hard rejection (refused refresh, signed-out run error) → +/// `SignedOut`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +pub enum AgentSignInState { + SignedIn, + SignedOut, + Unknown, +} + +impl Default for AgentSignInState { + fn default() -> Self { + Self::Unknown + } +} + /// Everything the Accounts settings page renders, rebuilt after every mutation. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] @@ -1564,4 +1598,71 @@ mod tests { assert!(encoded.get("setupError").is_none()); assert!(serde_json::from_value::(encoded).is_ok()); } + + /// A harness account's sign-in state and limited-until are additive on + /// the wire (ADR 0005): new payloads carry them under stable camelCase / + /// kebab-case names, a payload from before they existed parses (Unknown / + /// absent), and the state serializes as one of its three kebab-case + /// names — never null. + #[test] + fn agent_account_sign_in_state_is_additive_on_the_wire() { + let at = Utc.with_ymd_and_hms(2026, 10, 17, 9, 30, 0).unwrap(); + let account = AgentAccount { + id: "slot-1".into(), + harness: HarnessId::ClaudeCode, + email: Some("a@example.com".into()), + plan_label: None, + active: false, + usage_windows: Vec::new(), + usage_fetched_at: None, + usage_error: Some("Signed out — sign in again".into()), + sign_in_state: AgentSignInState::SignedOut, + limited_until: Some(at), + display_name: None, + organization: None, + auth_kind: None, + switchable: true, + saved_at: None, + provider: None, + }; + let value = serde_json::to_value(&account).unwrap(); + assert_eq!(value["signInState"], "signed-out"); + assert_eq!(value["limitedUntil"], serde_json::to_value(at).unwrap()); + // Unknown + no reset serialize without null placeholders. + let plain = AgentAccount { + sign_in_state: AgentSignInState::Unknown, + limited_until: None, + ..account.clone() + }; + let plain = serde_json::to_value(&plain).unwrap(); + assert_eq!(plain["signInState"], "unknown"); + assert!(plain.get("limitedUntil").is_none()); + for (state, name) in [ + (AgentSignInState::SignedIn, "signed-in"), + (AgentSignInState::SignedOut, "signed-out"), + (AgentSignInState::Unknown, "unknown"), + ] { + assert_eq!(serde_json::to_value(state).unwrap(), name); + } + // A payload from before the fields existed still parses, as Unknown. + let legacy = serde_json::json!({ + "id": "slot-1", + "harness": "claude-code", + "email": null, + "planLabel": null, + "active": true, + "usageWindows": [], + "switchable": false, + }); + let legacy: AgentAccount = serde_json::from_value(legacy).unwrap(); + assert_eq!(legacy.sign_in_state, AgentSignInState::Unknown); + assert_eq!(legacy.limited_until, None); + assert!(legacy.active); + // Round trip. + assert_eq!( + serde_json::from_value::(serde_json::to_value(&account).unwrap()) + .unwrap(), + account + ); + } } diff --git a/crates/ui/src/account_usage.rs b/crates/ui/src/account_usage.rs index 26c7787334..38076b060f 100644 --- a/crates/ui/src/account_usage.rs +++ b/crates/ui/src/account_usage.rs @@ -4,19 +4,26 @@ //! usage meters — and clicking one switches to it, the same `ActivateAgentAccount` Settings → //! Accounts runs. Both views share [`AccountsSnapshotCache`], so a switch in //! either shows up in the other. +//! +//! A signed-out account is marked on its row, offers "Sign in again" (the +//! engine's own login flow, driven inline here), and asks before a switch +//! lands on it — ticket 08, the same rules Settings → Accounts follows. use std::time::{Duration, Instant}; use gpui::{ Context, Entity, IntoElement, Render, SharedString, Subscription, Task, Window, div, prelude::*, px, }; -use roboco_proto::{AgentAccount, AgentAccountsSnapshot, HarnessId}; +use roboco_proto::{ + AgentAccount, AgentAccountsSnapshot, AgentLoginMode, AgentLoginStart, AgentLoginStatus, + HarnessId, +}; use roboco_rpc::methods; use crate::popover; use crate::settings::accounts::{ - self, AccountsSnapshotCache, UsageLevel, render_usage_meter, reports_usage, signs_in, - usage_color, usage_level, + self, AccountsSnapshotCache, UsageLevel, account_note, render_usage_meter, reports_usage, + signed_out, signed_out_switch_warning, signs_in, usage_color, usage_level, }; use crate::state::AppState; use crate::theme::Theme; @@ -47,6 +54,44 @@ pub fn active_account( .find(|account| account.harness == harness && account.active) } +/// The popover's inline "Sign in again" flow — the engine's own login +/// (`StartAgentLogin`), driven inside the accounts card: the browser opens, +/// the row reports progress, and a landed sign-in reloads the accounts (the +/// fresh credentials re-probe, so the row flips back to signed in). +enum SignInFlow { + /// `StartAgentLogin` in flight. + Starting { + harness: HarnessId, + provider: Option<&'static str>, + }, + /// Open the URL, poll until the browser flow lands. + Browser { + harness: HarnessId, + provider: Option<&'static str>, + start: AgentLoginStart, + error: Option, + }, + /// Claude's fallback: paste the code the page shows back here. + PasteCode { + harness: HarnessId, + provider: Option<&'static str>, + start: AgentLoginStart, + submitting: bool, + error: Option, + }, +} + +impl SignInFlow { + /// Who is being signed in to, for the progress line. + fn parts(&self) -> (HarnessId, Option<&'static str>) { + match self { + SignInFlow::Starting { harness, provider } + | SignInFlow::Browser { harness, provider, .. } + | SignInFlow::PasteCode { harness, provider, .. } => (*harness, *provider), + } + } +} + /// Loads (and switches) the accounts of the composer's target device. pub struct AccountUsage { state: Entity, @@ -59,10 +104,19 @@ pub struct AccountUsage { error: Option, load_task: Option>, action_task: Option>, + /// A signed-out account whose switch is asking first. + confirm_switch: Option, + /// The inline "Sign in again" flow, when one is running. + sign_in: Option, + sign_in_task: Option>, + sign_in_poll: Option>, + /// The paste-code field of a [`SignInFlow::PasteCode`]. + code_input: Entity, popup: popover::Popup, _poll: Task<()>, _cache: Subscription, _state: Subscription, + _code_events: Subscription, } impl AccountUsage { @@ -82,6 +136,14 @@ impl AccountUsage { } } }); + // The paste-code field of a Claude sign-in (its loopless fallback). + let code_input = + cx.new(|cx| crate::composer::ComposerInput::new("Paste the authorization code", cx)); + let code_events = cx.subscribe(&code_input, |usage: &mut Self, _, event, cx| { + if matches!(event, crate::composer::ComposerInputEvent::Submitted) { + usage.submit_sign_in_code(cx); + } + }); Self { _state: cx.observe(&state, |_, _, cx| cx.notify()), state, @@ -92,10 +154,16 @@ impl AccountUsage { error: None, load_task: None, action_task: None, + confirm_switch: None, + sign_in: None, + sign_in_task: None, + sign_in_poll: None, + code_input, popup: popover::Popup::default(), _poll: poll, // Settings → Accounts writes the same cache. _cache: cx.observe_global::(|_, cx| cx.notify()), + _code_events: code_events, } } @@ -229,6 +297,220 @@ impl AccountUsage { cx.notify(); } + // ---- "Sign in again" (the engine's own login flow, inline) ---- + + /// Start the existing login flow for a signed-out account's provider + /// group — the same `StartAgentLogin` Settings → Accounts runs. The + /// browser opens at once; the card shows the flow until it lands. + fn sign_in_again(&mut self, account: &AgentAccount, cx: &mut Context) { + let Some(engine) = + crate::request_routing::device_target(self.state.read(cx), self.target.as_deref()).ok() + else { + return; + }; + let provider = accounts::sign_in_option_for(account); + let harness = account.harness; + self.sign_in = Some(SignInFlow::Starting { harness, provider }); + self.error = None; + let mut params = serde_json::json!({ "harness": harness }); + if let (Some(provider), Some(object)) = (provider, params.as_object_mut()) { + object.insert("provider".into(), serde_json::json!(provider)); + } + let params = self.params(params); + self.sign_in_task = Some(cx.spawn(async move |this, cx| { + let result = engine.call(methods::START_AGENT_LOGIN, params).await; + this.update(cx, |usage, cx| { + match result.and_then(|value| { + serde_json::from_value::(value) + .map_err(|e| roboco_rpc::RpcError::Failed(e.to_string())) + }) { + Ok(start) => { + if !start.cli_opens_browser { + cx.open_url(&start.url); + } + match start.mode { + AgentLoginMode::PasteCode => { + usage.code_input.update(cx, |input, cx| input.set_text("", cx)); + usage.sign_in = Some(SignInFlow::PasteCode { + harness, + provider, + start, + submitting: false, + error: None, + }); + } + AgentLoginMode::Browser => { + usage.sign_in = Some(SignInFlow::Browser { + harness, + provider, + start, + error: None, + }); + usage.spawn_sign_in_poll(cx); + } + } + } + Err(err) => { + usage.sign_in = None; + usage.error = Some(format!("Login failed to start: {err}").into()); + } + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + /// Submit the pasted code of a Claude paste-code sign-in. + fn submit_sign_in_code(&mut self, cx: &mut Context) { + let Some(SignInFlow::PasteCode { + start, submitting, .. + }) = &mut self.sign_in + else { + return; + }; + if *submitting { + return; + } + let code = self.code_input.read(cx).text().trim().to_string(); + if code.is_empty() { + return; + } + let login_id = start.login_id.clone(); + *submitting = true; + let Some(engine) = + crate::request_routing::device_target(self.state.read(cx), self.target.as_deref()).ok() + else { + return; + }; + let params = self.params(serde_json::json!({ "loginId": login_id, "code": code })); + self.sign_in_task = Some(cx.spawn(async move |this, cx| { + let result = engine.call(methods::COMPLETE_AGENT_LOGIN, params).await; + this.update(cx, |usage, cx| { + match result { + Ok(_) => usage.sign_in_landed(cx), + Err(err) => { + if let Some(SignInFlow::PasteCode { + submitting, error, .. + }) = &mut usage.sign_in + { + *submitting = false; + *error = Some(err.to_string().into()); + } + } + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + /// Poll a browser sign-in until it lands (or fails). + fn spawn_sign_in_poll(&mut self, cx: &mut Context) { + let Some(SignInFlow::Browser { start, .. }) = &self.sign_in else { + return; + }; + let login_id = start.login_id.clone(); + let Some(engine) = + crate::request_routing::device_target(self.state.read(cx), self.target.as_deref()).ok() + else { + return; + }; + let params = self.params(serde_json::json!({ "loginId": login_id })); + self.sign_in_poll = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(1500)) + .await; + let result = engine.call(methods::POLL_AGENT_LOGIN, params.clone()).await; + let outcome = this + .update(cx, |usage, cx| { + let Some(SignInFlow::Browser { error, .. }) = &mut usage.sign_in else { + return true; // flow closed — stop polling + }; + match result + .as_ref() + .ok() + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + { + Some(poll) => match poll.status { + AgentLoginStatus::Done => { + usage.sign_in_landed(cx); + true + } + AgentLoginStatus::Error => { + *error = Some( + poll.message + .unwrap_or_else(|| "Login failed".to_string()) + .into(), + ); + cx.notify(); + true + } + AgentLoginStatus::Pending => false, + }, + None => { + *error = Some( + match &result { + Err(err) => format!("Poll failed: {err}"), + Ok(_) => "Poll failed: malformed reply".to_string(), + } + .into(), + ); + cx.notify(); + true + } + } + }) + .unwrap_or(true); + if outcome { + break; + } + } + })); + cx.notify(); + } + + /// A sign-in landed: close the flow and re-probe — fresh credentials + /// lift the engine's freshness gate, so the row flips back to signed in. + fn sign_in_landed(&mut self, cx: &mut Context) { + self.sign_in = None; + self.sign_in_poll = None; + self.last_forced = None; + self.load(true, cx); + } + + /// Cancel the inline sign-in (the engine kills the flow; the CLI child + /// and its throwaway home go with it). + fn cancel_sign_in(&mut self, cx: &mut Context) { + let login_id = match &self.sign_in { + Some(SignInFlow::Browser { start, .. }) + | Some(SignInFlow::PasteCode { start, .. }) => start.login_id.clone(), + _ => None, + }; + self.sign_in = None; + self.sign_in_poll = None; + self.sign_in_task = None; + if let (Some(login_id), Some(engine)) = ( + login_id, + crate::request_routing::device_target( + self.state.read(cx), + self.target.as_deref(), + ) + .ok(), + ) { + let params = self.params(serde_json::json!({ "loginId": login_id })); + self.action_task = Some(cx.spawn(async move |_, _| { + if let Err(err) = engine.call(methods::CANCEL_AGENT_LOGIN, params).await { + tracing::debug!(error = %err, "CancelAgentLogin failed (best-effort)"); + } + })); + } + cx.notify(); + } + /// The ring's reading: the live account's most-used window, when the /// harness has a live account with usage to show. fn fraction(&self, cx: &gpui::App) -> Option { @@ -319,6 +601,7 @@ impl AccountUsage { .unwrap_or_else(|| "Unknown account".into()) .into(); let can_switch = !account.active && account.switchable; + let is_signed_out = signed_out(account); let mut meta: Vec = account .plan_label .iter() @@ -335,20 +618,45 @@ impl AccountUsage { .child(SharedString::from("In use")) .into_any_element(), ); - } else if let Some(reason) = account - .usage_error - .clone() - .filter(|_| account.usage_windows.is_empty()) - { - meta.push(div().child(SharedString::from(reason)).into_any_element()); + } + if is_signed_out { + // The mark that keeps a dead login from reading healthy. + meta.push( + div() + .text_color(theme.danger) + .child(SharedString::from("Signed out")) + .into_any_element(), + ); + } + // The probe's reason rides the meta line EVEN WHEN stale + // meters are shown — cached meters used to hide the sign-out + // (the ticket's display bug). + if let Some(reason) = account_note(account) { + meta.push( + div() + .text_color(if is_signed_out { + theme.danger_muted + } else { + theme.text_muted + }) + .child(SharedString::from(reason)) + .into_any_element(), + ); } let switch_to = account.clone(); + let sign_in_to = account.clone(); popover::menu_row(theme, account.active, format!("account-usage-row-{ix}")) .id(("account-usage-row", ix)) .when(!can_switch, |row| row.cursor_default()) .when(can_switch, |row| { row.on_click(cx.listener(move |usage, _, _, cx| { - usage.switch(&switch_to, cx); + // Switching to a signed-out account asks first. + if signed_out(&switch_to) { + usage.confirm_switch = Some(switch_to.clone()); + cx.notify(); + } else { + usage.switch(&switch_to, cx); + } })) }) .child( @@ -367,15 +675,46 @@ impl AccountUsage { }), ) .child( - div().flex_none().flex().flex_col().gap(px(2.0)).children( - account - .usage_windows - .iter() - .take(2) - .map(|window| render_usage_meter(window, theme)), - ), + div() + .flex_none() + .flex() + .flex_col() + .gap(px(2.0)) + .children( + account + .usage_windows + .iter() + .take(2) + .map(|window| render_usage_meter(window, theme)), + ) + // The signed-out row's own action, nested so the + // row click (the switch confirm) still works. + .when(is_signed_out, |el| { + el.child( + div() + .id(("account-usage-sign-in", ix)) + .mt(px(2.0)) + .px(px(8.0)) + .py(px(3.0)) + .rounded(px(6.0)) + .bg(theme.accent.opacity(0.14)) + .text_size(px(11.5)) + .text_color(theme.accent) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _, _, cx| cx.stop_propagation()), + ) + .on_click(cx.listener(move |usage, _, _, cx| { + cx.stop_propagation(); + usage.sign_in_again(&sign_in_to, cx); + })) + .child(SharedString::from("Sign in again")), + ) + }), ) })) + .children(self.sign_in_footer(cx)) .children(self.error.clone().map(|error| { div() .px(px(8.0)) @@ -385,6 +724,133 @@ impl AccountUsage { .child(error) })) } + + /// The inline "Sign in again" progress: who is being signed in to, the + /// paste-code field when the CLI has no loopback, and its failure. + fn sign_in_footer(&self, cx: &mut Context) -> Option { + let theme = &Theme::of(cx).for_popup(); + let (harness, provider) = self.sign_in.as_ref()?.parts(); + let who = provider + .and_then(|key| { + accounts::login_options(harness) + .into_iter() + .find(|option| option.provider == Some(key)) + .map(|option| option.label.to_string()) + }) + .unwrap_or_else(|| accounts::provider_name(harness).to_string()); + let mut card = div() + .mx(px(8.0)) + .my(px(4.0)) + .px(px(8.0)) + .py(px(6.0)) + .rounded(px(6.0)) + .flex() + .flex_col() + .gap(px(4.0)) + .bg(theme.wash(0.06)) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .flex_1() + .truncate() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child(SharedString::from(format!( + "Signing in to {who} — finish in your browser" + ))), + ) + .child( + div() + .id("account-usage-sign-in-cancel") + .px(px(8.0)) + .py(px(3.0)) + .rounded(px(6.0)) + .text_size(px(11.5)) + .text_color(theme.text_muted) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _, _, cx| cx.stop_propagation()), + ) + .on_click( + cx.listener(|usage, _, _, cx| usage.cancel_sign_in(cx)), + ) + .child(SharedString::from("Cancel")), + ), + ); + match self.sign_in.as_ref()? { + SignInFlow::Starting { .. } | SignInFlow::Browser { .. } => {} + SignInFlow::PasteCode { + start, + submitting, + error, + .. + } => { + let open_url = start.url.clone(); + card = card + .child( + div() + .id("account-usage-sign-in-url") + .text_size(px(11.5)) + .text_color(theme.text_muted) + .cursor_pointer() + .hover(|s| s.text_color(theme.text)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _, _, cx| cx.stop_propagation()), + ) + .on_click(cx.listener(move |_, _, _, cx| { + cx.open_url(&open_url); + })) + .child(SharedString::from("Reopen the authorization page")), + ) + .child( + div() + .px(px(2.0)) + .child(self.code_input.clone().into_any_element()), + ) + .children(error.clone().map(|error| { + div() + .text_size(px(11.5)) + .text_color(theme.danger) + .child(error) + })) + .child( + div().flex().flex_row().justify_end().child( + div() + .id("account-usage-sign-in-submit") + .px(px(8.0)) + .py(px(3.0)) + .rounded(px(6.0)) + .bg(theme.accent.opacity(0.14)) + .text_size(px(11.5)) + .text_color(theme.accent) + .cursor_pointer() + .when(*submitting, |el| el.opacity(0.5)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _, _, cx| cx.stop_propagation()), + ) + .on_click(cx.listener(|usage, _, _, cx| { + cx.stop_propagation(); + usage.submit_sign_in_code(cx); + })) + .child(SharedString::from(if *submitting { + "Verifying…" + } else { + "Add account" + })), + ), + ); + } + } + Some(card) + } } /// Which footer ring's popover is up. @@ -406,9 +872,10 @@ impl FooterCard { /// The footer's ring cluster: account usage (accent arc, so the two read /// apart), then context occupancy. Each opens its popover on click. impl Render for AccountUsage { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).clone(); let context = self.state.read(cx).context_usage; + let confirm = self.render_switch_confirm(window.viewport_size(), cx); let account = self.fraction(cx).map(|fraction| { let level = usage_level(fraction); let chip = crate::context_usage::ring_chip( @@ -449,6 +916,56 @@ impl Render for AccountUsage { .gap(px(4.0)) .children(account) .children(context) + .when_some(confirm, |el, confirm| el.child(confirm)) + } + + /// The "switch to a signed-out account" confirmation, over the popover — + /// the same warning Settings → Accounts asks with. + fn render_switch_confirm( + &mut self, + viewport: gpui::Size, + cx: &mut Context, + ) -> Option { + let theme = Theme::of(cx).for_popup(); + let account = self.confirm_switch.clone()?; + let warning = signed_out_switch_warning(&account); + let card = popover::dialog_card(&theme) + .w(px(400.0)) + .gap(px(12.0)) + .child(popover::dialog_title(&theme, "Switch to a signed-out account?")) + .child(popover::dialog_body(&theme, warning)) + .child( + div() + .mt(px(4.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "usage-switch-sign-out-cancel") + .id("usage-switch-sign-out-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_switch = None; + cx.notify(); + })), + ) + .child( + popover::btn_primary(&theme, "Switch anyway") + .id("usage-switch-sign-out-confirm") + .on_click(cx.listener(move |this, _, _, cx| { + let account = this.confirm_switch.take(); + if let Some(account) = account { + this.switch(&account, cx); + } + cx.notify(); + })), + ), + ); + Some(popover::modal( + "account-usage-switch-confirm", + viewport, + card.into_any_element(), + )) } } @@ -506,4 +1023,28 @@ mod tests { ); assert!(active_account(&snapshot, HarnessId::Cursor).is_none()); } + + /// A signed-out account parses off the wire, is marked, and explains + /// itself BESIDE its cached meters — the popover used to show the reason + /// only when no windows existed, hiding a sign-out behind stale meters. + #[test] + fn signed_out_accounts_read_from_the_wire_and_show_their_reason() { + let mut account = account(HarnessId::ClaudeCode, false, &[0.4]); + assert!(!signed_out(&account), "a wire payload without the field is unknown"); + account.sign_in_state = roboco_proto::AgentSignInState::SignedOut; + assert!(signed_out(&account)); + assert_eq!( + account_note(&account).as_deref(), + Some("Signed out — sign in again"), + "the note exists even without a probe failure behind it" + ); + // The reason shows WITH the meters — the display fix. + assert!(!account.usage_windows.is_empty()); + // A probe failure is the note whatever the state. + account.usage_error = Some("Rate limited by Anthropic — retrying in 2m".into()); + assert_eq!( + account_note(&account).as_deref(), + Some("Rate limited by Anthropic — retrying in 2m") + ); + } } diff --git a/crates/ui/src/settings/accounts.rs b/crates/ui/src/settings/accounts.rs index 9cb9561674..c265959976 100644 --- a/crates/ui/src/settings/accounts.rs +++ b/crates/ui/src/settings/accounts.rs @@ -18,7 +18,7 @@ use std::time::Duration; use roboco_proto::{ AgentAccount, AgentAccountsSnapshot, AgentLoginMode, AgentLoginPoll, AgentLoginStart, - AgentLoginStatus, HarnessId, + AgentLoginStatus, AgentSignInState, HarnessId, }; use roboco_rpc::methods; @@ -248,6 +248,16 @@ pub fn add_option_label(option: LoginOption, empty: bool) -> String { } } +/// The model provider a "Sign in again" for `account` signs in to: its own +/// provider group for per-provider agents, the agent's only login otherwise. +/// Pure. +pub(crate) fn sign_in_option_for(account: &AgentAccount) -> Option<&'static str> { + login_options(account.harness) + .into_iter() + .find(|option| option.provider == account.provider.as_deref()) + .and_then(|option| option.provider) +} + /// The optimistic half of a switch: `account` becomes the live login of its /// group — its agent, or for agents that keep a login per model provider, /// that provider — and every other group keeps its own. Pure. @@ -259,6 +269,60 @@ pub fn mark_switched(snapshot: &mut AgentAccountsSnapshot, account: &AgentAccoun } } +// --------------------------------------------------------------------------- +// Pure: sign-in state (ticket 08) +// --------------------------------------------------------------------------- + +/// Whether the account is KNOWN to be signed out — a hard rejection (the +/// slot's refresh was refused, a revoked key) or a run's signed-out error +/// marked it. Ambiguous "session expired" and never-probed accounts are +/// `Unknown`, not signed out. Pure. +pub fn signed_out(account: &AgentAccount) -> bool { + account.sign_in_state == AgentSignInState::SignedOut +} + +/// The reason line an account row shows: the last probe's failure, or — +/// when a run error marked the account signed out with no probe behind it — +/// the sign-out itself. `None` when the account has nothing to complain +/// about. Shown BESIDE stale meters, never only when they are gone (a +/// cached meter used to hide the sign-out). Pure. +pub fn account_note(account: &AgentAccount) -> Option { + account + .usage_error + .clone() + .or_else(|| signed_out(account).then(|| "Signed out — sign in again".to_string())) +} + +/// The warning a switch to a signed-out account asks about first: the +/// account may not work until it is signed in again. Pure. +pub fn signed_out_switch_warning(account: &AgentAccount) -> String { + let who = account + .email + .clone() + .or_else(|| account.display_name.clone()) + .unwrap_or_else(|| "This account".into()); + format!( + "{who} is signed out. Switching to it now may not work — sign in \ + again first, or switch anyway and sign in after." + ) +} + +/// The red-tinged "Signed out" pill a signed-out row carries — the mark +/// that keeps a dead login from reading healthy beside green meters. +pub(crate) fn badge_signed_out(theme: &Theme) -> gpui::Div { + let danger = theme.danger; + let danger_text = theme.danger_muted; + div() + .flex_none() + .px(px(8.0)) + .py(px(2.0)) + .rounded_full() + .bg(danger.opacity(0.12)) + .text_size(crate::typography::ui_rems(10.5)) + .text_color(danger_text.opacity(0.9)) + .child(SharedString::from("Signed out")) +} + /// Usage meter columns (upstream accounts.rs): window label, a short bar, /// percent — the compact one-line meter the footer's account-usage card /// renders (the page's rows keep the pre-redesign meter until ticket 18). @@ -462,6 +526,9 @@ pub struct AccountsPage { snapshot: Loadable, /// Account id with an in-flight Switch/Forget. busy_account: Option, + /// A signed-out account whose Switch is asking for confirmation before + /// it becomes the CLI's login. + confirm_switch: Option, login: Option, error: Option, code_input: Entity, @@ -488,6 +555,7 @@ impl AccountsPage { device_menu: popover::Popup::default(), snapshot: Loadable::Idle, busy_account: None, + confirm_switch: None, login: None, error: None, code_input, @@ -528,6 +596,7 @@ impl AccountsPage { // device's cache is cold). self.login = None; self.busy_account = None; + self.confirm_switch = None; self.error = None; self.load(force_usage_for(LoadTrigger::Mount), cx); } @@ -1023,6 +1092,11 @@ impl AccountsPage { /// Escape that reached Settings unclaimed cancels an open login first, /// so it never closes Settings under the dialog. Returns whether it did. pub(crate) fn dismiss_on_escape(&mut self, cx: &mut Context) -> bool { + if self.confirm_switch.is_some() { + self.confirm_switch = None; + cx.notify(); + return true; + } if self.login.is_none() { return false; } @@ -1133,6 +1207,10 @@ impl AccountsPage { .into(); let switch_account = account.clone(); let forget_account = account.clone(); + let sign_in_account = account.clone(); + // A signed-out account — marked, offered "Sign in again", and asked + // about before a Switch lands on it. + let is_signed_out = signed_out(account); let badges = div() .flex() @@ -1144,38 +1222,48 @@ impl AccountsPage { }) .when_some(account.plan_label.clone(), |el, plan| { el.child(widgets::badge(theme, plan)) - }); + }) + .when(is_signed_out, |el| el.child(badge_signed_out(theme))); - // Actions only on INACTIVE accounts (roboco `{!account.active && …}`): + // Actions on INACTIVE accounts (roboco `{!account.active && …}`): // an icon-only Forget (trash, hover → foreground) then Switch, which - // reads "Switching…" while the activate round-trips. - let actions: Option = (!account.active).then(|| { + // reads "Switching…" while the activate round-trips — plus "Sign in + // again" on any SIGNED-OUT row (active or not): it re-runs the + // provider's login flow for this account's provider group. + let actions: Option = + ((!account.active) || is_signed_out).then(|| { div() .flex() .flex_row() .items_center() .gap(px(4.0)) - .child( - div() - .id(("account-forget", ix)) - .rounded(px(6.0)) - .px(px(6.0)) - .py(px(4.0)) - .text_color(theme.text_muted) - .cursor_pointer() - .when(is_busy, |el| el.opacity(0.5)) - .hover(|s| s.bg(crate::theme::ink(0.06)).text_color(theme.text)) - .tooltip(widgets::text_tooltip("Forget account")) - .on_click(cx.listener(move |this, _, _, cx| { - this.account_action(methods::FORGET_AGENT_ACCOUNT, &forget_account, cx); - })) - .child( - crate::icons::icon(crate::icons::TRASH_BIN_MINIMALISTIC) - .size(px(14.0)) - .text_color(theme.text_muted), - ), - ) - .when(account.switchable, |el| { + .when(!account.active, |el| { + el.child( + div() + .id(("account-forget", ix)) + .rounded(px(6.0)) + .px(px(6.0)) + .py(px(4.0)) + .text_color(theme.text_muted) + .cursor_pointer() + .when(is_busy, |el| el.opacity(0.5)) + .hover(|s| s.bg(crate::theme::ink(0.06)).text_color(theme.text)) + .tooltip(widgets::text_tooltip("Forget account")) + .on_click(cx.listener(move |this, _, _, cx| { + this.account_action( + methods::FORGET_AGENT_ACCOUNT, + &forget_account, + cx, + ); + })) + .child( + crate::icons::icon(crate::icons::TRASH_BIN_MINIMALISTIC) + .size(px(14.0)) + .text_color(theme.text_muted), + ), + ) + }) + .when(!account.active && account.switchable, |el| { el.child( crate::popover::btn_primary( theme, @@ -1188,14 +1276,39 @@ impl AccountsPage { .text_size(crate::typography::ui_rems(11.5)) .when(is_busy, |el| el.opacity(0.5)) .on_click(cx.listener(move |this, _, _, cx| { - this.account_action( - methods::ACTIVATE_AGENT_ACCOUNT, - &switch_account, - cx, - ); + // A signed-out switch asks first: the account may + // not work until it is signed in again. + if signed_out(&switch_account) { + this.confirm_switch = Some(switch_account.clone()); + cx.notify(); + } else { + this.account_action( + methods::ACTIVATE_AGENT_ACCOUNT, + &switch_account, + cx, + ); + } })), ) }) + .when(is_signed_out, |el| { + el.child( + crate::popover::btn_primary(theme, "Sign in again") + .id(("account-sign-in-again", ix)) + .px(px(8.0)) + .py(px(4.0)) + .rounded(px(6.0)) + .text_size(crate::typography::ui_rems(11.5)) + .when(is_busy, |el| el.opacity(0.5)) + .on_click(cx.listener(move |this, _, _, cx| { + this.start_login( + sign_in_account.harness, + sign_in_option_for(&sign_in_account), + cx, + ); + })), + ) + }) }); div() @@ -1235,41 +1348,63 @@ impl AccountsPage { // Meters XOR the quiet fallback line — never both // (roboco: `usage ? meters : "Usage unavailable"…`). // The engine's probe reason replaces the bare shrug; - // an unidentified login says so. + // an unidentified login says so. A SIGNED-OUT account + // keeps its reason beside the meters (the display + // fix: stale meters used to hide the sign-out). if account.usage_windows.is_empty() { let note = if !account.switchable && switches_accounts(account.harness) { match account.harness { // Keychain denied: the login is there, its secret isn't. - HarnessId::ClaudeCode => "Credentials unavailable", + HarnessId::ClaudeCode => "Credentials unavailable".to_string(), // An opaque token whose account couldn't be looked up. - _ => "Couldn't identify this login", + _ => "Couldn't identify this login".to_string(), } - } else if let Some(reason) = &account.usage_error { + } else if let Some(reason) = account_note(account) { // "Rate limited by Anthropic — retrying in 2m", // "Signed out — sign in again". - reason.as_str() + reason } else if refreshing { - "Checking usage…" + "Checking usage…".to_string() } else { - "Usage unavailable" + "Usage unavailable".to_string() }; el.child( div() .mt(px(6.0)) .truncate() .text_size(crate::typography::ui_rems(11.5)) - .text_color(theme.text_muted.opacity(0.6)) + .text_color(if is_signed_out { + theme.danger_muted.opacity(0.85) + } else { + theme.text_muted.opacity(0.6) + }) .child(SharedString::from(note)), ) } else { el.child( - div().mt(px(6.0)).flex().flex_col().gap(px(4.0)).children( - account - .usage_windows - .iter() - .map(|w| self.render_usage_meter(w, theme, now)), - ), + div().mt(px(6.0)).flex().flex_col().gap(px(4.0)) + .children( + account + .usage_windows + .iter() + .map(|w| self.render_usage_meter(w, theme, now)), + ) + // The reason line rides BELOW the meters, + // so a cached meter no longer hides it. + .when_some(account_note(account), |el, reason| { + el.child( + div() + .truncate() + .text_size(crate::typography::ui_rems(11.5)) + .text_color(if is_signed_out { + theme.danger_muted.opacity(0.85) + } else { + theme.text_muted.opacity(0.6) + }) + .child(SharedString::from(reason)), + ) + }), ) } }), @@ -1288,6 +1423,60 @@ impl AccountsPage { .into_any_element() } + /// The "switch to a signed-out account" confirmation: Switch asks before + /// making a dead login the CLI's own. Confirm runs the activate; Cancel + /// (or Escape via [`Self::dismiss_on_escape`]) keeps the current account. + fn render_switch_confirm( + &mut self, + viewport: gpui::Size, + cx: &mut Context, + ) -> Option { + let theme = Theme::of(cx).for_popup(); + let account = self.confirm_switch.clone()?; + let warning = signed_out_switch_warning(&account); + let card = popover::dialog_card(&theme) + .w(px(400.0)) + .gap(px(12.0)) + .child(popover::dialog_title(&theme, "Switch to a signed-out account?")) + .child(popover::dialog_body(&theme, warning)) + .child( + div() + .mt(px(4.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "switch-sign-out-cancel") + .id("switch-sign-out-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_switch = None; + cx.notify(); + })), + ) + .child( + popover::btn_primary(&theme, "Switch anyway") + .id("switch-sign-out-confirm") + .on_click(cx.listener(move |this, _, _, cx| { + let account = this.confirm_switch.take(); + if let Some(account) = account { + this.account_action( + methods::ACTIVATE_AGENT_ACCOUNT, + &account, + cx, + ); + } + cx.notify(); + })), + ), + ); + Some(popover::modal( + "account-switch-confirm", + viewport, + card.into_any_element(), + )) + } + fn render_login_dialog( &mut self, viewport: gpui::Size, @@ -1565,7 +1754,9 @@ impl Render for AccountsPage { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::of(cx).for_settings_surface(); let now = Utc::now(); - let dialog = self.render_login_dialog(window.viewport_size(), cx); + let viewport = window.viewport_size(); + let confirm = self.render_switch_confirm(viewport, cx); + let dialog = self.render_login_dialog(viewport, cx); let refreshing = matches!(self.snapshot, Loadable::Loading); let account_count = self .snapshot @@ -1897,6 +2088,7 @@ impl Render for AccountsPage { ) .children(scrollbar) .when_some(dialog, |el, dialog| el.child(dialog)) + .when_some(confirm, |el, confirm| el.child(confirm)) } } @@ -1933,6 +2125,8 @@ mod tests { }], usage_fetched_at: None, usage_error: None, + sign_in_state: AgentSignInState::Unknown, + limited_until: None, display_name: None, organization: None, auth_kind: None, @@ -2056,6 +2250,8 @@ mod tests { usage_windows: vec![], usage_fetched_at: None, usage_error: None, + sign_in_state: AgentSignInState::Unknown, + limited_until: None, display_name: None, organization: None, auth_kind: None, @@ -2161,6 +2357,8 @@ mod tests { usage_windows: vec![], usage_fetched_at: None, usage_error: None, + sign_in_state: AgentSignInState::Unknown, + limited_until: None, display_name: None, organization: None, auth_kind: None, @@ -2217,4 +2415,125 @@ mod tests { error: None, } } + + // ---- sign-in state (ticket 08) ---- + + /// A harness account with the given sign-in state — the wire parses all + /// three states, and a payload from before the field existed is unknown. + fn state_account(sign_in_state: AgentSignInState) -> AgentAccount { + serde_json::from_value(serde_json::json!({ + "id": "a1", + "harness": "claude-code", + "email": "user@example.com", + "planLabel": null, + "active": false, + "usageWindows": [], + "switchable": true, + "signInState": sign_in_state, + })) + .unwrap() + } + + #[test] + fn only_a_signed_out_state_marks_the_account() { + assert!(!signed_out(&state_account(AgentSignInState::Unknown))); + assert!(!signed_out(&state_account(AgentSignInState::SignedIn))); + assert!(signed_out(&state_account(AgentSignInState::SignedOut))); + // A payload from before the field existed is unknown, never marked. + let legacy: AgentAccount = serde_json::from_value(serde_json::json!({ + "id": "a1", + "harness": "claude-code", + "email": null, + "planLabel": null, + "active": true, + "usageWindows": [], + "switchable": true, + })) + .unwrap(); + assert!(!signed_out(&legacy)); + } + + /// The reason line survives cached meters and exists even when no probe + /// failed (a run error marked the account signed out). + #[test] + fn the_note_shows_beside_meters_and_covers_run_error_marks() { + // A probe failure is the reason, whatever the state. + let mut account = state_account(AgentSignInState::SignedIn); + account.usage_error = Some("Rate limited by Anthropic — retrying in 2m".into()); + assert_eq!( + account_note(&account).as_deref(), + Some("Rate limited by Anthropic — retrying in 2m") + ); + // A signed-out account with no probe behind it still explains itself. + let account = state_account(AgentSignInState::SignedOut); + assert_eq!( + account_note(&account).as_deref(), + Some("Signed out — sign in again") + ); + // A healthy account has nothing to say. + assert_eq!(account_note(&state_account(AgentSignInState::SignedIn)), None); + assert_eq!(account_note(&state_account(AgentSignInState::Unknown)), None); + } + + #[test] + fn the_switch_warning_names_the_signed_out_account() { + let account = state_account(AgentSignInState::SignedOut); + assert!(signed_out_switch_warning(&account).contains("user@example.com is signed out")); + let mut unnamed = state_account(AgentSignInState::SignedOut); + unnamed.email = None; + unnamed.display_name = None; + assert!(signed_out_switch_warning(&unnamed).starts_with("This account is signed out")); + } + + /// "Sign in again" signs in to the account's own provider group, or the + /// agent's only login when it isn't a per-provider one. + #[test] + fn sign_in_again_targets_the_accounts_provider_group() { + let mut opencode = state_account(AgentSignInState::SignedOut); + opencode.harness = HarnessId::Opencode; + opencode.provider = Some("openai".into()); + assert_eq!(sign_in_option_for(&opencode), Some("openai")); + opencode.provider = Some("github-copilot".into()); + assert_eq!(sign_in_option_for(&opencode), Some("github-copilot")); + // Single-login agents: the agent's own (only) sign-in. + let claude = state_account(AgentSignInState::SignedOut); + assert_eq!(sign_in_option_for(&claude), None); + assert_eq!(claude.provider, None); + } + + /// A signed-out row renders: the mark beside the meters, the reason under + /// them, and the Sign in again action — even on the ACTIVE account (the + /// common run-error case). Draw-level smoke; the state transitions are + /// the pure tests above. + #[gpui::test] + fn a_signed_out_row_renders_its_mark_and_reason(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + }); + let window = cx.add_window(|window, cx| { + let state = cx.new(|_| AppState::new()); + AccountsPage::new(state, cx) + }); + // Active, signed out, with STALE meters — the display-bug shape. + let mut account = state_account(AgentSignInState::SignedOut); + account.active = true; + account.usage_windows = vec![roboco_proto::AgentUsageWindow { + label: "Session".into(), + used_fraction: 0.4, + resets_at: None, + }]; + account.usage_error = Some("Signed out — sign in again".into()); + window + .update(cx, |page, _, cx| { + page.snapshot = Loadable::Ready(AgentAccountsSnapshot { + accounts: vec![account], + warnings: vec![], + }); + assert!(page.confirm_switch.is_none()); + }) + .unwrap(); + cx.update_window(window.into(), |_, window, cx| window.draw(cx).clear()) + .unwrap(); + } } diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index dfab1ab9fb..562691a2bc 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -188,6 +188,7 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::InstallHarnessRequest, roboco_proto::AgentAccount, roboco_proto::AgentAuthKind, + roboco_proto::AgentSignInState, roboco_proto::AgentAccountsSnapshot, roboco_proto::AgentAccountWarning, roboco_proto::AgentLoginStart, diff --git a/web/packages/app/tests/account-row.test.ts b/web/packages/app/tests/account-row.test.ts index 6b2db037ab..b449c74a0b 100644 --- a/web/packages/app/tests/account-row.test.ts +++ b/web/packages/app/tests/account-row.test.ts @@ -515,6 +515,7 @@ describe("AccountRow (settings) — the Forget affordance (wpn-09)", () => { active: false, usageWindows: [], switchable: true, + signInState: "unknown", ...fields, }; } diff --git a/web/packages/app/tests/account-usage.test.ts b/web/packages/app/tests/account-usage.test.ts index d1a20e1d5d..c46fa0223e 100644 --- a/web/packages/app/tests/account-usage.test.ts +++ b/web/packages/app/tests/account-usage.test.ts @@ -148,6 +148,7 @@ function account( email: null, planLabel: null, usageWindows: [], + signInState: "unknown", ...fields, }; } diff --git a/web/packages/app/tests/accounts-view.test.ts b/web/packages/app/tests/accounts-view.test.ts index 91c7adbbcc..31f0749a84 100644 --- a/web/packages/app/tests/accounts-view.test.ts +++ b/web/packages/app/tests/accounts-view.test.ts @@ -164,6 +164,7 @@ function account(fields: Partial): AgentAccount { active: false, usageWindows: [], switchable: true, + signInState: "unknown", ...fields, }; } diff --git a/web/packages/proto/src/generated/AgentAccount.ts b/web/packages/proto/src/generated/AgentAccount.ts index d3835765c3..f77911f52e 100644 --- a/web/packages/proto/src/generated/AgentAccount.ts +++ b/web/packages/proto/src/generated/AgentAccount.ts @@ -2,6 +2,7 @@ // Legacy wire name (ADR 0005): the canonical term is "Harness account" — see CONTEXT.md. import type { AgentAuthKind } from "./AgentAuthKind"; +import type { AgentSignInState } from "./AgentSignInState"; import type { AgentUsageWindow } from "./AgentUsageWindow"; import type { HarnessId } from "./HarnessId"; @@ -17,7 +18,22 @@ usageFetchedAt?: number | null, * "Sign in again", …), shown instead of a bare "Usage unavailable" — or * beside stale windows. `None` when the last probe succeeded or none ran. */ -usageError?: string | null, displayName?: string | null, organization?: string | null, +usageError?: string | null, +/** + * Whether the login is known to work: `SignedIn` after the last usage + * probe (or sign-in) succeeded, `SignedOut` after a hard rejection (a + * refused refresh, a signed-out run error). Ambiguous "session expired" + * outcomes never claim `SignedOut` — they stay `Unknown`, as does an + * account never probed. Additive (ADR 0005): older payloads default to + * `Unknown`. + */ +signInState: AgentSignInState, +/** + * The latest reset among the account's exhausted (100% used) windows — + * when it is in the future, the account is still limited. `None` when + * no window is exhausted (or none knows its reset). Additive (ADR 0005). + */ +limitedUntil?: string | null, displayName?: string | null, organization?: string | null, /** * How the CLI is signed in (`oauth` account vs raw `api-key`). */ diff --git a/web/packages/proto/src/generated/AgentSignInState.ts b/web/packages/proto/src/generated/AgentSignInState.ts new file mode 100644 index 0000000000..4ca31a7a7a --- /dev/null +++ b/web/packages/proto/src/generated/AgentSignInState.ts @@ -0,0 +1,12 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * Whether a harness account's login is known to work. `Unknown` is a real + * state, not an absence: an account that was never probed, or whose last + * probe was ambiguous (an expired token the CLI refreshes itself, a + * rate-limited probe), is `Unknown` — never `SignedOut`. The engine flips + * the state only on hard evidence: a successful probe or sign-in → + * `SignedIn`, a hard rejection (refused refresh, signed-out run error) → + * `SignedOut`. + */ +export type AgentSignInState = "signed-in" | "signed-out" | "unknown"; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 1774ec3248..d807db9a2e 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -9,6 +9,7 @@ export * from "./AgentLoginMode"; export * from "./AgentLoginPoll"; export * from "./AgentLoginStart"; export * from "./AgentLoginStatus"; +export * from "./AgentSignInState"; export * from "./AgentUsageWindow"; export * from "./AttachmentChunk"; export * from "./AttachmentTransfer"; From 81d545ea558e4844be81245bc22d0736f0212e64 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 16:21:56 +0700 Subject: [PATCH 16/96] fix(codex): format the fork fallback prompt positionally --- crates/harness/src/codex/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index 9b649014b0..382808725f 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -1243,7 +1243,7 @@ async fn run_session(session: Session) { // still governs native-command routing below (a native command never // carries a fallback — the engine attaches none). let delivered_prompt = match &fork_fallback { - Some(history) => format!("{history}{request.prompt}"), + Some(history) => format!("{history}{}", request.prompt), None => request.prompt.clone(), }; // Model, effort and service tier ride every `turn/start` (each sticks From de63147941e3cc12e608697b062cd431e869459e Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 16:22:10 +0700 Subject: [PATCH 17/96] feat(engine): wake an idle parent when a side chat turn ends Completion notices, the tracer bullet (orchestration v2 ticket 12): - a turn of an agent-created, notifying side chat is notifiable when its starting message or a steer within it came from the parent: the run handle carries the notifiable parent, armed at dispatch/steer time from the durable Chat.agentOrigin row (notify: false opts out; user-created side chats have no row and never notify; a user message starting a turn resets the claim, a mid-turn parent steer arms it); - when a notifiable turn ends, the engine records an outcome from its own turn-end state - completed (parked session, quiesce watchdog or final Done, gated on the completion marker), errored with a one-line reason, stopped, or waiting for input with a one-line summary of the question - and wakes an idle parent with an engine-authored notice message that starts a parent turn on its own harness; a busy parent is left to ticket 13 (the notice is never steered mid-turn); - the notice is a SYSTEM entry, never a user message, carrying a new MessagePart::CompletionNotice marker (per side chat: title, full id, outcome, one-line detail) with the notice text as a plain sibling part so older layouts stay readable; the text names each side chat by title and short id and points the agent at read_chat for the full result; - wiregen: new NoticeOutcome / NoticeSide types (additive, ADR 0005), hand-generated TS alongside; ui-tests.yml's engine step now runs --test completion_notices; - base repairs from ticket 11's fallout so the crate compiles: the two 5-arg dispatch_with_source_context calls in sessions.rs (auto-resume now threads the revived user entry's origin; the orphaned-steer re-dispatch keeps the steer's origin via RoutedSteer.origin_chat_id) and the transcript_budget test helper's missing origin_chat_id. --- .github/workflows/ui-tests.yml | 2 +- crates/doc/src/parts.rs | 59 +- crates/doc/src/schema.rs | 93 +- crates/engine/src/completion_notices.rs | 449 ++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/sessions.rs | 241 ++++- crates/engine/src/transcript_budget.rs | 8 +- crates/engine/tests/completion_notices.rs | 833 ++++++++++++++++++ crates/roboco-mcp/src/transcript.rs | 8 +- crates/ui/src/transcript.rs | 5 + crates/wiregen/src/main.rs | 2 + .../proto/src/generated/MessagePart.ts | 10 +- .../proto/src/generated/NoticeOutcome.ts | 7 + .../proto/src/generated/NoticeSide.ts | 26 + web/packages/proto/src/generated/index.ts | 2 + 15 files changed, 1729 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/completion_notices.rs create mode 100644 crates/engine/tests/completion_notices.rs create mode 100644 web/packages/proto/src/generated/NoticeOutcome.ts create mode 100644 web/packages/proto/src/generated/NoticeSide.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index b989ef0af1..460adbd9d7 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test completion_notices # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 3d7c1ef223..9f736b1b01 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -120,6 +120,36 @@ pub enum SubagentStatus { Failed, } +/// What the engine recorded when a notifiable side-chat turn came to rest +/// (orchestration v2). The outcome of one row of a completion notice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub enum NoticeOutcome { + Completed, + Errored, + Stopped, + WaitingForInput, +} + +/// One side chat a completion notice reports: its id and title, the +/// recorded outcome, and the one-line reason (error text or question +/// summary) when the outcome carries one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct NoticeSide { + /// The side chat's full id — clients open the chat with it; the notice's + /// text names the short form. + pub chat_id: String, + /// The side chat's title as of the notice, so the transcript reads it + /// without a registry lookup (a later rename never rewrites history). + pub title: String, + pub outcome: NoticeOutcome, + /// The one-line detail for errored (reason) and waiting-for-input + /// (question summary) rows; absent otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + /// One rendered part of an assistant message. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "kind", rename_all = "camelCase")] @@ -219,6 +249,24 @@ pub enum MessagePart { /// source does not rewrite history. source_title: String, }, + /// The engine's completion notice (orchestration v2): an engine-authored + /// SYSTEM entry in a parent chat reporting the recorded outcomes of its + /// agent-created side chats' turns — finished, failed (one-line reason), + /// stopped, or waiting for input (one-line summary). The entry's sibling + /// text part carries the same notice as plain text, so older layouts — + /// whose unknown-kind fallback yields an empty part — stay readable. + /// Written only by the engine when it delivers a notice; never a user + /// message, never agent content. + #[serde(rename_all = "camelCase")] + CompletionNotice { + id: String, + /// One row per side chat the notice reports (a single outcome when + /// one side chat finished; several when outcomes merged — ticket 13). + /// Defaults for leniency: a partial row reads as an empty notice + /// rather than a decode failure. + #[serde(default)] + sides: Vec, + }, } impl MessagePart { @@ -230,7 +278,8 @@ impl MessagePart { | MessagePart::Tool { id, .. } | MessagePart::Input { id, .. } | MessagePart::Error { id, .. } - | MessagePart::Fork { id, .. } => id, + | MessagePart::Fork { id, .. } + | MessagePart::CompletionNotice { id, .. } => id, } } @@ -268,6 +317,14 @@ impl MessagePart { source_title, .. } => source_chat_id.len() + source_title.len(), + MessagePart::CompletionNotice { sides, .. } => sides + .iter() + .map(|side| { + side.chat_id.len() + + side.title.len() + + side.detail.as_ref().map_or(0, String::len) + }) + .sum(), } } } diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index 2a2249e6fc..1f20e1675d 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -10,8 +10,8 @@ //! - `queue`: LoroMovableList of LoroMap { //! id, text, attachments?, issuedBy, issuedAt, editedAt? } (any device writes) //! -//! Part maps: { id, kind: "text"|"reasoning"|"tool"|"input"|"error"|"image", text?: LoroText, -//! reasoning?: LoroText, call?: json, isError?, questions?: json, resolved?, message? }. +//! Part maps: { id, kind: "text"|"reasoning"|"tool"|"input"|"error"|"image"|"fork"|"completionNotice", +//! text?: LoroText, reasoning?: LoroText, call?: json, isError?, questions?: json, resolved?, message? }. //! Text bodies are **LoroText** so streaming appends RLE-merge (1.03x oplog overhead vs //! 125x for whole-value rewrites). @@ -133,6 +133,11 @@ struct DocPartJson { /// One-line live tail of the subagent's output (additive). #[serde(default, skip_serializing_if = "Option::is_none")] subagent_tail: Option, + /// Completion-notice rows (kind: "completionNotice", additive): the + /// side chats and their recorded outcomes an engine-authored notice + /// reports. + #[serde(default, skip_serializing_if = "Option::is_none")] + sides: Option, } /// App parts → doc part json (mirror of `toDocParts`). @@ -231,6 +236,12 @@ fn to_doc_part(part: &MessagePart) -> Result { source_title: Some(source_title.clone()), ..Default::default() }, + MessagePart::CompletionNotice { id, sides } => DocPartJson { + id: id.clone(), + kind: "completionNotice".into(), + sides: Some(serde_json::to_value(sides)?), + ..Default::default() + }, }) } @@ -286,6 +297,13 @@ fn from_doc_part(p: DocPartJson) -> MessagePart { source_chat_id: p.source_chat_id.unwrap_or_default(), source_title: p.source_title.unwrap_or_default(), }, + "completionNotice" => MessagePart::CompletionNotice { + id: p.id, + sides: p + .sides + .and_then(|s| serde_json::from_value(s).ok()) + .unwrap_or_default(), + }, _ => MessagePart::Text { id: p.id, text: p.text.unwrap_or_default(), @@ -907,6 +925,9 @@ fn push_part(parts: &LoroList, part: &MessagePart) -> Result<(), DocError> { if let Some(subagent_tail) = &doc_part.subagent_tail { map.insert("subagentTail", subagent_tail.as_str())?; } + if let Some(sides) = &doc_part.sides { + map.insert("sides", loro_value_from_json(sides))?; + } Ok(()) } @@ -1442,6 +1463,74 @@ mod tests { assert_eq!(entries[0].parts, vec![seam]); } + /// The engine-authored completion notice: a SYSTEM entry whose marker + /// part (sides + outcomes) round-trips through the doc beside the plain + /// text that keeps older layouts readable. Wire names stay camelCase and + /// additive (ADR 0005): a row from an older writer carries no sides and + /// degrades to an empty text part, never a decode failure. + #[test] + fn completion_notice_round_trips_through_the_doc() { + let doc = SessionDoc::init("notice").unwrap(); + let marker = MessagePart::CompletionNotice { + id: "n0".into(), + sides: vec![ + NoticeSide { + chat_id: "chat-side-1234".into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }, + NoticeSide { + chat_id: "chat-side-5678".into(), + title: "Flaky test".into(), + outcome: NoticeOutcome::Errored, + detail: Some("build timed out".into()), + }, + ], + }; + let text = MessagePart::Text { + id: "t0".into(), + text: "2 side chats reached outcomes:".into(), + }; + doc.push_message(&SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + id: "notice:1".into(), + role: MessageRole::System, + parts: vec![marker.clone(), text.clone()], + created_at: 1, + device_id: "dev".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + }) + .unwrap(); + let entries = doc.read_entries().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].role, MessageRole::System); + assert_eq!(entries[0].parts, vec![marker, text]); + + // The wire shape: camelCase, additive, tag "completionNotice". + let value = serde_json::to_value(entries[0].parts.clone()).unwrap(); + assert_eq!(value[0]["kind"], "completionNotice"); + assert_eq!(value[0]["sides"][0]["chatId"], "chat-side-1234"); + assert_eq!(value[0]["sides"][0]["outcome"], "completed"); + assert_eq!(value[0]["sides"][1]["outcome"], "errored"); + assert_eq!(value[0]["sides"][1]["detail"], "build timed out"); + assert!(value[0]["sides"][0].get("detail").is_none()); + // An older writer's marker (no sides) degrades to empty rows. + let legacy = serde_json::json!({ + "kind": "completionNotice", + "id": "n0", + }); + assert_eq!( + serde_json::from_value::(legacy).unwrap(), + MessagePart::CompletionNotice { + id: "n0".into(), + sides: Vec::new(), + } + ); + } + #[test] fn generated_image_persists_updates_and_salvages() { let doc = SessionDoc::init("images").unwrap(); diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs new file mode 100644 index 0000000000..06ee8a0afe --- /dev/null +++ b/crates/engine/src/completion_notices.rs @@ -0,0 +1,449 @@ +//! Completion notices (orchestration v2): when a turn of an agent-created, +//! notifying side chat comes to rest, the engine records an outcome for the +//! chat that created it — based on its own recorded turn-end state, never on +//! anything the agent calls — and wakes an idle parent with one +//! engine-authored notice message that starts a parent turn. +//! +//! This module is the tracer bullet (ticket 12): outcome records for +//! completed / errored / stopped / awaiting-input turns, and delivery to an +//! IDLE parent. A busy parent is left to ticket 13 (its notice waits for the +//! turn to end, and several outcomes merge into one notice); restart +//! durability, acknowledgement and the loop cap land in tickets 13–14. The +//! pending records here are the seam those tickets build on. +//! +//! The notice message is a SYSTEM entry — never a user message — carrying a +//! [`roboco_doc::MessagePart::CompletionNotice`] marker so clients can render +//! it distinctly (ticket 15), with the same text as a plain part so older +//! layouts stay readable. Its text names each side chat by title and short +//! id with its outcome, and points the parent agent at `read_chat`. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use roboco_doc::{ + MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, SessionMessageEntry, +}; + +use crate::doc_host::DocHost; +use crate::sessions::SessionsEngine; +use crate::{new_id, now_ms}; + +/// Cap for a notice's one-line detail — an error's reason or a question's +/// summary. Matches the transcript's tool-output summary budget: the notice +/// is a pointer to `read_chat`, never a report. +pub(crate) const NOTICE_DETAIL_MAX: usize = 160; + +/// The engine's recorded turn-end state for one notifiable side-chat turn. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Outcome { + /// The turn completed. + Completed, + /// The turn errored, with its one-line reason. + Errored { reason: Option }, + /// The turn was stopped or interrupted. + Stopped, + /// The turn is waiting for input, with the one-line summary of the + /// question or approval it is waiting on. + WaitingForInput { summary: String }, +} + +/// An outcome recorded for a parent, awaiting delivery. +#[derive(Debug, Clone)] +pub(crate) struct PendingOutcome { + /// The side chat whose turn ended. + pub(crate) side_chat_id: String, + pub(crate) outcome: Outcome, +} + +/// Outcome records owed to parents, keyed by parent chat id. In-memory for +/// the tracer bullet; ticket 13 makes them durable and merges the ones a +/// busy parent accumulates into one notice. +#[derive(Debug, Default)] +pub(crate) struct CompletionNotices { + pending: Mutex>>, +} + +impl CompletionNotices { + pub(crate) fn record(&self, parent: &str, outcome: PendingOutcome) { + lock(&self.pending) + .entry(parent.to_string()) + .or_default() + .push(outcome); + } + + /// Take everything owed to `parent` for a delivery. `None` when the + /// parent is owed nothing. + pub(crate) fn take_for_delivery(&self, parent: &str) -> Option> { + lock(&self.pending) + .remove(parent) + .filter(|outcomes| !outcomes.is_empty()) + } + + /// A delivery that could not start a parent turn (busy again, no run + /// configuration, no doc): the outcomes stay owed, at the front of the + /// queue in their recorded order. + pub(crate) fn requeue(&self, parent: &str, outcomes: Vec) { + if outcomes.is_empty() { + return; + } + let mut pending = lock(&self.pending) + .entry(parent.to_string()) + .or_default(); + for outcome in outcomes.into_iter().rev() { + pending.insert(0, outcome); + } + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// First non-empty line, trimmed and capped with an ellipsis — the one-line +/// form a notice's detail owes the parent. `None` for blank text. +pub(crate) fn one_line(text: Option<&str>) -> Option { + let text = text?.trim(); + if text.is_empty() { + return None; + } + let line = text + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(text) + .trim_end(); + if line.chars().count() <= NOTICE_DETAIL_MAX { + return Some(line.to_owned()); + } + let mut cut = NOTICE_DETAIL_MAX; + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + Some(format!("{}…", &line[..cut])) +} + +/// The one-line summary of the question or approval a side chat is waiting +/// on: the question's own text, its header when the text is blank. +pub(crate) fn question_summary(questions: &[roboco_proto::UserInputQuestion]) -> String { + let text = questions + .first() + .map(|question| { + if question.question.trim().is_empty() { + question.header.as_str() + } else { + question.question.as_str() + } + }) + .unwrap_or("input"); + one_line(Some(text)).unwrap_or_else(|| "input".into()) +} + +/// Record an outcome for a side-chat turn and deliver what its parent is +/// owed. Called from the run loop, fire-and-forget: the side chat's own +/// event stream never waits on the parent's turn. +pub(crate) async fn record_and_deliver( + engine: &SessionsEngine, + side_chat: &str, + parent: &str, + outcome: Outcome, +) { + engine.notices().record( + parent, + PendingOutcome { + side_chat_id: side_chat.to_string(), + outcome, + }, + ); + deliver(engine, parent).await; +} + +/// Deliver the parent's pending outcomes as one engine-authored notice that +/// starts a parent turn. An idle parent is woken immediately; a busy one +/// receives nothing here — its outcomes stay pending for ticket 13's +/// end-of-turn delivery, and the notice is never steered mid-turn. +pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { + if engine.turn_in_flight(parent) { + return; + } + let Some(outcomes) = engine.notices().take_for_delivery(parent) else { + return; + }; + let Some(host) = engine.host() else { + tracing::warn!(parent, "completion notice held: no doc host"); + engine.notices().requeue(parent, outcomes); + return; + }; + let sides: Vec = outcomes.iter().map(|o| side_row(&host, o)).collect(); + let text = notice_text(&sides); + // The notice turn runs on the parent's own configuration: its chat row + // when there is one, else the last live request. `dispatch` re-derives + // the harness session and stamps the Roboco MCP server (so the parent + // can `read_chat` immediately). + let request = host.request_from_chat_row(parent, &text).or_else(|| { + engine.last_request(parent).map(|mut request| { + request.prompt = text.clone(); + request.resume = None; + request.attachments = Vec::new(); + request + }) + }); + let Some(request) = request else { + tracing::warn!( + parent, + "completion notice held: no run configuration for the chat" + ); + engine.notices().requeue(parent, outcomes); + return; + }; + let Ok(handle) = host.open(parent) else { + tracing::warn!(parent, "completion notice held: doc unavailable"); + engine.notices().requeue(parent, outcomes); + return; + }; + // A parent turn that started while this delivery was being built + // receives the notice after its turn instead (ticket 13): it is never + // steered mid-turn. The entry has not been written yet, so the outcomes + // go back to the front of the pending queue untouched. + if engine.turn_in_flight(parent) { + engine.notices().requeue(parent, outcomes); + return; + } + let entry = notice_entry(host.device_id(), &sides, &text); + if let Err(err) = handle.doc().push_message(&entry) { + tracing::warn!(parent, error = %err, "completion notice entry write failed"); + engine.notices().requeue(parent, outcomes); + return; + } + let harness = host.harness_for_request(parent, &request); + if let Err(err) = engine + .dispatch(parent, harness, request, Some(entry.id.clone()), None) + .await + { + // The entry is already in the transcript (readable on the parent's + // next turn); only the wake failed. Do not requeue: a dispatch that + // fails once fails the same way twice. + tracing::warn!(parent, error = %err, "completion notice turn failed to start"); + } +} + +fn side_row(host: &DocHost, outcome: &PendingOutcome) -> NoticeSide { + let title = host + .workspace() + .and_then(|ws| ws.chat(&outcome.side_chat_id).ok().flatten()) + .and_then(|chat| chat.title) + .map(|title| title.trim().to_owned()) + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| "Side chat".into()); + let (recorded, detail) = match &outcome.outcome { + Outcome::Completed => (NoticeOutcome::Completed, None), + Outcome::Errored { reason } => (NoticeOutcome::Errored, reason.clone()), + Outcome::Stopped => (NoticeOutcome::Stopped, None), + Outcome::WaitingForInput { summary } => { + (NoticeOutcome::WaitingForInput, Some(summary.clone())) + } + }; + NoticeSide { + chat_id: outcome.side_chat_id.clone(), + title, + outcome: recorded, + detail, + } +} + +/// The notice's readable text — also the parent turn's prompt. Names each +/// side chat by title and short id with its outcome, and tells the agent to +/// use `read_chat` for the full result. +pub(crate) fn notice_text(sides: &[NoticeSide]) -> String { + let mut lines: Vec = Vec::with_capacity(sides.len() + 2); + match sides { + [] => lines.push("Side chats reached outcomes.".into()), + [only] => lines.push(format!("Side chat {}.", side_line(only))), + many => { + lines.push(format!("{} side chats reached outcomes:", many.len())); + lines.extend(many.iter().map(|side| format!("- {}.", side_line(side)))); + } + } + lines.push(read_line(sides)); + lines.join("\n") +} + +/// The `read_chat` pointer — the notice is a pointer, never a report. +fn read_line(sides: &[NoticeSide]) -> String { + match sides { + [only] => format!( + "Read the full result with the Roboco `read_chat` tool, chat {}.", + short_id(&only.chat_id) + ), + _ => "Read each chat's full result with the Roboco `read_chat` tool.".into(), + } +} + +/// `"title" (short id) outcome` — the one-line form for one side chat. +fn side_line(side: &NoticeSide) -> String { + let outcome = match side.outcome { + NoticeOutcome::Completed => "finished".into(), + NoticeOutcome::Errored => match &side.detail { + Some(reason) => format!("failed: {reason}"), + None => "failed".into(), + }, + NoticeOutcome::Stopped => "was stopped".into(), + NoticeOutcome::WaitingForInput => match &side.detail { + Some(summary) => format!("is waiting for input: {summary}"), + None => "is waiting for input".into(), + }, + }; + format!("\"{}\" ({}) {}", side.title, short_id(&side.chat_id), outcome) +} + +/// Eight-char id prefix — the sidebar's convention. +fn short_id(id: &str) -> &str { + &id[..id.len().min(8)] +} + +/// The engine-authored notice message: a SYSTEM entry (never a user +/// message) carrying the structured marker clients render distinctly +/// (ticket 15), with the notice's text beside it as a plain part so older +/// layouts stay readable. +fn notice_entry(device_id: &str, sides: &[NoticeSide], text: &str) -> SessionMessageEntry { + SessionMessageEntry { + id: format!("notice:{}", new_id()), + role: MessageRole::System, + parts: vec![ + MessagePart::CompletionNotice { + id: "n0".into(), + sides: sides.to_vec(), + }, + MessagePart::Text { + id: "t0".into(), + text: text.to_owned(), + }, + ], + created_at: now_ms(), + device_id: device_id.to_owned(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + origin_chat_id: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn side(chat_id: &str, title: &str, outcome: NoticeOutcome) -> NoticeSide { + NoticeSide { + chat_id: chat_id.into(), + title: title.into(), + outcome, + detail: None, + } + } + + #[test] + fn one_line_takes_the_first_line_trimmed_and_capped() { + assert_eq!(one_line(None), None); + assert_eq!(one_line(Some(" \n\t")), None); + assert_eq!(one_line(Some(" provider 502 ")), Some("provider 502".into())); + // Multi-line errors keep only their first non-empty line. + assert_eq!( + one_line(Some("\nprovider 502: bad gateway\nretry later\n")), + Some("provider 502: bad gateway".into()) + ); + let long = "x".repeat(NOTICE_DETAIL_MAX + 40); + let capped = one_line(Some(&long)).unwrap(); + assert_eq!(capped.chars().count(), NOTICE_DETAIL_MAX + 1); + assert!(capped.ends_with('…')); + // Char-boundary safety on multibyte input. + let wide = "é".repeat(NOTICE_DETAIL_MAX + 5); + let capped = one_line(Some(&wide)).unwrap(); + assert_eq!(capped.chars().count(), NOTICE_DETAIL_MAX + 1); + } + + #[test] + fn question_summary_prefers_the_question_then_the_header() { + use roboco_proto::UserInputQuestion; + let questions = vec![ + UserInputQuestion { + id: "q1".into(), + header: "Approval".into(), + question: " Should we bump the major version? ".into(), + options: vec!["Yes".into(), "No".into()], + multi_select: false, + }, + ]; + assert_eq!( + question_summary(&questions), + "Should we bump the major version?" + ); + let header_only = vec![UserInputQuestion { + id: "q1".into(), + header: "Approve shell access".into(), + question: String::new(), + options: vec![], + multi_select: false, + }]; + assert_eq!(question_summary(&header_only), "Approve shell access"); + assert_eq!(question_summary(&[]), "input"); + } + + #[test] + fn notice_text_names_title_short_id_outcome_and_read_chat() { + let mut completed = side("chat-side-1234", "Implement login", NoticeOutcome::Completed); + completed.detail = None; + let single = notice_text(&[completed]); + assert_eq!( + single, + "Side chat \"Implement login\" (chat-si) finished.\n\ + Read the full result with the Roboco `read_chat` tool, chat chat-si." + ); + + let mut errored = side("chat-side-5678", "Flaky test", NoticeOutcome::Errored); + errored.detail = Some("the build command timed out".into()); + let mut waiting = side("chat-side-90ab", "Review the API", NoticeOutcome::WaitingForInput); + waiting.detail = Some("Should we bump the major version?".into()); + let merged = notice_text(&[completed, errored, waiting]); + assert!(merged.starts_with("3 side chats reached outcomes:\n")); + assert!(merged.contains("- \"Implement login\" (chat-si) finished.")); + assert!(merged.contains("- \"Flaky test\" (chat-si) failed: the build command timed out.")); + assert!( + merged + .contains("- \"Review the API\" (chat-si) is waiting for input: Should we bump the major version?") + ); + assert!(merged.contains("`read_chat`")); + // A stopped row carries no detail. + let stopped = side("chat-side-cdef", "Migration", NoticeOutcome::Stopped); + assert!(notice_text(&[stopped]).contains("\"Migration\" (chat-si) was stopped.")); + // Degenerate empty input still renders a pointer, never a panic. + assert!(notice_text(&[]).contains("read_chat")); + } + + #[test] + fn pending_outcomes_record_take_and_requeue() { + let notices = CompletionNotices::default(); + assert!(notices.take_for_delivery("parent").is_none()); + notices.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-1".into(), + outcome: Outcome::Completed, + }, + ); + notices.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-2".into(), + outcome: Outcome::Stopped, + }, + ); + let taken = notices.take_for_delivery("parent").unwrap(); + assert_eq!(taken.len(), 2); + assert_eq!(taken[0].side_chat_id, "chat-side-1"); + // Taken is taken: a second delivery owes nothing. + assert!(notices.take_for_delivery("parent").is_none()); + // A failed delivery puts them back at the front, in order. + notices.requeue("parent", taken); + let requeued = notices.take_for_delivery("parent").unwrap(); + assert_eq!(requeued[0].side_chat_id, "chat-side-1"); + assert_eq!(requeued[1].side_chat_id, "chat-side-2"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3eb56ae44..35c1a45a2e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ use roboco_sync::DocsStore; pub mod agent_accounts; pub mod change_requests; mod chat_persistence; +pub mod completion_notices; pub mod diff_sync; pub mod doc_host; mod fs_watch; diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 8da235c1da..8e286e488a 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -143,6 +143,12 @@ struct RunHandle { /// This runtime's provider session holds the fork's copied history (a /// side chat's bootstrap went out on its run or on one of its steers). fork_history_sent: Arc, + /// The parent chat this run's CURRENT turn owes a completion notice to + /// (orchestration v2): set when the turn's starting message or a steer + /// within it came from the notifying parent that created the chat, + /// reset when a user message starts a new turn. Read at the engine's + /// own turn-end states to record the outcome. + notifiable: Mutex>, /// The user stopped this runtime: it is being torn down and takes no /// more prompts. Flipped under the ledger lock, so every accepted steer /// is unambiguously before the stop (cancelled with it) or after it @@ -155,6 +161,11 @@ struct RunHandle { struct RoutedSteer { prompt: String, message_id: String, + /// The chat that sent this steer, when a chat sent it through the Roboco + /// MCP server — the re-dispatch of an orphaned steer keeps it, so the + /// turn it starts stays notifiable for its parent (ticket 11's rule, + /// extended to the orphan path by ticket 12). + origin_chat_id: Option, /// The delivered text carried the fork's copied history: its delivery /// is recorded only when the runtime confirms consuming it (`Steered`). /// An orphan re-dispatches the bare prompt, still owing the history. @@ -198,6 +209,10 @@ struct Inner { /// (roboco kept the same pair on `chats.harness_session_id`). An empty /// session id is the "do not resume" tombstone after a rejected resume. harness_sessions: Mutex>, + /// Completion-notice bookkeeping (orchestration v2): the outcomes + /// side-chat turns owe their agent parents. Durability lands with + /// ticket 13; this is the in-memory seam. + notices: crate::completion_notices::CompletionNotices, /// Auto-titler for untitled chats (wired at engine assembly; absent in bare tests). titles: OnceLock, generated_images: OnceLock<(crate::uploads::Uploads, std::path::PathBuf)>, @@ -241,6 +256,7 @@ impl SessionsEngine { sessions_tx, last_requests: Mutex::new(HashMap::new()), harness_sessions: Mutex::new(HashMap::new()), + notices: crate::completion_notices::CompletionNotices::default(), titles: OnceLock::new(), generated_images: OnceLock::new(), turn_listener: OnceLock::new(), @@ -317,6 +333,61 @@ impl SessionsEngine { lock(&self.inner.statuses).get(chat_id).cloned() } + /// The completion-notice state (crate-internal; orchestration v2). + pub(crate) fn notices(&self) -> &crate::completion_notices::CompletionNotices { + &self.inner.notices + } + + /// The doc host, for the completion-notice delivery path (crate-internal). + pub(crate) fn host(&self) -> Option { + self.inner.doc_host() + } + + /// The parent a message from `origin` makes a turn of `chat_id` + /// notifiable for: only the chat the durable agent-origin row + /// (`roboco_proto::AgentOrigin` on the chat) names, and only while it + /// did not opt out of notices at creation. `None` for user-typed sends + /// (no origin), third-party sends, user-created side chats (no agent + /// origin) and opted-out creators — those never notify. + fn notifiable_parent_for(&self, chat_id: &str, origin: Option<&str>) -> Option { + let origin = origin?; + let agent_origin = self + .inner + .doc_host() + .and_then(|host| host.workspace()) + .and_then(|ws| ws.chat(chat_id).ok().flatten()) + .and_then(|chat| chat.agent_origin)?; + agent_origin + .notify + .then(|| agent_origin.chat_id) + .filter(|parent| parent == origin) + } + + /// Stamp a live run's notifiable parent for a message that just joined + /// it. A message into a parked (idle) session starts a NEW turn: the + /// turn's notifiability is that message's own. One into a working turn + /// only ever ARMS notices — a parent message makes the turn notifiable; + /// anything else (a user's steer) leaves the starting message's claim + /// intact. + fn mark_notifiable(&self, chat_id: &str, run_id: &str, origin: Option<&str>) { + let mid_turn = matches!( + self.session_status(chat_id).map(|session| session.status), + Some(SessionStatus::Working) | Some(SessionStatus::AwaitingInput) + ); + let from_parent = self.notifiable_parent_for(chat_id, origin); + let mut handle = lock(&self.inner.runs); + let Some(run) = handle.get_mut(chat_id).filter(|h| h.run_id == *run_id) else { + return; + }; + let mut notifiable = lock(&run.notifiable); + let current = notifiable.take(); + *notifiable = if mid_turn { + from_parent.or(current) + } else { + from_parent + }; + } + /// Whether this harness takes a prompt *during* a turn, rather than only at /// the boundary between turns. The queue drain's central question: a /// mid-turn agent gets the message now, everything else holds it until the @@ -556,6 +627,7 @@ impl SessionsEngine { pending.push_back(RoutedSteer { prompt: request.prompt.clone(), message_id: user_id.clone(), + origin_chat_id: origin_chat_id.clone(), fork_history: bootstrap.is_some(), after_stop: retiring.load(std::sync::atomic::Ordering::Acquire), request: Some(Box::new(request.clone())), @@ -567,6 +639,11 @@ impl SessionsEngine { false }; if accepted { + // A warm dispatch is a steer: the message joins the turn in + // flight (arming its completion notice) or starts the parked + // session's next turn (whose notifiability is the message's + // own) — orchestration v2. + self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref()); if reconfigure { // The runtime runs this configuration from now on: later // sends compare against it, and a re-dispatch of an @@ -736,6 +813,9 @@ impl SessionsEngine { pending_inputs, routed_steers: Arc::new(Mutex::new(std::collections::VecDeque::new())), fork_history_sent: fork_history_sent.clone(), + notifiable: Mutex::new( + self.notifiable_parent_for(chat_id, origin_chat_id.as_deref()), + ), retiring: Arc::new(std::sync::atomic::AtomicBool::new(false)), }, ); @@ -879,6 +959,7 @@ impl SessionsEngine { pending.push_back(RoutedSteer { prompt: prompt.to_string(), message_id: user_id.clone(), + origin_chat_id: origin_chat_id.clone(), fork_history: bootstrap.is_some(), after_stop: retiring.load(std::sync::atomic::Ordering::Acquire), request: sent_with, @@ -893,6 +974,10 @@ impl SessionsEngine { _ => SteerOutcome::DeferredByUpdate, }); }; + // A parent's steer within the turn keeps the turn notifiable; one + // into a parked session defines the next turn's notifiability — + // orchestration v2 completion notices. + self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref()); let handle = self.doc_handle(chat_id)?; handle.write_user_message( &user_id, @@ -1099,7 +1184,9 @@ impl SessionsEngine { } // The revival prompt: the last user message (idempotent re-dispatch // under the SAME id — `write_user_message` dedupes by id, so the - // transcript never shows a duplicate). + // transcript never shows a duplicate). Its structured origin + // rides along, so a parent-started turn that is revived stays + // notifiable for its completion notice (orchestration v2). let prompt = handle.doc().read_entries().ok().and_then(|entries| { entries .iter() @@ -1107,7 +1194,11 @@ impl SessionsEngine { .find(|e| e.role == MessageRole::User) .and_then(|e| { e.parts.iter().find_map(|p| match p { - MessagePart::Text { text, .. } => Some((e.id.clone(), text.clone())), + MessagePart::Text { text, .. } => Some(( + e.id.clone(), + text.clone(), + e.origin_chat_id.clone(), + )), _ => None, }) }) @@ -1157,7 +1248,7 @@ impl SessionsEngine { resume_ix * RESUME_STAGGER_MS + resume_jitter_ms(&chat_id), ); resume_ix += 1; - let (user_id, prompt_text) = prompt.expect("gated by will_resume"); + let (user_id, prompt_text, origin_chat_id) = prompt.expect("gated by will_resume"); let sessions = self.clone(); let gate = resume_gate.clone(); tokio::spawn(async move { @@ -1206,6 +1297,7 @@ impl SessionsEngine { harness_id, request, Some(user_id), + origin_chat_id, ) .await { @@ -2091,6 +2183,83 @@ fn cursor_unstarted_history( )) } +/// The parent the run's CURRENT turn owes a completion notice to, read at +/// the engine's own turn-end states (orchestration v2). +fn run_notifiable_parent(inner: &Inner, chat_id: &str, run_id: &str) -> Option { + lock(&inner.runs) + .get(chat_id) + .filter(|h| h.run_id == run_id) + .and_then(|h| lock(&h.notifiable).clone()) +} + +/// Record a completion-notice outcome, fire-and-forget: the side chat's +/// event stream never waits on the parent's notice turn. Engine teardown +/// records nothing — the runs it kills were not user-visible outcomes +/// (restart semantics are ticket 13's, at boot, not at teardown). +fn spawn_notice_record( + inner: Arc, + side_chat: String, + parent: String, + outcome: crate::completion_notices::Outcome, +) { + if inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + return; + } + let engine = SessionsEngine { inner }; + tokio::spawn(async move { + crate::completion_notices::record_and_deliver(&engine, &side_chat, &parent, outcome).await; + }); +} + +/// Record the outcome of a turn that settled INSIDE the run loop (a parked +/// persistent session, the quiesce watchdog): only real turn ends record — +/// an internal handoff (a pending steer or a sent message owns the +/// continuation) notifies nothing. +fn record_parked_turn( + inner: &Arc, + chat_id: &str, + run_id: &str, + completed: bool, + stopped_in_place: bool, +) { + let outcome = if stopped_in_place { + Some(crate::completion_notices::Outcome::Stopped) + } else if completed { + Some(crate::completion_notices::Outcome::Completed) + } else { + None + }; + if let Some(outcome) = outcome + && let Some(parent) = run_notifiable_parent(inner, chat_id, run_id) + { + spawn_notice_record(inner.clone(), chat_id.to_string(), parent, outcome); + } +} + +/// The outcome for the run's FINAL turn-end, from the engine's own recorded +/// state: the Done status and error, and whether the completion marker was +/// minted (a Done with a pending steer or a sent message on its way is an +/// internal handoff, not a completion). +fn final_outcome( + status: DoneStatus, + error: Option<&str>, + completed_turn: bool, +) -> Option { + match status { + DoneStatus::Completed if completed_turn => { + Some(crate::completion_notices::Outcome::Completed) + } + DoneStatus::Completed => None, + DoneStatus::Errored => Some(crate::completion_notices::Outcome::Errored { + reason: crate::completion_notices::one_line(error), + }), + DoneStatus::Interrupted => Some(crate::completion_notices::Outcome::Stopped), + } +} + #[allow(clippy::too_many_arguments)] async fn drive_run( inner: Arc, @@ -2242,10 +2411,18 @@ async fn drive_run( &AgentEvent::Done { status: DoneStatus::Errored, result: None, - error: Some(message), + error: Some(message.clone()), session_id: None, }, ); + // Completion notices (orchestration v2): a notifiable turn that + // never started is still a turn the parent is owed — it errored. + if let Some(parent) = run_notifiable_parent(&inner, &chat_id, &run_id) { + let outcome = crate::completion_notices::Outcome::Errored { + reason: crate::completion_notices::one_line(Some(message.as_str())), + }; + spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); + } inner.remove_run(&chat_id, &run_id); inner.set_status(&chat_id, SessionStatus::Errored, false); return; @@ -2395,6 +2572,10 @@ async fn drive_run( let mut last_subagent_activity: Option = None; let mut final_completed_turn = None; + // The run's FINAL Done (status + error), captured at the break that ends + // the run — the engine's own recorded turn-end state for the completion + // notice. Parked settles record inside the loop instead. + let mut final_done: Option<(DoneStatus, Option)> = None; let final_status = loop { let event: AgentEvent = if let Some(event) = prepared_events.pop_front() { event @@ -2606,6 +2787,15 @@ async fn drive_run( inner.set_status_with_completion( &chat_id, SessionStatus::Idle, false, completed_turn, ); + // The watchdog finalized a completed turn (no native + // Done): it owes its parent the same notice a Done would. + record_parked_turn( + &inner, + &chat_id, + &run_id, + completed_turn.is_some(), + false, + ); continue; } }; @@ -3065,10 +3255,27 @@ async fn drive_run( inner.remember_harness_session(&chat_id, session_id, &run_cwd); note_fork_history_session(&doc, carried_history, session_id); } - AgentEvent::InputRequested { .. } => { + AgentEvent::InputRequested { questions, .. } => { // Known-id guaranteed: the unknown-id twin was dropped above, // before the parked gate. + let was_awaiting = lock(&inner.statuses) + .get(&chat_id) + .is_some_and(|s| s.status == SessionStatus::AwaitingInput); inner.set_status(&chat_id, SessionStatus::AwaitingInput, false); + // A notifiable turn reaching awaiting input owes its parent a + // notice with a one-line summary of the question — once per + // awaiting stretch, not per twin question. + if !was_awaiting + && let Some(parent) = run_notifiable_parent(&inner, &chat_id, &run_id) + { + let summary = crate::completion_notices::question_summary(questions); + spawn_notice_record( + inner.clone(), + chat_id.clone(), + parent, + crate::completion_notices::Outcome::WaitingForInput { summary }, + ); + } } AgentEvent::InputResolved { .. } => { inner.set_status(&chat_id, SessionStatus::Working, false); @@ -3091,7 +3298,7 @@ async fn drive_run( // still in place and tested. } - if let AgentEvent::Done { status, .. } = &event { + if let AgentEvent::Done { status, error, .. } = &event { // A question still pending at turn end can never be legitimately // answered (its turn is over): drain the resolvers NOW, or a late // `respond_input` finds one, emits InputResolved, and un-parks @@ -3200,9 +3407,20 @@ async fn drive_run( false, completed_turn, ); + // The parked turn settled: a completed turn notifies its + // parent, a stop-in-place notifies as stopped, an internal + // handoff (a pending steer owns the continuation) does not. + record_parked_turn( + &inner, + &chat_id, + &run_id, + completed_turn.is_some(), + stopped_in_place, + ); continue; } final_completed_turn = completed_turn; + final_done = Some((*status, error.clone())); break match status { DoneStatus::Errored => SessionStatus::Errored, _ => SessionStatus::Idle, @@ -3243,8 +3461,18 @@ async fn drive_run( .filter(|h| h.run_id == run_id) .map(|h| std::mem::take(&mut *lock(&h.routed_steers)).into()) .unwrap_or_default(); + // Completion notices (orchestration v2): the run's final turn ended — + // read the notifiable parent before the handle goes away, then record + // the outcome the engine's own state observed. + let notifiable = run_notifiable_parent(&inner, &chat_id, &run_id); inner.remove_run(&chat_id, &run_id); inner.set_status_with_completion(&chat_id, final_status, false, final_completed_turn); + if let (Some(parent), Some((status, error))) = (notifiable, final_done) { + let completed_turn = final_completed_turn.is_some(); + if let Some(outcome) = final_outcome(status, error.as_deref(), completed_turn) { + spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); + } + } // A Stop cancels the messages it found waiting; one accepted after it // began is still the user's next message and must run. let orphans: Vec = if inner @@ -3292,6 +3520,7 @@ async fn drive_run( harness_id, request, Some(steer.message_id.clone()), + steer.origin_chat_id.clone(), ) .await { diff --git a/crates/engine/src/transcript_budget.rs b/crates/engine/src/transcript_budget.rs index e5aba64233..83f0075e17 100644 --- a/crates/engine/src/transcript_budget.rs +++ b/crates/engine/src/transcript_budget.rs @@ -196,12 +196,13 @@ fn render_parts(parts: &[MessagePart]) -> String { MessagePart::Error { message, .. } => format!("Error: {message}"), MessagePart::Tool { .. } => render_command_run(part), // Reasoning is the model's private scratchpad; images are - // attachments; input requests and fork seams are structure — - // none of them belong in a budgeted summary. + // attachments; input requests, fork seams and completion notices + // are structure — none of them belong in a budgeted summary. MessagePart::Reasoning { .. } | MessagePart::Image { .. } | MessagePart::Input { .. } - | MessagePart::Fork { .. } => continue, + | MessagePart::Fork { .. } + | MessagePart::CompletionNotice { .. } => continue, }; blocks.push(block); } @@ -361,6 +362,7 @@ mod tests { device_id: "device".into(), status: Some(MessageStatus::Complete), continuation_of: None, + origin_chat_id: None, } } diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs new file mode 100644 index 0000000000..4d1421f241 --- /dev/null +++ b/crates/engine/tests/completion_notices.rs @@ -0,0 +1,833 @@ +//! Completion notices wake an idle parent (orchestration v2, ticket 12). +//! +//! The tracer bullet for the spec's completion-notice stories 13–17 and 19: +//! - a turn of an agent-created, NOTIFYING side chat is notifiable when its +//! starting message, or a steer within it, came from the parent; +//! - when such a turn ends, the engine records an outcome from its OWN +//! turn-end state — completed; errored with a one-line reason; stopped; +//! waiting for input with a one-line summary of the question — and wakes +//! an idle parent with one engine-authored notice message that starts a +//! parent turn; +//! - the notice is a SYSTEM entry (never a user message) carrying a +//! completion-notice marker for clients to render distinctly (ticket 15), +//! names the side chat by title and short id with its outcome, and tells +//! the agent to use `read_chat` for the full result; +//! - a user-started turn in the child, a user-created side chat, and a +//! `notify: false` child never wake the parent. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{ + MessagePart, MessageRole, NoticeOutcome, NoticeSide, SessionMessageEntry, +}; +use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SandboxLevel, + SessionStatus, SteeringMode, UserInputQuestion, +}; + +const PARENT: &str = "chat-parent"; +const SIDE: &str = "chat-side"; + +/// What one `run()` call does — the tests script turns FIFO, one entry per +/// run the engine starts (a side chat's turn, then the parent's notice +/// turn, …). +enum Turn { + /// Stream one line of text, then Done with the status and error. + End { + status: DoneStatus, + error: Option, + }, + /// Ask a question through the engine's input bridge, then park awaiting + /// the answer (the turn stays open, the chat awaits input). + AskInput { question: &'static str }, + /// Hang until a steer arrives: consume it, emit the `Steered` boundary, + /// then settle the turn Completed (what a steered run does when its + /// work finishes). + Hang, +} + +/// A steerable mock whose turns the test scripts. Every run's prompt is +/// recorded, so a test can prove the parent was — or was never — woken by +/// a notice. +struct Scripted { + turns: Arc>>, + prompts: Arc>>, + /// The prompts of steers the mock consumed (delivered mid-run). + steered: Arc>>, +} + +impl Scripted { + fn new(turns: Vec) -> Arc { + Arc::new(Self { + turns: Arc::new(Mutex::new(turns.into())), + prompts: Arc::new(Mutex::new(Vec::new())), + steered: Arc::new(Mutex::new(Vec::new())), + }) + } + + fn run_count(&self) -> usize { + self.prompts.lock().unwrap().len() + } + + fn prompts_containing(&self, needle: &str) -> bool { + self.prompts + .lock() + .unwrap() + .iter() + .any(|p| p.contains(needle)) + } +} + +#[async_trait] +impl Harness for Scripted { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Scripted" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::StepBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + controls: RunControls, + ) -> Result>, HarnessError> { + self.prompts.lock().unwrap().push(request.prompt.clone()); + let turn = self + .turns + .lock() + .unwrap() + .pop_front() + .unwrap_or(Turn::Hang); + let session = format!("sess-{}", self.run_count()); + let (tx, mut rx) = tokio::sync::mpsc::channel::>(32); + let steered = self.steered.clone(); + let mut steering = controls.steering; + let request_input = controls.request_input; + let started_session = session.clone(); + let cwd = request.cwd.clone(); + tokio::spawn(async move { + let _ = tx + .send(Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd, + session_id: started_session, + assistant_message_id: "a0".into(), + })) + .await; + match turn { + Turn::End { status, error } => { + let _ = tx + .send(Ok(AgentEvent::TextDelta { + text: "side answer".into(), + })) + .await; + let _ = tx + .send(Ok(AgentEvent::Done { + status, + result: None, + error, + session_id: Some(session), + })) + .await; + } + Turn::AskInput { question } => { + // The engine's input bridge mints the id and parks the + // resolver before the event reaches the run loop, so a + // legitimate question is always known here. + let questions = vec![UserInputQuestion { + id: "q1".into(), + header: "Approval".into(), + question: question.into(), + options: vec!["Yes".into(), "No".into()], + multi_select: false, + }]; + // Awaiting the answer parks the turn: the chat goes to + // awaiting input and stays there. + let _ = (request_input)(questions).await; + let _ = tx + .send(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + })) + .await; + } + Turn::Hang => loop { + tokio::select! { + steer = steering.recv() => match steer { + Some(message) => { + steered.lock().unwrap().push(message.prompt.clone()); + // The steer's boundary, then the steered turn + // settles: the engine retires the accepted + // steer at the boundary and parks the turn at + // Done. + if tx + .send(Ok(AgentEvent::Steered { + assistant_message_id: None, + next_assistant_message_id: None, + })) + .await + .is_err() + { + break; + } + let _ = tx + .send(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + })) + .await; + break; + } + None => break, + }, + _ = tx.closed() => break, + } + }, + } + }); + Ok(rx.boxed()) + } +} + +fn run_request(prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(HarnessId::Mock), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: false, + resume: None, + attachments: vec![], + worktree: None, + } +} + +async fn wait_for(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +fn entries(core: &EngineCore, chat: &str) -> Vec { + core.doc_host + .open(chat) + .expect("chat doc") + .doc() + .read_entries() + .expect("transcript") +} + +/// The completion-notice entries in a chat: SYSTEM entries carrying the +/// marker — never user entries. +fn notice_entries(core: &EngineCore, chat: &str) -> Vec { + entries(core, chat) + .into_iter() + .filter(|entry| entry.role == MessageRole::System) + .filter(|entry| { + entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::CompletionNotice { .. })) + }) + .collect() +} + +fn notice_text(entry: &SessionMessageEntry) -> String { + entry + .parts + .iter() + .find_map(|part| match part { + MessagePart::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .unwrap_or_default() +} + +fn notice_sides(entry: &SessionMessageEntry) -> Vec { + entry + .parts + .iter() + .find_map(|part| match part { + MessagePart::CompletionNotice { sides, .. } => Some(sides.clone()), + _ => None, + }) + .unwrap_or_default() +} + +async fn wait_idle(core: &EngineCore, chat: &str) { + wait_for( + || { + core.sessions + .session_status(chat) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the chat to go idle", + ) + .await; +} + +/// Create a parent (with a cwd, so a notice turn can run on it) and an +/// agent-created side chat under it, titled, with `notify`. +async fn create_parent_and_side( + core: &EngineCore, + client: &roboco_rpc::RpcClient, + notify: Option, +) { + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": PARENT, + "deviceId": core.device_id, + "cwd": "/tmp", + }), + ) + .await + .expect("createChat parent"); + core.workspace.rename_chat(PARENT, "Orchestrator").unwrap(); + let mut agent_origin = serde_json::json!({ "chatId": PARENT }); + if let Some(notify) = notify { + agent_origin["notify"] = serde_json::json!(notify); + } + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": SIDE, + "deviceId": core.device_id, + "parentChatId": PARENT, + "agentOrigin": agent_origin, + }), + ) + .await + .expect("createChat side"); + core.workspace.rename_chat(SIDE, "Implement login").unwrap(); +} + +/// Queue a Run command for `chat`, optionally sent by the parent through the +/// MCP surface (`origin_chat_id`). +async fn queue_run( + client: &roboco_rpc::RpcClient, + chat: &str, + prompt: &str, + message_id: &str, + origin_chat_id: Option<&str>, +) { + let mut params = serde_json::json!({ + "chatId": chat, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Run { + request: run_request(prompt), + message_id: message_id.into(), + }) + .unwrap(), + }); + if let Some(origin) = origin_chat_id { + params["originChatId"] = serde_json::json!(origin); + } + client + .call(roboco_rpc::methods::QUEUE_COMMAND, params) + .await + .expect("queue run"); +} + +/// An idle parent is woken by a notice when its side chat's parent-started +/// turn completes: the notice is a system entry (never a user message) +/// carrying the marker, names the child by title and short id, points at +/// `read_chat`, and starts a parent turn on the parent's own harness. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_idle_parent_is_woken_when_its_side_chat_turn_completes() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's notice turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // The parent starts the child's turn through the MCP surface. + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_for( + || scripted.prompts_containing("please implement login"), + "the side chat's turn to start", + ) + .await; + + // The child's turn completes; the idle parent is woken by the notice. + wait_for( + || scripted.prompts_containing("finished"), + "the notice to start a parent turn", + ) + .await; + // The notice turn is the parent's SECOND run, on its own harness. + assert_eq!(scripted.run_count(), 2); + let notice_prompt = scripted.prompts.lock().unwrap()[1].clone(); + assert!(notice_prompt.contains("Side chat \"Implement login\"")); + assert!(notice_prompt.contains("read_chat")); + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1, "exactly one notice entry"); + let notice = ¬ices[0]; + // Engine/system message, not a user message, and engine-authored (no + // originating chat). + assert_eq!(notice.role, MessageRole::System); + assert_eq!(notice.origin_chat_id, None); + assert_eq!(notice.status, Some(roboco_doc::MessageStatus::Complete)); + // The marker clients render distinctly (ticket 15). + assert_eq!( + notice_sides(notice), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + // The plain-text sibling names the child and points at read_chat. + let text = notice_text(notice); + let short = &SIDE[..8]; + assert!(text.contains(&format!("\"Implement login\" ({short}) finished"))); + assert!(text.contains("read_chat")); + assert!(text.contains(short)); + + // The parent's notice turn ran and settled. + wait_idle(&core, PARENT).await; + // The child's own transcript keeps its answer, unrun by the parent. + assert!(entries(&core, SIDE) + .iter() + .any(|e| e.parts.iter().any(|p| matches!(p, MessagePart::Text { text, .. } if text == "side answer")))); + + core.shutdown().await; +} + +/// A parent-started turn that errors notifies with the one-line reason — +/// the first line of the engine's recorded error, capped. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_errored_side_chat_turn_notifies_the_parent_with_the_reason() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + Turn::End { + status: DoneStatus::Errored, + error: Some( + "provider 502: bad gateway\nstream failed mid-turn\nretry later".into(), + ), + }, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_for( + || scripted.prompts_containing("failed"), + "the failure notice to start a parent turn", + ) + .await; + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + let sides = notice_sides(¬ices[0]); + assert_eq!( + sides, + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Errored, + // One line: the first line of the recorded error only. + detail: Some("provider 502: bad gateway".into()), + }] + ); + let text = notice_text(¬ices[0]); + assert!(text.contains("failed: provider 502: bad gateway")); + assert!(!text.contains("retry later")); + assert!(text.contains("read_chat")); + + core.shutdown().await; +} + +/// A parent-started turn that is stopped or interrupted notifies as +/// stopped. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_stopped_side_chat_turn_notifies_the_parent() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + Turn::End { + status: DoneStatus::Interrupted, + error: None, + }, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_for( + || scripted.prompts_containing("was stopped"), + "the stopped notice to start a parent turn", + ) + .await; + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + let sides = notice_sides(¬ices[0]); + assert_eq!( + sides, + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Stopped, + detail: None, + }] + ); + assert!(notice_text(¬ices[0]).contains("was stopped")); + + core.shutdown().await; +} + +/// A parent-started turn that reaches awaiting input notifies with a +/// one-line summary of the question it is waiting on. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_awaiting_input_side_chat_turn_notifies_with_the_question() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + Turn::AskInput { + question: "Should we bump the major version?", + }, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, SIDE, "please review the API", "m-run-1", Some(PARENT)).await; + // The child parks on its question; the parent is woken by the notice. + wait_for( + || { + core.sessions + .session_status(SIDE) + .is_some_and(|s| s.status == SessionStatus::AwaitingInput) + }, + "the side chat to await input", + ) + .await; + wait_for( + || scripted.prompts_containing("waiting for input"), + "the waiting-for-input notice to start a parent turn", + ) + .await; + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + let sides = notice_sides(¬ices[0]); + assert_eq!( + sides, + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::WaitingForInput, + detail: Some("Should we bump the major version?".into()), + }] + ); + let text = notice_text(¬ices[0]); + assert!(text.contains("is waiting for input: Should we bump the major version?")); + assert!(text.contains("read_chat")); + + core.shutdown().await; +} + +/// A turn the USER started in the child (no parent-originated message) +/// produces no notice: the parent is never woken. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_user_started_turn_in_the_child_produces_no_notice() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // A typed send: no origin chat on the command or the entry. + queue_run(&client, SIDE, "typed by the user", "m-run-1", None).await; + wait_idle(&core, SIDE).await; + // Let any (wrongly recorded) notice delivery settle before asserting + // absence. + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "a user-started turn must not wake the parent" + ); + assert!(entries(&core, PARENT).is_empty()); + assert_eq!( + scripted.run_count(), + 1, + "only the side chat's turn ran — no parent turn" + ); + // The typed send carries no origin on its user entry. + assert!(entries(&core, SIDE) + .iter() + .all(|e| e.origin_chat_id.is_none())); + + core.shutdown().await; +} + +/// A user-created side chat (no agent origin on its row) produces no notice +/// even when a chat-originated message reaches it: the durable creator row +/// is what makes turns notifiable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_user_created_side_chat_produces_no_notice() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + // A side chat with a parent but NO agent origin: the UI's blank side + // chat shape. + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": PARENT, + "deviceId": core.device_id, + "cwd": "/tmp", + }), + ) + .await + .expect("createChat parent"); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": SIDE, + "deviceId": core.device_id, + "parentChatId": PARENT, + }), + ) + .await + .expect("createChat blank side chat"); + + // Even a parent-originated message does not make its turns notifiable: + // there is no durable creator row to key on. + queue_run(&client, SIDE, "sent by a chat anyway", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "a user-created side chat must never notify" + ); + assert_eq!(scripted.run_count(), 1, "no parent turn was started"); + + core.shutdown().await; +} + +/// A `notify: false` side chat (fire-and-forget) records no outcomes for its +/// creator: the opt-out at creation is durable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_notify_false_side_chat_produces_no_notice() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, Some(false)).await; + assert_eq!( + core.workspace.chat(SIDE).unwrap().unwrap().agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: false, + }) + ); + + queue_run(&client, SIDE, "fire and forget", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "a notify: false child must never wake its creator" + ); + assert_eq!(scripted.run_count(), 1, "no parent turn was started"); + + core.shutdown().await; +} + +/// A steer the parent sent WITHIN a user-started turn makes that turn +/// notifiable: the notice fires when the steered turn completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_parent_steer_within_the_turn_makes_it_notifiable() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The user starts the child's turn; it hangs. + Turn::Hang, + // The parent's notice turn, after the steered turn completes. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let registry = HarnessRegistry::new(); + registry.register(scripted.clone()); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // The user's opening turn — no origin, not notifiable by itself. + queue_run(&client, SIDE, "typed by the user", "m-open", None).await; + wait_for( + || scripted.prompts_containing("typed by the user"), + "the user's turn to start", + ) + .await; + wait_for( + || { + core.sessions + .session_status(SIDE) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the child to be working", + ) + .await; + + // The parent steers mid-run: the steer joins the turn in flight. + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": SIDE, + "command": serde_json::to_value(roboco_doc::SessionCommandPayload::Steer { + prompt: "use the other approach".into(), + message_id: Some("m-steer-1".into()), + }) + .unwrap(), + "originChatId": PARENT, + }), + ) + .await + .expect("queue steer"); + // The mock consumes the steer and emits its Steered boundary. + wait_for( + || { + scripted + .steered + .lock() + .unwrap() + .iter() + .any(|p| p.contains("use the other approach")) + }, + "the steer to be consumed", + ) + .await; + // The user entry the steer wrote carries the parent's origin. + wait_for( + || { + entries(&core, SIDE) + .iter() + .any(|e| e.origin_chat_id.as_deref() == Some(PARENT)) + }, + "the steer's user entry to land", + ) + .await; + + // The steered turn completes at the mock's Done: the parent is woken by + // the completion notice. + wait_for( + || scripted.prompts_containing("finished"), + "the completion notice to start a parent turn", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + assert_eq!(scripted.run_count(), 2); + + core.shutdown().await; +} diff --git a/crates/roboco-mcp/src/transcript.rs b/crates/roboco-mcp/src/transcript.rs index 929a218f00..96eb8fdb30 100644 --- a/crates/roboco-mcp/src/transcript.rs +++ b/crates/roboco-mcp/src/transcript.rs @@ -146,9 +146,11 @@ fn render_one(entry: &SessionMessageEntry, options: RenderOptions) -> RenderedMe } } MessagePart::Error { message, .. } => errors.push(message.clone()), - // The fork seam is a transcript marker, not agent content: an - // orchestrator reads the copied history as ordinary turns. - MessagePart::Fork { .. } => {} + // The fork seam and the completion notice are transcript + // markers, not agent content: an orchestrator reads the copied + // history as ordinary turns, and the notice's own text part + // already rendered above. + MessagePart::Fork { .. } | MessagePart::CompletionNotice { .. } => {} } } RenderedMessage { diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index ed7ff998c7..05470cffcc 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -1605,6 +1605,11 @@ pub fn rows_for_entry( // intake landed first): the part persists in the doc and // simply renders no row here until the UI port lands. MessagePart::Image { .. } => {} + // The engine's completion notice renders through its + // plain-text sibling part for now (orchestration v2 + // ticket 15 owns the distinct system-notice row); the + // marker itself is structure, not content. + MessagePart::CompletionNotice { .. } => {} } } } diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index 3691a28cdd..e0de920a58 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -264,6 +264,8 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_doc::ToolDiffStat, roboco_doc::MessageStatus, roboco_doc::SubagentStatus, + roboco_doc::NoticeOutcome, + roboco_doc::NoticeSide, roboco_doc::MessagePart, roboco_doc::SessionCommandKind, roboco_doc::SessionCommandStatus, diff --git a/web/packages/proto/src/generated/MessagePart.ts b/web/packages/proto/src/generated/MessagePart.ts index 0445422de4..3c61c204bd 100644 --- a/web/packages/proto/src/generated/MessagePart.ts +++ b/web/packages/proto/src/generated/MessagePart.ts @@ -1,5 +1,6 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +import type { NoticeSide } from "./NoticeSide"; import type { SubagentStatus } from "./SubagentStatus"; import type { ToolCall } from "./ToolCall"; import type { ToolDiff } from "./ToolDiff"; @@ -67,4 +68,11 @@ subagentTail?: string | null, } | { "kind": "input", id: string, requestId: stri * without a registry lookup, and a later rename or delete of the * source does not rewrite history. */ -sourceTitle: string, }; +sourceTitle: string, } | { "kind": "completionNotice", id: string, +/** + * One row per side chat the notice reports (a single outcome when + * one side chat finished; several when outcomes merged — ticket 13). + * Defaults for leniency: a partial row reads as an empty notice + * rather than a decode failure. + */ +sides: Array, }; diff --git a/web/packages/proto/src/generated/NoticeOutcome.ts b/web/packages/proto/src/generated/NoticeOutcome.ts new file mode 100644 index 0000000000..929c8825fa --- /dev/null +++ b/web/packages/proto/src/generated/NoticeOutcome.ts @@ -0,0 +1,7 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * What the engine recorded when a notifiable side-chat turn came to rest + * (orchestration v2). The outcome of one row of a completion notice. + */ +export type NoticeOutcome = "completed" | "errored" | "stopped" | "waitingForInput"; diff --git a/web/packages/proto/src/generated/NoticeSide.ts b/web/packages/proto/src/generated/NoticeSide.ts new file mode 100644 index 0000000000..2de3c92a6f --- /dev/null +++ b/web/packages/proto/src/generated/NoticeSide.ts @@ -0,0 +1,26 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +import type { NoticeOutcome } from "./NoticeOutcome"; + +/** + * One side chat a completion notice reports: its id and title, the + * recorded outcome, and the one-line reason (error text or question + * summary) when the outcome carries one. + */ +export type NoticeSide = { +/** + * The side chat's full id — clients open the chat with it; the notice's + * text names the short form. + */ +chatId: string, +/** + * The side chat's title as of the notice, so the transcript reads it + * without a registry lookup (a later rename never rewrites history). + */ +title: string, +outcome: NoticeOutcome, +/** + * The one-line detail for errored (reason) and waiting-for-input + * (question summary) rows; absent otherwise. + */ +detail?: string | null, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 908b430388..3ab47bbf51 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -73,6 +73,8 @@ export * from "./ModelOption"; export * from "./ModelOptionChoice"; export * from "./MoveWorkspaceEntryRequest"; export * from "./MutateParams"; +export * from "./NoticeOutcome"; +export * from "./NoticeSide"; export * from "./PairedSession"; export * from "./PairingLink"; export * from "./PreviewService"; From f34bd73c359a27a32dc10e8445105d1fdf880c26 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 16:41:15 +0700 Subject: [PATCH 18/96] feat(engine): mark the harness account signed out from a run's error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire ticket 08's account state to ticket 05's structured error kind: when a run's AgentEvent::Error carries kind signedOut (directly, or tagged inside a subagent event — the child runs on the same login), the engine resolves the harness's live login and marks its account signed out on the harness-accounts store the RPCs list. The mark is best-effort (logged, never failing the run), once per run, and lands before the run's terminal state settles. - SessionsEngine gains the accounts hook (set at assembly, replaceable so a test's isolated store swaps in over the detected one); Inner::note_run_signed_out drives AgentAccounts::mark_live_signed_out, which lists first so the live login's slot exists even when no accounts view ever ran, then marks. - AgentAccounts::with_mocked_endpoints: doc-hidden test seam mirroring override_cli — every provider endpoint on one local mock server, the same set the in-crate provider tests build on (mocked_endpoints, moved up from provider_tests). - Engine integration tests (account_sign_in_state.rs, on the ui-tests engine line): a scripted Claude-shaped harness emits the same classified error the Claude driver attaches to authentication_failed; the run marks the active account signed out through ListAgentAccounts, and a later successful probe against the mock usage endpoint flips it back to signed-in. A second test covers the subagent-tagged shape. Not run locally per the ticket override; CI verifies. --- .github/workflows/ui-tests.yml | 2 +- crates/engine/src/agent_accounts.rs | 55 ++++ .../src/agent_accounts/provider_tests.rs | 20 +- crates/engine/src/lib.rs | 3 + crates/engine/src/sessions.rs | 69 ++++- crates/engine/tests/account_sign_in_state.rs | 277 ++++++++++++++++++ 6 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 crates/engine/tests/account_sign_in_state.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 6de999df53..0a75742b6b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 7a23bb4a0e..6ffd6964ab 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -774,6 +774,27 @@ impl Default for ProbeEndpoints { } } +/// Every endpoint on one mock server, with slot refresh and loopback http +/// admitted — the set [`AgentAccounts::with_mocked_endpoints`] and the +/// in-crate provider tests build their mocks on. +fn mocked_endpoints(base: &str) -> ProbeEndpoints { + ProbeEndpoints { + claude_usage: format!("{base}/api/oauth/usage"), + claude_token: format!("{base}/v1/oauth/token"), + claude_loopback_token: format!("{base}/v1/oauth/token"), + claude_profile: format!("{base}/api/oauth/profile"), + codex_usage: format!("{base}/backend-api/wham/usage"), + grok_usage: format!("{base}/v1/billing?format=credits"), + github_api: base.to_string(), + github_login: base.to_string(), + openai_auth: base.to_string(), + openai_port: 0, + nous_portal: base.to_string(), + allow_slot_refresh: true, + allow_loopback_http: true, + } +} + /// The live Claude credential read, cached briefly per identity (see /// [`AgentAccounts::read_claude_credentials_cached`]). struct CachedClaudeCredentials { @@ -929,6 +950,15 @@ impl AgentAccounts { lock(&self.inner.cli_overrides).insert(harness, path.into()); } + /// Test seam: [`Self::new`] with every provider endpoint pointed at + /// `base` (a local mock server) instead of the real providers — the same + /// endpoint set the in-crate provider tests use. Integration tests drive + /// usage probes and token flows against it; never use in production. + #[doc(hidden)] + pub fn with_mocked_endpoints(config: AgentAccountsConfig, base: &str) -> Self { + Self::with_endpoints(config, mocked_endpoints(base)) + } + /// The ACP harness for `harness`'s CLI, honouring [`Self::override_cli`]. fn acp_harness(&self, harness: HarnessId) -> Option { let acp = match harness { @@ -1538,6 +1568,31 @@ impl AgentAccounts { Ok(()) } + /// [`Self::mark_account_signed_out`] for a RUN's signed-out error + /// (ticket 05's structured kind, wired by the sessions engine): resolve + /// the CLI's LIVE login for `harness` and mark ITS account — the account + /// every new run on this harness would use. The list first + /// auto-snapshots the live login into its slot, so the row exists even + /// when no accounts view ever ran. Single-login agents resolve + /// unambiguously; a per-provider agent's login names its provider, which + /// the caller must resolve before this can mark anything (no driver + /// classifies those signed-out yet). + pub async fn mark_live_signed_out(&self, harness: HarnessId) -> Result<(), EngineError> { + let snapshot = self.list(false).await?; + let row = snapshot + .accounts + .iter() + .find(|account| account.harness == harness && account.active) + .ok_or_else(|| { + EngineError::Other(format!( + "no live {} login to mark signed out", + harness_slug(harness) + )) + })?; + let account_id = row.id.clone(); + self.mark_account_signed_out(harness, &account_id) + } + // ── add-account OAuth flows ───────────────────────────────────────────── pub async fn start_login(&self, harness: HarnessId) -> Result { diff --git a/crates/engine/src/agent_accounts/provider_tests.rs b/crates/engine/src/agent_accounts/provider_tests.rs index 6f82e78646..802bd79165 100644 --- a/crates/engine/src/agent_accounts/provider_tests.rs +++ b/crates/engine/src/agent_accounts/provider_tests.rs @@ -140,23 +140,11 @@ fn accounts_with(root: &Path, endpoints: ProbeEndpoints) -> (AgentAccounts, Agen ) } -/// Endpoints that all point at `base` (a mock), with slot refresh allowed. +/// Endpoints that all point at `base` (a mock), with slot refresh allowed — +/// [`mocked_endpoints`] in the parent, kept as the local spelling the flows +/// read best in. fn mocked(base: &str) -> ProbeEndpoints { - ProbeEndpoints { - claude_usage: format!("{base}/api/oauth/usage"), - claude_token: format!("{base}/v1/oauth/token"), - claude_loopback_token: format!("{base}/v1/oauth/token"), - claude_profile: format!("{base}/api/oauth/profile"), - codex_usage: format!("{base}/backend-api/wham/usage"), - grok_usage: format!("{base}/v1/billing?format=credits"), - github_api: base.to_string(), - github_login: base.to_string(), - openai_auth: base.to_string(), - openai_port: 0, - nous_portal: base.to_string(), - allow_slot_refresh: true, - allow_loopback_http: true, - } + mocked_endpoints(base) } fn rows(snapshot: &AgentAccountsSnapshot, harness: HarnessId) -> Vec { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3eb56ae44..3d457f71af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -269,6 +269,9 @@ impl EngineCore { // login between engines, so no route is registered in production. let agent_accounts = AgentAccounts::with_callback_routes(agent_accounts_config, previews.callback_routes()); + // Runs that fail signed-out mark the harness's live login on the + // same store the accounts RPCs list (ticket 08). + sessions.set_agent_accounts(agent_accounts.clone()); let harness_updates = harness_updates::HarnessUpdateCoordinator::new(data_dir, registry.clone()); harness_updates.start(); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index ad034a815a..f32c2fa047 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -30,8 +30,8 @@ use roboco_doc::{ }; use roboco_harness::{CancellationToken, Harness, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionStatus, UserInputAnswer, - UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionStatus, + UserInputAnswer, UserInputQuestion, }; use crate::doc_host::{ChatDocHandle, DocHost}; @@ -205,6 +205,11 @@ struct Inner { /// dispatch or accepted steer) — the diff sync snapshots the checkout tree /// for the Changes pane's "Latest turn" scope. Absent in bare tests. turn_listener: OnceLock, + /// Harness accounts (engine assembly; absent in bare tests): a run that + /// fails with a signed-out error marks the harness's live login on them. + /// A `Mutex` (not `OnceLock`) so a test's isolated store can swap in over + /// the detected one — the marking and the RPC list must see one store. + agent_accounts: Mutex>, } /// Turn-start hook: called with `(chat_id, cwd)`. @@ -244,6 +249,7 @@ impl SessionsEngine { titles: OnceLock::new(), generated_images: OnceLock::new(), turn_listener: OnceLock::new(), + agent_accounts: Mutex::new(None), }), } } @@ -294,6 +300,13 @@ impl SessionsEngine { let _ = self.inner.turn_listener.set(listener); } + /// Wire the harness accounts (called once at engine assembly; a test + /// swaps in an isolated store through the same call). A run that fails + /// with a signed-out error marks the harness's live login on them. + pub fn set_agent_accounts(&self, accounts: crate::agent_accounts::AgentAccounts) { + *lock(&self.inner.agent_accounts) = Some(accounts); + } + fn note_turn_start(&self, chat_id: &str, cwd: &str) { if let Some(listener) = self.inner.turn_listener.get() { listener(chat_id, cwd); @@ -1333,6 +1346,26 @@ impl Inner { .collect() } + /// A run failed with ticket 05's structured signed-out error: mark the + /// harness's live login's account signed out (ticket 08) — the account + /// every new run on this harness would use, so the account switcher + /// stops showing it as healthy. Best-effort by design: a marking failure + /// is logged and never disturbs the run's own error path, and the next + /// sign-in or successful usage probe clears the mark. + async fn note_run_signed_out(&self, chat_id: &str, harness: HarnessId) { + let Some(accounts) = lock(&self.agent_accounts).clone() else { + return; + }; + if let Err(err) = accounts.mark_live_signed_out(harness).await { + tracing::warn!( + chat = %chat_id, + provider = ?harness, + error = %err, + "could not mark the harness account signed out" + ); + } + } + fn publish(&self, chat_id: &str, event: &AgentEvent) -> u64 { let seq = match self.journal.append(chat_id, event) { Ok(seq) => seq, @@ -2094,6 +2127,23 @@ fn cursor_unstarted_history( )) } +/// Whether `event` carries ticket 05's signed-out error kind — directly, or +/// tagged inside a subagent event (a subagent runs on the same login, so its +/// rejection is equally hard evidence the login is dead). +fn event_signs_out(event: &AgentEvent) -> bool { + let leaf = match event { + AgentEvent::Subagent { event, .. } => event.as_ref(), + event => event, + }; + matches!( + leaf, + AgentEvent::Error { + kind: Some(AgentErrorKind::SignedOut), + .. + } + ) +} + #[allow(clippy::too_many_arguments)] async fn drive_run( inner: Arc, @@ -2286,6 +2336,10 @@ async fn drive_run( let mut interrupt_deadline: Option = None; let mut interrupted = false; let mut saw_session_started = false; + // One signed-out mark per run: repeats and retries all describe the same + // dead login, and each marking pass resolves the live login (a detection + // sweep) — once is enough. + let mut marked_signed_out = false; // Liveness heartbeat: this loop RUNNING is proof the harness stream is // open, so freshness must not depend on events arriving. Silent stretches // are normal and UNBOUNDED — a long tool call, redacted thinking, an @@ -2624,6 +2678,17 @@ async fn drive_run( event }; + // Sign-out evidence (ticket 05's structured kind, ticket 08's + // marking): the harness rejected its login — the login is dead, not + // stale. Checked BEFORE the subagent routing and the parked gate so + // every arrival shape counts (a subagent running on the same login + // fails signed-out just as hard), and awaited inline so the mark has + // landed by the time the run's own terminal state settles. + if !marked_signed_out && event_signs_out(&event) { + marked_signed_out = true; + inner.note_run_signed_out(&chat_id, harness_id).await; + } + // ── subagent routing ─────────────────────────────────────────── // Tagged events NEVER fold into the parent transcript: they stream // into the subagent's own doc, and the parent keeps only the spawn diff --git a/crates/engine/tests/account_sign_in_state.rs b/crates/engine/tests/account_sign_in_state.rs new file mode 100644 index 0000000000..71b626fd95 --- /dev/null +++ b/crates/engine/tests/account_sign_in_state.rs @@ -0,0 +1,277 @@ +//! Harness-account sign-in state driven from RUNS (orchestration v2, +//! ticket 08). +//! +//! Ticket 05 gave `AgentEvent::Error` a structured kind; this is the wiring +//! that consumes it: a run whose error is classified signed-out marks the +//! harness's LIVE login's account signed out (visible through +//! `ListAgentAccounts`, cached meters and all), and a later successful +//! usage probe flips it back to signed-in. The scripted harness carries the +//! same classification ticket 05's Claude driver attaches to +//! `authentication_failed`, so the engine-side behavior is covered without +//! a real CLI. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_engine::{AgentAccounts, AgentAccountsConfig, EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, + SandboxLevel, SessionStatus, SteeringMode, +}; +use roboco_rpc::methods; + +const CHAT: &str = "chat-signed-out"; + +/// A Claude-shaped harness whose scripted run fails with ticket 05's +/// structured signed-out kind. `nested` wraps the error in a subagent event +/// instead — a subagent runs on the same login, so its rejection is equally +/// hard evidence the login is dead. +struct SignedOutHarness { + nested: bool, +} + +#[async_trait] +impl Harness for SignedOutHarness { + fn id(&self) -> HarnessId { + HarnessId::ClaudeCode + } + fn display_name(&self) -> &str { + "Claude Code" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + let signed_out = AgentEvent::Error { + message: "Authentication failed — sign in to Claude again.".into(), + kind: Some(AgentErrorKind::SignedOut), + }; + let error = if self.nested { + AgentEvent::Subagent { + parent_tool_use_id: "sub-signout".into(), + event: Box::new(signed_out), + } + } else { + signed_out + }; + let events = vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::ClaudeCode, + model: "claude-test".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: "sess-signout".into(), + assistant_message_id: "a-signout".into(), + }), + Ok(error), + Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("Authentication failed — sign in to Claude again.".into()), + session_id: Some("sess-signout".into()), + }), + ]; + Ok(futures::stream::iter(events).boxed()) + } +} + +/// A local stand-in for Anthropic's usage endpoint: every probe answered +/// with a green usage body, so a forced list re-establishes a marked +/// account as signed-in. +async fn usage_server() -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind mock usage server"); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + // Drain the request (a headers-only probe GET). + let mut request = vec![0u8; 16 * 1024]; + let _ = socket.read(&mut request).await.unwrap_or(0); + let body = + r#"{"five_hour":{"utilization":6.0,"resets_at":"2026-10-18T08:00:00Z"}}"#; + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \ + {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(reply.as_bytes()).await; + let _ = socket.shutdown().await; + }); + } + }); + base +} + +/// A Claude Code login (config + credentials) under the isolated `config`. +fn write_claude_login(config: &AgentAccountsConfig, uuid: &str, email: &str, token: &str) { + std::fs::create_dir_all(&config.claude_config_dir).expect("claude config dir"); + std::fs::write( + &config.claude_config_file, + serde_json::json!({ + "oauthAccount": { "accountUuid": uuid, "emailAddress": email }, + }) + .to_string(), + ) + .expect("claude config"); + std::fs::write( + config.claude_config_dir.join(".credentials.json"), + serde_json::json!({ + "claudeAiOauth": { + "accessToken": token, + "refreshToken": format!("refresh-{token}"), + // Far-future expiry: probes must never try to rotate it. + "expiresAt": 4_102_444_800_000i64, + } + }) + .to_string(), + ) + .expect("claude creds"); +} + +fn run_request(prompt: &str) -> RunRequest { + RunRequest { + mcp: None, + prompt: prompt.into(), + harness: Some(HarnessId::ClaudeCode), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: false, + resume: None, + attachments: vec![], + worktree: None, + } +} + +/// The engine under test: a Claude login live in an isolated account store +/// (whose usage endpoint is the local mock), and the signed-out harness +/// registered under `claude-code`. The SAME account store is wired into the +/// sessions engine (the run-error marking) and the RPC surface +/// (`ListAgentAccounts`). +async fn setup(nested: bool) -> (EngineCore, roboco_rpc::RpcClient) { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.keep(); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(SignedOutHarness { nested })); + let core = + EngineCore::assemble(&root, Arc::new(registry), HarnessId::ClaudeCode).expect("engine"); + + let config = AgentAccountsConfig::isolated(&root); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + let base = usage_server().await; + let accounts = AgentAccounts::with_mocked_endpoints(config, &base); + // One store, two handles: the RPC lists from it, the marking writes to + // it — replacing the detected store `assemble` wired in. + core.agent_accounts = accounts.clone(); + core.sessions.set_agent_accounts(accounts); + let client = roboco_rpc::memory_client(core.rpc_service()); + (core, client) +} + +async fn wait_for(mut predicate: impl FnMut() -> bool, what: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// The signed-in state of the Claude row, as `ListAgentAccounts` reports it. +async fn claude_sign_in_state(client: &roboco_rpc::RpcClient, force: bool) -> String { + let snapshot = client + .call( + methods::LIST_AGENT_ACCOUNTS, + serde_json::json!({ "forceUsage": force }), + ) + .await + .expect("ListAgentAccounts"); + snapshot["accounts"] + .as_array() + .expect("accounts") + .iter() + .find(|account| account["harness"] == "claude-code") + .map(|account| account["signInState"].as_str().expect("signInState").into()) + .expect("a claude-code account row") +} + +async fn wait_for_status(core: &EngineCore, chat: &str, status: SessionStatus) { + wait_for( + || { + core.sessions + .session_status(chat) + .is_some_and(|s| s.status == status) + }, + "the chat to settle", + ) + .await; +} + +/// A run error of kind signed-out marks the harness's active account signed +/// out — the row says so through `ListAgentAccounts` even though its cached +/// meters never left — and a later successful usage probe marks it signed +/// in again. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_signed_out_run_error_marks_the_active_account_until_a_probe_clears_it() { + let (core, client) = setup(false).await; + core.doc_host.open(CHAT).expect("chat doc"); + + // Before any run: the live login is unknown (never probed). + assert_eq!(claude_sign_in_state(&client, false).await, "unknown"); + + core.sessions + .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None) + .await + .expect("dispatch"); + wait_for_status(&core, CHAT, SessionStatus::Errored).await; + + // The run's error marked the LIVE account signed out — no probe ran + // (the mark is offline), so this is the run's evidence, not a probe's. + assert_eq!(claude_sign_in_state(&client, false).await, "signed-out"); + + // A later successful probe re-establishes the account: the forced list + // probes the mock usage endpoint, which answers green. + assert_eq!(claude_sign_in_state(&client, true).await, "signed-in"); +} + +/// A subagent's signed-out error marks the shared login just the same: the +/// child runs on the same credentials the parent does. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_subagents_signed_out_error_marks_the_shared_login_too() { + let (core, client) = setup(true).await; + core.doc_host.open(CHAT).expect("chat doc"); + + assert_eq!(claude_sign_in_state(&client, false).await, "unknown"); + core.sessions + .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None) + .await + .expect("dispatch"); + wait_for_status(&core, CHAT, SessionStatus::Errored).await; + + assert_eq!(claude_sign_in_state(&client, false).await, "signed-out"); +} From b0774b920c6a340447849215070b6071c9b44042 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 16:43:46 +0700 Subject: [PATCH 19/96] fix(engine): give the sign-in test request the provider fork field --- crates/engine/tests/account_sign_in_state.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/tests/account_sign_in_state.rs b/crates/engine/tests/account_sign_in_state.rs index 71b626fd95..46e1f01759 100644 --- a/crates/engine/tests/account_sign_in_state.rs +++ b/crates/engine/tests/account_sign_in_state.rs @@ -163,6 +163,7 @@ fn run_request(prompt: &str) -> RunRequest { resume: None, attachments: vec![], worktree: None, + provider_fork: None, } } From f286ef6b8d8180d72052b7908b83372a0dd0a9ab Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 16:46:16 +0700 Subject: [PATCH 20/96] feat(forks): fork from any assistant reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 21 (t3-orchestration-v2): every assistant reply on desktop — errored and interrupted ones included, not just the latest complete one — offers "Fork from here". - ForkSideChat RPC gains an optional `boundary` (entry id): the copy stops at that reply, inclusive, and that entry's provider turn ref becomes the native fork boundary. Absent, today's behaviour stays (the latest complete reply). A logical message copies whole — the boundary walks its continuation fragments, and the ref comes from the root. - The boundary plumbing stays harness-agnostic: the ProviderFork intent already carries (session, last_turn), so Codex (ticket 17) forks at the earlier turn and tickets 18-20 plug in unchanged. The budgeted history is cut at the boundary either way, because the fork's doc only holds the copied prefix. - Desktop: a git-branch hover action beside Copy message on the last settled row of every assistant reply (subagent doc tabs never offer it), emitting TranscriptEvent::ForkFromEntry; the shell forks the chat the reply lives in (a side chat's fork lands as a sibling). The tooltip notes files are not rewound. Engine tests: earlier-boundary cut + native fork + fallback cut, latest-reply default unchanged, errored/interrupted boundaries, invalid boundaries refused, ref-less boundary pastes the cut budget, split messages copy whole. UI rows test: the action rides every settled assistant reply and nothing else. Not run locally (CI runs them). --- crates/engine/src/rpc.rs | 124 +++++-- crates/engine/tests/side_chats.rs | 549 ++++++++++++++++++++++++++++++ crates/rpc/src/lib.rs | 5 +- crates/ui/src/shell.rs | 26 +- crates/ui/src/shell/side_chats.rs | 25 +- crates/ui/src/transcript.rs | 195 ++++++++++- 6 files changed, 889 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 882759e21a..21d4b67c75 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1521,6 +1521,15 @@ impl RpcService for EngineRpc { /// side chat's parent so the copy lists as a sibling. #[serde(default)] parent_chat_id: Option, + /// The assistant entry to fork at (t3-orchestration-v2 + /// ticket 21, "Fork from here"): the copy stops at that + /// reply, inclusive, and its provider turn ref becomes + /// the native fork boundary. Any SETTLED assistant entry + /// qualifies — an earlier one, an errored one, an + /// interrupted one. Absent (the classic fork button) + /// keeps today's behaviour: the latest complete reply. + #[serde(default)] + boundary: Option, } let p: ForkParams = parse_params(params)?; let parent_chat_id = p @@ -1550,17 +1559,48 @@ impl RpcService for EngineRpc { .doc() .read_entries() .map_err(|e| RpcError::Failed(e.to_string()))?; - let boundary = entries - .iter() - .rposition(|entry| { - entry.role == roboco_doc::MessageRole::Assistant - && entry.status == Some(roboco_doc::MessageStatus::Complete) - }) - .ok_or_else(|| { - RpcError::Failed( - "Wait for a completed response before starting a side chat".into(), - ) - })?; + // The reply the copy stops at, inclusive. An explicit + // boundary is any settled assistant entry — never a + // streaming one (the run is still writing it, and its turn + // ref is not stamped yet); the default stays the latest + // COMPLETE assistant reply, exactly as before this change. + let chosen = match p + .boundary + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + Some(id) => entries + .iter() + .position(|entry| entry.id == id) + .filter(|&ix| { + entries[ix].role == roboco_doc::MessageRole::Assistant + && entries[ix].status != Some(roboco_doc::MessageStatus::Streaming) + }) + .ok_or_else(|| { + RpcError::Failed( + "Fork boundary must be a settled assistant reply".into(), + ) + })?, + None => entries + .iter() + .rposition(|entry| { + entry.role == roboco_doc::MessageRole::Assistant + && entry.status == Some(roboco_doc::MessageStatus::Complete) + }) + .ok_or_else(|| { + RpcError::Failed( + "Wait for a completed response before starting a side chat".into(), + ) + })?, + }; + // A logical message can span continuation fragments after + // its root (schema capability for oversized replies): the + // fork copies the WHOLE message the chosen entry belongs + // to, and the native boundary ref comes from its root — + // where a turn ref is stamped. + let boundary = logical_message_end(&entries, chosen); + let ref_entry = logical_message_root(&entries, chosen); let mut chat = source.clone(); chat.id = p.chat_id; chat.parent_chat_id = Some(parent_chat_id); @@ -1627,16 +1667,21 @@ impl RpcService for EngineRpc { }) .map_err(|e| RpcError::Failed(e.to_string()))?; } - // Native fork intent (t3-orchestration-v2 ticket 17): when the - // boundary entry carries a provider turn ref, the fork records - // the source provider session and the boundary on the new chat — - // its first run asks the harness to fork the source session at - // that turn (Codex `thread/fork {threadId, lastTurnId}`) - // instead of resuming it, so the copied history rides the - // provider and nothing is pasted. No ref — old chats, or a - // harness that records none — keeps today's behavior: a fresh - // session plus the budgeted paste. - if let Some(reference) = entries[boundary].provider_turn.clone() { + // Native fork intent (t3-orchestration-v2 tickets 17+21): + // when the boundary entry carries a provider turn ref, the + // fork records the source provider session and the boundary + // on the new chat — its first run asks the harness to fork + // the source session at that turn (Codex `thread/fork + // {threadId, lastTurnId}`) instead of resuming it, so the + // copied history rides the provider and nothing is pasted. + // The boundary is whatever reply the caller chose — the + // latest by default, an earlier/errored/interrupted one + // explicitly — so every harness's driver (17–20) forks at + // exactly that turn. No ref — old chats, or a harness that + // records none — keeps today's behavior: a fresh session + // plus the budgeted paste, cut at the boundary because the + // fork's own doc only holds the copied prefix. + if let Some(reference) = entries[ref_entry].provider_turn.clone() { target .doc() .set_provider_fork( @@ -3077,6 +3122,43 @@ impl RpcService for EngineRpc { } } +/// The index of the ROOT entry of the logical message `at` belongs to: an +/// oversized relayed reply can split into continuation fragments +/// (`root#c1`, …) after its root, and the whole chain is one message +/// (`join_continuation_entries`). An orphan fragment (root missing) is its +/// own root. +fn logical_message_root(entries: &[roboco_doc::SessionMessageEntry], at: usize) -> usize { + let mut root = at; + loop { + let Some(parent) = entries[root].continuation_of.as_deref() else { + return root; + }; + match root + .checked_sub(1) + .filter(|&prev| entries[prev].id.as_str() == parent) + { + Some(prev) => root = prev, + None => return root, + } + } +} + +/// The inclusive index of the LAST entry of the logical message `at` +/// belongs to — the end a fork copies through, so a split message never +/// copies as a truncated prefix of itself. +fn logical_message_end(entries: &[roboco_doc::SessionMessageEntry], at: usize) -> usize { + let root = logical_message_root(entries, at); + let root_id = entries[root].id.as_str(); + let mut end = root; + while entries + .get(end + 1) + .is_some_and(|next| next.continuation_of.as_deref() == Some(root_id)) + { + end += 1; + } + end.max(at) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index f399deb1a8..408be18e4c 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -25,6 +25,12 @@ //! ref or a changed harness keeps the ticket-01 budgeted paste. The turn //! refs themselves ride assistant entries via a harness event the engine //! stamps at segment finish. +//! - "Fork from here" (t3-orchestration-v2 ticket 21): the RPC's optional +//! `boundary` picks the reply to fork at — any settled assistant entry, +//! an earlier, errored or interrupted one — and the copy (and the budgeted +//! fallback) stop there, inclusive, while the native fork boundary becomes +//! exactly that reply's turn ref. Without it, today's behaviour stays: the +//! latest complete reply. use async_trait::async_trait; use futures::StreamExt; @@ -98,6 +104,19 @@ fn message(id: &str, role: MessageRole, text: &str, status: MessageStatus) -> Se } } +/// An assistant entry carrying a provider turn ref, as a run that reports +/// one stamps it (ticket 17): the harness is the chat's own so the dispatch +/// below takes the native-fork path. +fn turn_entry(id: &str, text: &str, status: MessageStatus, turn: &str) -> SessionMessageEntry { + let mut entry = message(id, MessageRole::Assistant, text, status); + entry.provider_turn = Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "parent-provider-session".into(), + turn_id: turn.into(), + }); + entry +} + #[tokio::test] async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_session() { let dir = tempfile::tempdir().unwrap(); @@ -597,6 +616,536 @@ async fn forks_without_refs_or_with_a_changed_harness_paste_the_budget() { core.shutdown().await; } +/// "Fork from here" on an earlier reply (t3-orchestration-v2 ticket 21): +/// the copy stops at the chosen entry, inclusive, and the first run's +/// native-fork boundary is THAT entry's turn ref — not the latest one's. +/// The budgeted fallback history is cut at the boundary too, so the fork +/// never sees later messages even when the harness refuses the fork. +#[tokio::test] +async fn forking_from_an_earlier_reply_cuts_the_copy_and_forks_at_that_turn() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + source + .doc() + .push_message(&message( + "u2", + MessageRole::User, + "And the code?", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a2", + "the code is fine", + MessageStatus::Complete, + "turn-2", + )) + .unwrap(); + source + .doc() + .push_message(&message( + "u3", + MessageRole::User, + "ship it", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a3", + "shipping", + MessageStatus::Complete, + "turn-3", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + + // Fork from the FIRST reply. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side-early", "sourceChatId": "main", "boundary": "a1" }), + ) + .await + .unwrap(); + // The transcript ends at that reply: only u1, a1, and the seam — the + // fork never sees u2, a2, u3, or a3. + let ids = |chat: &str| { + core.doc_host + .open(chat) + .unwrap() + .doc() + .read_entries() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect::>() + }; + assert_eq!( + ids("side-early"), + vec!["u1".to_string(), "a1".to_string(), "fork:side-early".to_string()], + "the copy stops at the boundary, inclusive" + ); + // The copied boundary keeps its ref, so a later fork of the fork still + // finds a boundary. + let copied = core + .doc_host + .open("side-early") + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert_eq!( + copied.iter().find(|e| e.id == "a1").unwrap().provider_turn, + Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "parent-provider-session".into(), + turn_id: "turn-1".into(), + }) + ); + + // The first run forks natively at the CHOSEN boundary… + let request = side_turn(&core, &requests, "side-early", "What did I ask?", "s1").await; + let fork = request + .provider_fork + .expect("native fork at the chosen boundary"); + assert_eq!(fork.session_id, "parent-provider-session"); + assert_eq!(fork.last_turn, "turn-1"); + // …nothing is pasted onto the prompt… + assert_eq!(request.prompt, "What did I ask?", "{}", request.prompt); + // …and the refused-fork fallback is cut at the boundary: it holds the + // copied prefix and never a later message. + let fallback = fork.fallback_history.expect("fallback history attached"); + assert!(fallback.contains("PINEAPPLE"), "{fallback}"); + assert!(!fallback.contains("the code is fine"), "{fallback}"); + assert!(!fallback.contains("shipping"), "{fallback}"); + + // Without a boundary, today's behaviour stays: the fork continues from + // the LATEST reply. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side-latest", "sourceChatId": "main" }), + ) + .await + .unwrap(); + assert_eq!( + ids("side-latest"), + vec![ + "u1".to_string(), + "a1".to_string(), + "u2".to_string(), + "a2".to_string(), + "u3".to_string(), + "a3".to_string(), + "fork:side-latest".to_string(), + ], + "no boundary copies through the latest reply" + ); + let request = side_turn(&core, &requests, "side-latest", "What next?", "s2").await; + let fork = request + .provider_fork + .expect("the latest reply's ref drives the default fork"); + assert_eq!(fork.last_turn, "turn-3"); + core.shutdown().await; +} + +/// Errored and interrupted replies are forkable (ticket 21): an errored +/// turn is a COMPLETE entry with an error part, an interrupted one an +/// ABORTED entry — both keep the turn ref the run stamped before it ended, +/// and a fork at either continues the source session natively at exactly +/// that turn. +#[tokio::test] +async fn errored_and_interrupted_replies_can_be_forked() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "all good", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + // An errored reply: complete status with an error part, ref stamped. + let mut errored = turn_entry("a2", "", MessageStatus::Complete, "turn-2"); + errored.parts = vec![ + MessagePart::Text { + id: "a2-text".into(), + text: "half an answer".into(), + }, + MessagePart::Error { + id: "a2-err".into(), + message: "the model blew up".into(), + }, + ]; + source.doc().push_message(&errored).unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "retry?", + MessageStatus::Complete, + )) + .unwrap(); + // An interrupted reply: aborted status, ref stamped before the stop. + source + .doc() + .push_message(&turn_entry( + "a3", + "partial answer", + MessageStatus::Aborted, + "turn-3", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + + // Fork at the ERRORED reply. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side-err", "sourceChatId": "main", "boundary": "a2" }), + ) + .await + .unwrap(); + let ids = |chat: &str| { + core.doc_host + .open(chat) + .unwrap() + .doc() + .read_entries() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect::>() + }; + assert_eq!( + ids("side-err"), + vec![ + "a1".to_string(), + "a2".to_string(), + "fork:side-err".to_string(), + ], + "the errored reply is the boundary, inclusive" + ); + let request = side_turn(&core, &requests, "side-err", "What went wrong?", "e1").await; + let fork = request + .provider_fork + .expect("the errored turn's ref drives the native fork"); + assert_eq!(fork.last_turn, "turn-2"); + + // Fork at the INTERRUPTED reply — the boundary a default fork would + // never pick (it is aborted, not complete). + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side-int", "sourceChatId": "main", "boundary": "a3" }), + ) + .await + .unwrap(); + assert_eq!( + ids("side-int"), + vec![ + "a1".to_string(), + "a2".to_string(), + "u1".to_string(), + "a3".to_string(), + "fork:side-int".to_string(), + ], + "the interrupted reply is the boundary, inclusive" + ); + let request = side_turn(&core, &requests, "side-int", "Continue", "i1").await; + let fork = request + .provider_fork + .expect("the interrupted turn's ref drives the native fork"); + assert_eq!(fork.last_turn, "turn-3"); + core.shutdown().await; +} + +/// A boundary that is not a settled assistant reply is refused, and no +/// chat row appears: an unknown id, a user entry, and a reply still +/// streaming (its run is writing it, and its turn ref is not stamped yet). +#[tokio::test] +async fn an_invalid_boundary_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let core = EngineCore::assemble( + dir.path(), + Arc::new(HarnessRegistry::new()), + HarnessId::Mock, + ) + .unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "hi", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "hello", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a2", + "still going…", + MessageStatus::Streaming, + "turn-2", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + for boundary in ["nope", "u1", "a2"] { + let error = client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side-bad", + "sourceChatId": "main", + "boundary": boundary, + }), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("settled assistant reply"), + "boundary {boundary}: {error}" + ); + assert!(core.workspace.chat("side-bad").unwrap().is_none()); + } + core.shutdown().await; +} + +/// A boundary without a provider turn ref (an old chat, or a run that never +/// reported one) falls back to the budgeted paste — cut at the boundary, +/// so the pasted history never carries a later message (ticket 21's rule: +/// the fork never sees anything after the boundary, whichever path it +/// takes). +#[tokio::test] +async fn a_boundary_without_a_ref_pastes_the_budget_up_to_the_boundary() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&message( + "a1", + MessageRole::Assistant, + "I remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&message( + "u2", + MessageRole::User, + "And the code?", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&message( + "a2", + MessageRole::Assistant, + "the code is fine", + MessageStatus::Complete, + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side-early", "sourceChatId": "main", "boundary": "a1" }), + ) + .await + .unwrap(); + let request = side_turn(&core, &requests, "side-early", "What did I ask?", "s1").await; + assert_eq!(request.provider_fork, None, "no ref, no native fork"); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!( + !request.prompt.contains("the code is fine"), + "the paste is cut at the boundary: {}", + request.prompt + ); + assert!(request.prompt.ends_with("What did I ask?")); + core.shutdown().await; +} + +/// A logical message can span continuation fragments after its root +/// (oversized relayed replies). A boundary on the root copies the WHOLE +/// message — never a truncated prefix — and the native boundary ref comes +/// from the root, where a run stamps it. +#[tokio::test] +async fn a_split_boundary_entry_copies_whole() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + let root = turn_entry("a1", "first half", MessageStatus::Complete, "turn-1"); + source.doc().push_message(&root).unwrap(); + let mut fragment = message( + "a1#c1", + MessageRole::Assistant, + "second half", + MessageStatus::Complete, + ); + fragment.continuation_of = Some("a1".into()); + source.doc().push_message(&fragment).unwrap(); + source + .doc() + .push_message(&turn_entry( + "a2", + "later reply", + MessageStatus::Complete, + "turn-2", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + // Both the root id and a fragment id resolve to the whole message. + for (chat, boundary) in [("side-root", "a1"), ("side-fragment", "a1#c1")] { + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": chat, + "sourceChatId": "main", + "boundary": boundary, + }), + ) + .await + .unwrap(); + let ids: Vec = core + .doc_host + .open(chat) + .unwrap() + .doc() + .read_entries() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect(); + assert_eq!( + ids, + vec![ + "a1".to_string(), + "a1#c1".to_string(), + format!("fork:{chat}"), + ], + "boundary {boundary} copies the whole logical message" + ); + let request = side_turn(&core, &requests, chat, "Continue", "s1").await; + let fork = request.provider_fork.expect("the root's ref drives the fork"); + assert_eq!(fork.last_turn, "turn-1", "boundary {boundary}"); + } + core.shutdown().await; +} + #[tokio::test] async fn cannot_fork_an_empty_chat_or_overwrite_a_main_chat() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 562a68807f..752398c04e 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -71,7 +71,10 @@ pub mod methods { pub const RETRY_DELIVERY: &str = "RetryDelivery"; /// Fork a chat into a side chat: copies the source's completed history, /// appends the `fork` seam, and lists the copy under its parent. - /// Params `{chatId, sourceChatId, parentChatId?}` → `Chat`. + /// Params `{chatId, sourceChatId, parentChatId?, boundary?}` → `Chat`; + /// `boundary` (t3-orchestration-v2 ticket 21) names the assistant entry + /// to fork at — any settled reply, not just the latest complete one — + /// and the copy stops there, inclusive. pub const FORK_SIDE_CHAT: &str = "ForkSideChat"; pub const WATCH_DOC_MESSAGES: &str = "WatchDocMessages"; /// Messages typed while the agent was busy, held on the chat doc so every diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 101c29e08c..2a6ed28911 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3654,8 +3654,9 @@ impl Shell { } } - /// Spawn-chip events from the primary transcript AND from subagent-tab - /// transcripts (nested spawns open their own tabs). + /// Spawn-chip and fork events from the primary transcript AND from + /// side-chat/subagent-tab transcripts (nested spawns open their own + /// tabs; a side chat's "Fork from here" forks that chat). fn on_transcript_event( &mut self, _: Entity, @@ -3677,6 +3678,27 @@ impl Shell { cx, ); } + TranscriptEvent::ForkFromEntry { chat_id, entry_id } => { + // The reply may live in the selected chat or in an open side + // chat's transcript. The fork hangs under the source — a + // side chat's own fork lands as a sibling, like its fork + // button — and copies only up to the chosen reply. + let Some(source) = self + .state + .read(cx) + .chats + .iter() + .find(|chat| chat.id == *chat_id) + .cloned() + else { + return; + }; + let parent = source + .parent_chat_id + .clone() + .unwrap_or_else(|| source.id.clone()); + self.fork_chat(source, parent, Some(entry_id.clone()), cx); + } } } diff --git a/crates/ui/src/shell/side_chats.rs b/crates/ui/src/shell/side_chats.rs index bdce38cd1a..753add2e35 100644 --- a/crates/ui/src/shell/side_chats.rs +++ b/crates/ui/src/shell/side_chats.rs @@ -39,17 +39,21 @@ impl Shell { return; }; let parent = source.id.clone(); - self.fork_chat(source, parent, cx); + self.fork_chat(source, parent, None, cx); } - /// Fork `source` through its latest completed response into a new chat - /// hanging under `parent_id`, and open it in the right pane. A side - /// chat's own fork button passes its parent so the copy lists as a - /// sibling; the picker passes the source itself. + /// Fork `source` into a new chat hanging under `parent_id`, and open it + /// in the right pane. A side chat's own fork button passes its parent so + /// the copy lists as a sibling; the picker passes the source itself. + /// `boundary` (t3-orchestration-v2 ticket 21, "Fork from here") forks at + /// a chosen assistant reply — any settled one, errored and interrupted + /// included — instead of the latest completed response; `None` keeps + /// today's behaviour. pub(super) fn fork_chat( &mut self, source: roboco_proto::Chat, parent_id: String, + boundary: Option, cx: &mut Context, ) { if self.side_chat_creating { @@ -60,12 +64,21 @@ impl Shell { }; let key = self.panel_key(cx); self.side_chat_creating = true; - let params = serde_json::json!({ + let mut params = serde_json::json!({ "chatId": uuid::Uuid::new_v4().to_string(), "sourceChatId": source.id, "parentChatId": parent_id, "targetDeviceId": source.device_id, }); + // Only send the boundary when one was chosen: absent, the engine + // forks from the latest complete reply (the classic button). + if let Some(boundary) = boundary + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + params["boundary"] = serde_json::Value::String(boundary.to_owned()); + } cx.spawn(async move |this, cx| { let result = engine .client() diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 8921217f01..dcbc96031c 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -1072,6 +1072,12 @@ pub struct Row { /// settled row, beside the timestamp; tools and transport-only metadata /// are deliberately excluded. pub copy_text: Option, + /// "Fork from here" (t3-orchestration-v2 ticket 21): the entry a settled + /// ASSISTANT reply's hover strip offers to fork at. Present on the same + /// row as `copy_text` — every settled assistant reply, errored and + /// interrupted ones included — never on a user row, the fork seam, or a + /// reply still streaming. + pub fork_entry: Option, /// Hidden while the named compact-work fold is closed. pub compact_fold: Option, } @@ -1317,6 +1323,7 @@ pub fn rows_for_entry( timestamp: Some(entry.created_at), copy_text, compact_fold: None, + fork_entry: None, }]; } @@ -1377,6 +1384,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, }); *group_ix += 1; }; @@ -1519,6 +1527,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, kind: if streaming { RowKind::LiveMarkdown { tree: tree.clone(), @@ -1559,6 +1568,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, }); } MessagePart::Error { @@ -1577,6 +1587,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, }); } MessagePart::Fork { @@ -1596,6 +1607,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, }); } // Tools and thoughts are grouped by the outer arms; @@ -1638,6 +1650,11 @@ pub fn rows_for_entry( row.turn_start = false; row.timestamp = None; row.copy_text = None; + // The synthetic work entry shares the original's role, so + // its recursive rows picked up a fork action too — the + // action belongs to the entry's own last settled row, not + // to a fold-hidden work row. + row.fork_entry = None; row.compact_fold = Some(work_id.clone()); } let header = Row { @@ -1662,6 +1679,7 @@ pub fn rows_for_entry( timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, }; let pos = compact_group_pos.unwrap_or(rows.len()); for (i, row) in inner.into_iter().enumerate() { @@ -1691,6 +1709,11 @@ pub fn rows_for_entry( { last.timestamp = Some(entry.created_at); last.copy_text = assistant_copy_text(entry); + // "Fork from here" (ticket 21) rides the same settled row: every + // assistant reply — errored and interrupted ones included — offers + // the fork action. User and system entries never do. + last.fork_entry = (entry.role == MessageRole::Assistant) + .then(|| entry_id.clone()); last.version ^= 1 << 62; } rows @@ -3389,6 +3412,14 @@ pub enum TranscriptEvent { title: String, frozen: bool, }, + /// "Fork from here" (t3-orchestration-v2 ticket 21): the hover action + /// on a settled assistant reply — completed, errored or interrupted. + /// `chat_id` is the chat the reply lives in; `entry_id` is the boundary + /// the fork copies up to, inclusive. + ForkFromEntry { + chat_id: String, + entry_id: String, + }, } impl gpui::EventEmitter for Transcript {} @@ -7100,10 +7131,11 @@ impl Transcript { self.ensure_diagram_worker(cx); // Hover-revealed metadata strip: a RESERVED 32px lane under the - // entry's last row. Timestamp, copy action, and copied feedback only - // flip visibility/content, so none of them shifts the virtualizer. - // User entries align end (under the bubble), assistant entries start. - // Both read timestamp first, then the copy action. + // entry's last row. Timestamp, copy action, fork action, and copied + // feedback only flip visibility/content, so none of them shifts the + // virtualizer. User entries align end (under the bubble), assistant + // entries start. Both read timestamp first, then the copy action; + // assistant entries end with "Fork from here" (ticket 21). let is_user_row = matches!(row.kind, RowKind::User { .. }); let hovered = self .hovered_entry @@ -7112,6 +7144,49 @@ impl Transcript { let copied_message = self.copied_message.as_ref() == Some(&row.entry_id); let copy_text = row.copy_text.clone(); let copy_entry_id = row.entry_id.clone(); + // "Fork from here" (t3-orchestration-v2 ticket 21): every settled + // assistant reply — errored and interrupted ones included — offers a + // fork at exactly that reply, beside the copy action. A subagent doc + // tab is a read-only view with no chat row to fork, so it never + // offers the action; the tooltip says what the fork does NOT do: + // files are not rewound. + let fork_here = row + .fork_entry + .clone() + .filter(|_| self.doc_override.is_none()) + .zip(self.chat_id.clone()) + .map(|(entry_id, chat_id)| { + let fade_key = format!("fork-here-hover-{entry_id}"); + div() + .id(SharedString::from(format!("fork-here-{entry_id}"))) + .size(px(Theme::SPACE_MD * 2.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(Theme::CONTROL_RADIUS)) + .cursor_pointer() + // Same quiet icon-button treatment as the copy action. + .bg(motion::hover_blend( + &fade_key, + gpui::transparent_black(), + crate::theme::ink(0.08), + )) + .on_hover(motion::hover_listener(fade_key)) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(TranscriptEvent::ForkFromEntry { + chat_id: chat_id.clone(), + entry_id: entry_id.clone(), + }); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Fork from here — a new side chat continues from this reply. Files are not rewound.", + )) + .child( + crate::icons::icon(crate::icons::GIT_BRANCH) + .size(px(14.0)) + .text_color(theme.text_muted), + ) + }); let strip = row.timestamp.map(|ms| { let timestamp = div() .text_size(crate::typography::ui_rems(12.0)) @@ -7155,7 +7230,7 @@ impl Transcript { .flex_row() .items_center() .gap(px(Theme::SPACE_SM)); - let metadata = metadata.child(timestamp).children(copy); + let metadata = metadata.child(timestamp).children(copy).children(fork_here); div() .h(px(Theme::SPACE_SM + Theme::SPACE_MD * 2.0)) .pt(px(Theme::SPACE_SM)) @@ -11551,6 +11626,7 @@ mod tests { timestamp: None, copy_text: None, compact_fold: None, + fork_entry: None, } } @@ -15652,6 +15728,115 @@ mod tests { assert!(rows.iter().all(|r| r.entry_id.as_ref() == live.id)); } + /// "Fork from here" (t3-orchestration-v2 ticket 21): the hover-strip + /// action is offered on every SETTLED assistant reply — completed, + /// errored (complete status + error part), and interrupted (aborted) + /// ones — and never on a user row, the fork seam, or a reply still + /// streaming. It rides the same last settled row as the timestamp and + /// copy action, and carries the entry id the fork RPC forks at. + #[test] + fn fork_from_here_is_offered_on_every_settled_assistant_reply() { + // Completed reply: the action rides its LAST settled row only. + let done = assistant( + "a1", + MessageStatus::Complete, + vec![text_part("p1", "one\n\ntwo")], + ); + let rows = rows_for_entry(&done, false, false, &mut parse); + assert!(rows.len() >= 2); + assert_eq!(rows.last().unwrap().fork_entry.as_deref(), Some("a1")); + assert!(rows[..rows.len() - 1] + .iter() + .all(|r| r.fork_entry.is_none())); + + // An errored reply is a settled reply (complete status, error part). + let mut errored = assistant( + "a2", + MessageStatus::Complete, + vec![text_part("p1", "half an answer")], + ); + errored.parts.push(MessagePart::Error { + id: "a2-err".into(), + message: "the model blew up".into(), + }); + assert_eq!( + rows_for_entry(&errored, false, false, &mut parse) + .last() + .unwrap() + .fork_entry + .as_deref(), + Some("a2") + ); + + // An interrupted reply too (aborted status). + let interrupted = assistant( + "a3", + MessageStatus::Aborted, + vec![text_part("p1", "partial answer")], + ); + assert_eq!( + rows_for_entry(&interrupted, false, false, &mut parse) + .last() + .unwrap() + .fork_entry + .as_deref(), + Some("a3") + ); + + // Never mid-stream. + let live = assistant( + "a4", + MessageStatus::Streaming, + vec![text_part("p1", "streaming…")], + ); + assert!( + rows_for_entry(&live, false, false, &mut parse) + .iter() + .all(|r| r.fork_entry.is_none()) + ); + + // Never on a user row. + let mut user = assistant( + "u1", + MessageStatus::Complete, + vec![text_part("p1", "hi")], + ); + user.role = MessageRole::User; + assert!( + rows_for_entry(&user, false, false, &mut parse) + .iter() + .all(|r| r.fork_entry.is_none()) + ); + + // Never on the fork seam (a system entry). + let mut seam = assistant("seam", MessageStatus::Complete, vec![]); + seam.role = MessageRole::System; + seam.parts = vec![MessagePart::Fork { + id: "fork:side".into(), + source_chat_id: "main".into(), + source_title: "Main".into(), + }]; + assert!( + rows_for_entry(&seam, false, false, &mut parse) + .iter() + .all(|r| r.fork_entry.is_none()) + ); + + // Compact mode: the work rows fold under a header built from a + // SYNTHETIC work entry that shares the reply's role — its rows must + // never carry the action; only the entry's own last (reply) row does. + let with_work = assistant( + "a5", + MessageStatus::Complete, + vec![tool_part("t1", "cargo test"), text_part("p1", "done")], + ); + let rows = rows_for_entry(&with_work, false, true, &mut parse); + assert_eq!(rows.last().unwrap().fork_entry.as_deref(), Some("a5")); + assert!(rows[..rows.len() - 1] + .iter() + .all(|r| r.fork_entry.is_none())); + } + #[test] fn message_copy_keeps_authored_text_and_excludes_tool_traces() { let entry = assistant( From 7fb1962c091f4649bec433cc02685b6461737ff8 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 17:11:25 +0700 Subject: [PATCH 21/96] fix(engine): settle the borrow errors from the notice and sign-in merges --- crates/engine/src/agent_accounts.rs | 4 ++-- crates/engine/src/completion_notices.rs | 5 ++--- crates/engine/src/sessions.rs | 10 +++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 6ffd6964ab..c78b4629d1 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -687,8 +687,6 @@ impl UsageEntry { } Err(error) => { self.retry_at = Some(now + error.backoff().as_millis() as i64); - self.error = Some(error); - self.credentials = Some(credentials); // Only a HARD rejection flips the state to signed out; every // other failure (expired-but-CLI-refreshes, rate limit, // network) says nothing about the sign-in and leaves the last @@ -696,6 +694,8 @@ impl UsageEntry { if matches!(error, ProbeError::SignedOut { .. }) { self.sign_in = Some(AgentSignInState::SignedOut); } + self.error = Some(error); + self.credentials = Some(credentials); } } } diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 1a6943ca15..e2a2f5a419 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -86,9 +86,8 @@ impl CompletionNotices { if outcomes.is_empty() { return; } - let mut pending = lock(&self.pending) - .entry(parent.to_string()) - .or_default(); + let mut all = lock(&self.pending); + let pending = all.entry(parent.to_string()).or_default(); for outcome in outcomes.into_iter().rev() { pending.insert(0, outcome); } diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 8f40e05715..199f952383 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -366,8 +366,7 @@ impl SessionsEngine { let origin = origin?; let agent_origin = self .inner - .doc_host() - .and_then(|host| host.workspace()) + .workspace() .and_then(|ws| ws.chat(chat_id).ok().flatten()) .and_then(|chat| chat.agent_origin)?; agent_origin @@ -2933,7 +2932,7 @@ async fn drive_run( reap_deferred_until = None; self_continued_turn = false; inner.set_status_with_completion( - &chat_id, SessionStatus::Idle, false, completed_turn, + &chat_id, SessionStatus::Idle, false, completed_turn.clone(), ); // The watchdog finalized a completed turn (no native // Done): it owes its parent the same notice a Done would. @@ -3577,7 +3576,7 @@ async fn drive_run( &chat_id, SessionStatus::Idle, false, - completed_turn, + completed_turn.clone(), ); // The parked turn settled: a completed turn notifies its // parent, a stop-in-place notifies as stopped, an internal @@ -3638,9 +3637,10 @@ async fn drive_run( // the outcome the engine's own state observed. let notifiable = run_notifiable_parent(&inner, &chat_id, &run_id); inner.remove_run(&chat_id, &run_id); + let final_turn_completed = final_completed_turn.is_some(); inner.set_status_with_completion(&chat_id, final_status, false, final_completed_turn); if let (Some(parent), Some((status, error))) = (notifiable, final_done) { - let completed_turn = final_completed_turn.is_some(); + let completed_turn = final_turn_completed; if let Some(outcome) = final_outcome(status, error.as_deref(), completed_turn) { spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); } From c4848ae427c8522b82a5899e1030b45edd710322 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 17:30:21 +0700 Subject: [PATCH 22/96] feat(engine): record Limited chats and Resume after usage limits Orchestration v2 ticket 06 (spec stories 34-37, 41, 43). A run that ends on a usage-limit error leaves an additive limit record on the chat's status row; the chat stays errored on the wire (ADR 0005) and clients layer the Limited presentation on top: - Session gains an optional limit record (SessionLimit: harness, window, resetsAt, accountId) - serde-defaulted, skipped when absent, mirrored onto the workspace doc's session row (both the registry and the legacy loro writer) so remote sidebars see it. The reset time the harness's own error did not carry is filled from the harness account's cached usage windows (label match first, else the fullest window), and the record names the active account by its engine slot id. The accounts store is wired at assembly and swappable in tests. - A Limited chat holds its doc queue (the same pause a Stop leaves), so queued messages wait behind the continuation instead of dispatching into the same limit; the next explicit run (Resume or the user's own message) thaws it. Plain unclassified errors keep today's flush. - The record clears whenever a new turn starts - any status transition writes the row whole, and only the run task's limit-errored exit passes the record in. - Desktop: the composer status strip becomes the Limited banner (window, relative reset time, Resume button -> the shared RESUME_CONTINUE_PROMPT sent as a normal run; drafts and staged files are handed back to the composer), and sidebar rows plus the Files-panel chat list wear an amber Limited marker (proto view dot::LIMITED). Indicator gains a Limited variant for errored rows carrying the record; limited transitions chime attention like any error. - Wire: SessionLimit registered in wiregen and hand-emitted to web/packages/proto (Session.limit, SessionLimit.ts, index). - CONTEXT.md gains the Limited glossary entry under Chat status. - Tests (not run locally per the verification override; CI runs them): engine integration tests limited_chats.rs (recording, workspace mirroring, reset/account fill from a seeded isolated account store, Resume + queue order, plain-error control, clearing), proto wire round-trips and view derivations, GPUI state derivation tests. --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 4 + crates/doc/src/registry.rs | 10 + crates/doc/src/registry/tests.rs | 1 + crates/doc/src/workspace.rs | 42 ++ crates/engine/src/agent_accounts.rs | 14 + crates/engine/src/doc_host.rs | 9 + crates/engine/src/lib.rs | 3 + crates/engine/src/sessions.rs | 123 +++- crates/engine/src/workspace_host.rs | 1 + crates/engine/tests/limited_chats.rs | 664 ++++++++++++++++++ crates/mobile-client/src/clock.rs | 1 + crates/mobile-client/src/notify.rs | 1 + crates/mobile-client/src/session/composer.rs | 1 + crates/mobile-client/src/workspace/view.rs | 1 + crates/proto/src/agent.rs | 6 + crates/proto/src/entities.rs | 86 +++ crates/proto/src/view.rs | 126 +++- crates/roboco-mcp/src/tools.rs | 1 + crates/ui/examples/macos-resource-profile.rs | 1 + crates/ui/src/composer.rs | 27 + crates/ui/src/files/sections.rs | 19 +- crates/ui/src/shell.rs | 68 +- crates/ui/src/state.rs | 62 +- crates/ui/src/transcript.rs | 2 + crates/wiregen/src/main.rs | 1 + web/packages/proto/src/generated/Session.ts | 9 +- .../proto/src/generated/SessionLimit.ts | 33 + web/packages/proto/src/generated/index.ts | 1 + 29 files changed, 1305 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/limited_chats.rs create mode 100644 web/packages/proto/src/generated/SessionLimit.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 6de999df53..f408d59c0b 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test limited_chats # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index 87dd992ad1..5a4dd5c3f2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,10 @@ _Avoid_: Child chat, subagent (that is a harness-spawned agent inside a run) A chat's live run state: idle, working, awaiting input, or errored. _Avoid_: Session, state (when it means chat status) +**Limited**: +An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume; queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. +_Avoid_: Rate-limited (that is the harness event), throttled, capped + **Chat doc**: The chat's durable document — transcript, command ledger, and message queue in one doc, owned by the engine. _Avoid_: Session doc diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index 499e4fe3df..a811dc4b67 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -1501,6 +1501,10 @@ impl RegistryDoc { /// Upsert a session-status row (writer discipline: each device writes only /// its own runs' rows). Staleness is checked client-side via `updatedAt`. pub fn upsert_session(&mut self, session: &Session) -> Result<(), DocError> { + let limit = match &session.limit { + Some(limit) => serde_json::to_value(limit)?, + None => Value::Null, + }; let set = fields([ ("chatId", json!(session.chat_id)), ("deviceId", json!(session.device_id)), @@ -1508,6 +1512,7 @@ impl RegistryDoc { ("lastCompletedTurn", json!(session.last_completed_turn)), ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), + ("limit", limit), ]); self.write(KIND_SESSIONS, &session.chat_id.clone(), OpKind::Upsert, set); Ok(()) @@ -1647,6 +1652,10 @@ impl RegistryDoc { } for session in &state.sessions { let ms = newest(&[Some(session.updated_at), session.started_at]); + let limit = match &session.limit { + Some(limit) => serde_json::to_value(limit)?, + None => Value::Null, + }; seed( KIND_SESSIONS, &session.chat_id, @@ -1658,6 +1667,7 @@ impl RegistryDoc { ("lastCompletedTurn", json!(session.last_completed_turn)), ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), + ("limit", limit), ]), ); } diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 254d3a3357..353f0ad65f 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -302,6 +302,7 @@ fn session(chat_id: &str, device_id: &str, status: SessionStatus) -> Session { status, started_at: Some(ts(3_000)), updated_at: ts(3_500), + limit: None, } } diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index 00f0656467..9dcab256f5 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -492,6 +492,13 @@ impl WorkspaceDoc { )?; set_opt_ms(&row, "startedAt", session.started_at)?; row.insert("updatedAt", session.updated_at.timestamp_millis())?; + match &session.limit { + Some(limit) => row.insert( + "limit", + LoroValue::from(serde_json::to_value(limit)?), + )?, + None => row.delete("limit")?, + } self.doc.commit(); Ok(()) } @@ -787,6 +794,8 @@ pub(crate) struct RawSession { started_at: Option, #[serde(default)] updated_at: i64, + #[serde(default)] + limit: Option, } impl From for Session { @@ -798,6 +807,7 @@ impl From for Session { status: raw.status, started_at: raw.started_at.map(dt), updated_at: dt(raw.updated_at), + limit: raw.limit, } } } @@ -906,6 +916,7 @@ mod tests { status, started_at: Some(ts(3_000)), updated_at: ts(3_500), + limit: None, } } @@ -982,6 +993,37 @@ mod tests { assert_eq!(ws.read_sessions().unwrap()[0].last_completed_turn, None); } + /// The usage-limit record rides the session row additively: it + /// round-trips through the doc, a row from an older writer (no such + /// key) reads as unlimited, and clearing it on a later upsert (a new + /// turn started) drops the stale marker instead of keeping it. + #[test] + fn limit_record_round_trips_and_clears_on_the_session_row() { + let ws = WorkspaceDoc::new(); + let mut row = session("chat-1", "dev-a", SessionStatus::Errored); + row.limit = Some(roboco_proto::SessionLimit { + harness: HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + account_id: Some("slot-9f2c".into()), + }); + ws.upsert_session(&row).unwrap(); + assert_eq!(ws.read_sessions().unwrap(), vec![row.clone()]); + // Old writers never wrote the field: a row without the key parses + // as unlimited. + ws.row("sessions", "chat-1") + .unwrap() + .delete("limit") + .unwrap(); + assert_eq!(ws.read_sessions().unwrap()[0].limit, None); + // A new turn clears the record: the engine upserts the row without + // one, and the stale marker must not survive that write. + let mut resumed = session("chat-1", "dev-a", SessionStatus::Working); + ws.upsert_session(&resumed).unwrap(); + resumed.limit = None; + assert_eq!(ws.read_sessions().unwrap(), vec![resumed]); + } + #[test] fn rows_round_trip() { let ws = WorkspaceDoc::new(); diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 512bf8c0b0..f82d7eed63 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -889,6 +889,20 @@ impl AgentAccounts { self.list_locked(force_usage).await } + /// The ACTIVE account row for `harness` — the login the CLI is running + /// under right now, with its cached usage windows (the last good probe; + /// no network). The sessions engine reads this to complete a usage-limit + /// record: which account the limit hit, and the reset time when the + /// harness's own error did not carry one. + pub async fn active_account(&self, harness: HarnessId) -> Option { + self.list(false) + .await + .ok()? + .accounts + .into_iter() + .find(|account| account.harness == harness && account.active) + } + /// Caller holds [`Inner::ops`]. async fn list_locked(&self, force_usage: bool) -> Result { let mut warnings: Vec = Vec::new(); diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 6ef1746333..b6dc943f9c 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -393,6 +393,15 @@ impl ChatDocHandle { rx } + /// Hold queued rows until an explicit prompt thaws them (the same state + /// a Stop leaves): the sessions engine arms this when a turn ends on a + /// usage limit, so a Limited chat's queued messages wait behind the + /// continuation instead of dispatching into the same limit. A queued row + /// inserted afterwards also waits — nothing dispatches while paused. + pub fn pause_queue(&self) { + self.queue_paused.store(true, Ordering::Release); + } + /// Where a newly steered row goes: after the rows already steered, ahead /// of every ordinary row. Records `id` as steered. fn steer_slot(&self, id: &str) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3eb56ae44..379ed352a5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -269,6 +269,9 @@ impl EngineCore { // login between engines, so no route is registered in production. let agent_accounts = AgentAccounts::with_callback_routes(agent_accounts_config, previews.callback_routes()); + // Usage-limit records read the active login's cached windows from the + // accounts store (which account the limit hit, reset-time fill). + sessions.set_agent_accounts(agent_accounts.clone()); let harness_updates = harness_updates::HarnessUpdateCoordinator::new(data_dir, registry.clone()); harness_updates.start(); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index ad034a815a..63c51dfe01 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -30,8 +30,8 @@ use roboco_doc::{ }; use roboco_harness::{CancellationToken, Harness, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionStatus, UserInputAnswer, - UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionLimit, + SessionStatus, UserInputAnswer, UserInputQuestion, }; use crate::doc_host::{ChatDocHandle, DocHost}; @@ -205,6 +205,12 @@ struct Inner { /// dispatch or accepted steer) — the diff sync snapshots the checkout tree /// for the Changes pane's "Latest turn" scope. Absent in bare tests. turn_listener: OnceLock, + /// The harness-accounts store, wired at engine assembly: where a usage + /// limit record's missing reset time and account come from (the active + /// login's cached usage windows). Swappable so integration tests can + /// mount an isolated fake store; `None` in bare session-only tests — + /// limits then record without an account or reset fill. + agent_accounts: Mutex>, } /// Turn-start hook: called with `(chat_id, cwd)`. @@ -244,6 +250,7 @@ impl SessionsEngine { titles: OnceLock::new(), generated_images: OnceLock::new(), turn_listener: OnceLock::new(), + agent_accounts: Mutex::new(None), }), } } @@ -294,6 +301,14 @@ impl SessionsEngine { let _ = self.inner.turn_listener.set(listener); } + /// Wire (or, in tests, swap) the harness-accounts store the engine reads + /// usage-limit context from — the active account and its cached usage + /// windows (see [`Inner::limit_record`]). Engine assembly wires the + /// engine's own store; tests mount an isolated one. + pub fn set_agent_accounts(&self, accounts: crate::agent_accounts::AgentAccounts) { + *lock(&self.inner.agent_accounts) = Some(accounts); + } + fn note_turn_start(&self, chat_id: &str, cwd: &str) { if let Some(listener) = self.inner.turn_listener.get() { listener(chat_id, cwd); @@ -1385,6 +1400,57 @@ impl Inner { self.set_status_with_completion(chat_id, status, fresh_start, None); } + /// Complete a usage-limit record for a turn that ended on one: which + /// harness account it hit (the active login — resolved when the turn + /// settles; warm runtimes keep the token they started with, so that is + /// the login the run used in practice), and — when the harness's own + /// error carried no reset time — the fill from that account's cached + /// usage windows: the window whose LABEL matches the limit's, else the + /// one with the highest usage (ties: the soonest reset). Engines + /// without an accounts store (bare tests) record without either. + /// `None` when the classification was not a usage limit. + async fn limit_record( + &self, + harness: HarnessId, + kind: AgentErrorKind, + ) -> Option { + let AgentErrorKind::UsageLimit { window, resets_at } = kind else { + return None; + }; + let accounts = lock(&self.agent_accounts).clone(); + let active = match accounts { + Some(accounts) => accounts.active_account(harness).await, + None => None, + }; + let resets_at = resets_at.or_else(|| { + let windows = &active.as_ref()?.usage_windows; + // Same window, when the account's meters name it (Codex's labels + // match the driver's; Claude's do not, so the fallback matters). + let by_label = windows + .iter() + .find(|w| w.resets_at.is_some() && w.label.eq_ignore_ascii_case(&window)); + let pick = by_label.or_else(|| { + windows + .iter() + .filter(|w| w.resets_at.is_some()) + // Highest usage first, soonest reset as the tiebreak: the + // exhausted window is the blocker, not a fresh one. + .max_by(|a, b| { + a.used_fraction + .total_cmp(&b.used_fraction) + .then_with(|| b.resets_at.cmp(&a.resets_at)) + }) + }); + pick.and_then(|w| w.resets_at).map(|at| at.timestamp_millis()) + }); + Some(SessionLimit { + harness, + window, + resets_at, + account_id: active.map(|account| account.id), + }) + } + fn has_pending_steers(&self, chat_id: &str, run_id: &str) -> bool { lock(&self.runs) .get(chat_id) @@ -1400,6 +1466,23 @@ impl Inner { status: SessionStatus, fresh_start: bool, completed_turn: Option, + ) { + self.set_status_with_limit(chat_id, status, fresh_start, completed_turn, None); + } + + /// The full status transition: everything above, plus the usage-limit + /// record a limited turn ends with. `None` CLEARS any previous record — + /// the marker lives exactly as long as the errored status that recorded + /// it, so a new turn (any Working transition) or any other settled + /// outcome drops it. Only the run task's limit-errored exit passes + /// `Some`. + fn set_status_with_limit( + &self, + chat_id: &str, + status: SessionStatus, + fresh_start: bool, + completed_turn: Option, + limit: Option, ) { let now = Utc::now(); let session = { @@ -1413,6 +1496,7 @@ impl Inner { status, started_at: None, updated_at: now, + limit: None, }); // `started_at` is the elapsed-timer base and must only ever mean // "this turn". Entering Working from a settled state always @@ -1429,6 +1513,7 @@ impl Inner { } entry.status = status; entry.updated_at = now; + entry.limit = limit; match status { SessionStatus::Working if fresh_start || !was_active => { entry.started_at = Some(now); @@ -2398,6 +2483,11 @@ async fn drive_run( let mut last_subagent_activity: Option = None; let mut final_completed_turn = None; + // The run's LAST error classification. Harnesses emit the classified + // `AgentEvent::Error` (usage limit / signed out) before their terminal + // `Done`; when that Done is Errored, this is what turns the settled row + // into a Limited one (the record on the session status). + let mut last_error_kind: Option = None; let final_status = loop { let event: AgentEvent = if let Some(event) = prepared_events.pop_front() { event @@ -3076,6 +3166,12 @@ async fn drive_run( AgentEvent::InputResolved { .. } => { inner.set_status(&chat_id, SessionStatus::Working, false); } + AgentEvent::Error { kind, .. } => { + // Track the latest classification; a later unclassified + // error supersedes an earlier limit one (the turn then ended + // on that failure, not the limit). + last_error_kind = kind.clone(); + } _ => {} } @@ -3246,8 +3342,29 @@ async fn drive_run( .filter(|h| h.run_id == run_id) .map(|h| std::mem::take(&mut *lock(&h.routed_steers)).into()) .unwrap_or_default(); + // A turn that ended on a usage limit records the additive limit row + // (account + reset-time fill) while this run still owns the chat — the + // accounts lookup is the only await in the exit path, so it runs before + // the handle goes away rather than racing a fresh dispatch's status. + let limit = match (final_status, last_error_kind) { + (SessionStatus::Errored, Some(kind)) => { + inner.limit_record(harness_id, kind).await + } + _ => None, + }; + if limit.is_some() { + // Queued messages wait BEHIND the continuation instead of dispatching + // into the same wall: a limited chat holds its queue exactly like a + // Stop does, and the next explicit prompt (Resume's continue prompt, + // or the user's own message) thaws it when its run dispatches. + if let Some(host) = inner.doc_host() + && let Ok(handle) = host.open(&chat_id) + { + handle.pause_queue(); + } + } inner.remove_run(&chat_id, &run_id); - inner.set_status_with_completion(&chat_id, final_status, false, final_completed_turn); + inner.set_status_with_limit(&chat_id, final_status, false, final_completed_turn, limit); // A Stop cancels the messages it found waiting; one accepted after it // began is still the user's next message and must run. let orphans: Vec = if inner diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index 7416bd8d32..6c7de97cb1 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -1082,6 +1082,7 @@ mod tests { last_completed_turn: None, started_at: None, updated_at: at, + limit: None, } } diff --git a/crates/engine/tests/limited_chats.rs b/crates/engine/tests/limited_chats.rs new file mode 100644 index 0000000000..b300e332da --- /dev/null +++ b/crates/engine/tests/limited_chats.rs @@ -0,0 +1,664 @@ +//! Limited chats (orchestration v2, ticket 06): a run that ends on a +//! usage-limit error leaves the additive limit record on the chat's status — +//! harness, window, reset time (filled from the harness account's cached +//! windows when the harness's own error carried none) and the account it +//! happened on. Queued messages wait behind the continuation; Resume sends +//! the continue prompt as a normal run; a new turn clears the record. +//! +//! The harness seam is a scripted stand-in (`LimitHarness`): its first run +//! ends on a classified usage-limit error exactly the way the Claude/Codex +//! drivers do (`Error { kind }` then an errored `Done`), later runs hold +//! until the test releases them so queue-order assertions are deterministic. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{MessagePart, MessageRole, SessionCommandPayload}; +use roboco_engine::{AgentAccounts, AgentAccountsConfig, EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, + SandboxLevel, SessionLimit, SessionStatus, SteeringMode, +}; + +const CHAT: &str = "chat-limited"; + +/// A scripted harness: the FIRST run ends on an error carrying `kind` +/// (a usage-limit kind for the Limited tests, `None` for plain failures); +/// every run waits for a `finish` signal before its terminal event, so the +/// test owns when turns end; later runs complete normally. +struct LimitHarness { + id: HarnessId, + kind: Option, + finish: tokio::sync::broadcast::Sender<()>, + prompts: Arc>>, + runs: std::sync::atomic::AtomicUsize, +} + +impl LimitHarness { + fn new(id: HarnessId, kind: Option) -> (Arc, Arc>>) { + let (finish, _) = tokio::sync::broadcast::channel(16); + let prompts = Arc::new(Mutex::new(Vec::new())); + ( + Arc::new(Self { + id, + kind, + finish, + prompts: prompts.clone(), + runs: std::sync::atomic::AtomicUsize::new(0), + }), + prompts, + ) + } +} + +#[async_trait] +impl Harness for LimitHarness { + fn id(&self) -> HarnessId { + self.id + } + fn display_name(&self) -> &str { + "Limit" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result>, HarnessError> { + self.prompts.lock().unwrap().push(request.prompt.clone()); + let run = self.runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let kind = self.kind.clone(); + let session = format!("sess-{run}"); + let mut finish = self.finish.subscribe(); + let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { + harness: self.id, + model: "limit-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: session.clone(), + assistant_message_id: format!("a-{run}"), + })]); + let tail = futures::stream::once(async move { + // The turn runs until the test says it ends. + let _ = finish.recv().await; + if run == 0 { + // The classified limit error, then the errored Done — the + // driver shape ticket 05 landed. `None` kind = a plain, + // unclassified failure (the not-Limited control case). + let (message, kind) = match kind { + Some(kind @ AgentErrorKind::UsageLimit { .. }) => ( + "Claude usage limit reached — try again after the limit resets.", + Some(kind), + ), + Some(kind) => ("The agent failed its login.", Some(kind)), + None => ("The agent child crashed.", None), + }; + vec![ + Ok(AgentEvent::Error { + message: message.into(), + kind, + }), + Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some(session), + }), + ] + } else { + vec![ + Ok(AgentEvent::TextDelta { + text: "Picked the work back up.".into(), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + }), + ] + } + }) + .flat_map(futures::stream::iter); + Ok(started.chain(tail).boxed()) + } +} + +async fn wait_for(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +/// The continue prompt Resume sends — asserted as the exact engine-side run +/// prompt, spelled by the shared proto constant. +fn run_request(prompt: &str, harness: HarnessId) -> RunRequest { + RunRequest { + prompt: prompt.into(), + harness: Some(harness), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: ".".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + mcp: None, + resume: None, + } +} + +/// Queue a Run command the way the composer's Resume / normal send does +/// (the durable command plane, not the visible message queue). +fn queue_run(core: &EngineCore, prompt: &str, harness: HarnessId, message_id: &str) { + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run_request(prompt, harness), + message_id: message_id.into(), + }, + ) + .expect("queue run command"); +} + +fn queue_texts(core: &EngineCore) -> Vec { + core.doc_host + .open(CHAT) + .ok() + .and_then(|h| h.doc().read_queue().ok()) + .unwrap_or_default() + .into_iter() + .map(|item| item.text) + .collect() +} + +fn user_texts(core: &EngineCore) -> Vec { + core.doc_host + .open(CHAT) + .ok() + .and_then(|h| h.doc().read_entries().ok()) + .unwrap_or_default() + .iter() + .filter(|e| e.role == MessageRole::User) + .map(|e| { + e.parts + .iter() + .filter_map(|p| match p { + MessagePart::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("") + }) + .collect() +} + +/// Assemble a core around the scripted harness, pre-titled so the +/// auto-titler never dispatches a request of its own, and mount an ISOLATED +/// accounts store (empty by default) so limit records never read the +/// machine's real CLI logins. +async fn setup( + built: (Arc, Arc>>), +) -> ( + EngineCore, + Arc, + Arc>>, +) { + let tmp = tempfile::tempdir().unwrap(); + // Leak the guard: the engine outlives this helper; uniqueness is all the + // test needs. + let path = tmp.keep(); + let (harness, prompts) = built; + let registry = HarnessRegistry::new(); + registry.register(harness.clone()); + let core = EngineCore::assemble(&path.join("data"), Arc::new(registry), HarnessId::Mock) + .expect("engine core assembles"); + core.sessions + .set_agent_accounts(AgentAccounts::new(AgentAccountsConfig::isolated( + &path.join("accounts"), + ))); + create_chat(&core).await; + (core, harness, prompts) +} + +async fn create_chat(core: &EngineCore) { + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + // Pre-title so the auto-titler never dispatches a harness request of its own. + core.workspace + .rename_chat(CHAT, "Pre-titled") + .expect("rename chat"); +} + +fn limit_row(core: &EngineCore) -> Option { + core.sessions + .session_status(CHAT) + .and_then(|s| s.limit.clone()) +} + +/// A run ending on a classified usage-limit error leaves the additive limit +/// record on the errored chat — harness, window and reset time exactly as +/// the harness's own error reported them — and mirrors it onto the workspace +/// status row so remote sidebars can show it too. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_limit_errored_run_records_the_limit_on_the_chat_status() { + let (core, harness, prompts) = setup(LimitHarness::new( + HarnessId::ClaudeCode, + Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + )) + .await; + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the limited run to start", + ) + .await; + let _ = harness.finish.send(()); + + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored) + }, + "the chat to settle errored", + ) + .await; + // The chat stays ERRORED on the wire (ADR 0005); the record is the + // additive Limited detail. + assert_eq!( + limit_row(&core), + Some(SessionLimit { + harness: HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + account_id: None, + }) + ); + // No accounts on the engine's isolated store → no account on the record. + // The row rides the workspace doc too (remote devices' markers). + wait_for( + || { + core.workspace + .read_sessions() + .map(|rows| { + rows.iter().any(|row| { + row.chat_id == CHAT && row.status == SessionStatus::Errored + && row.limit.as_ref().is_some_and(|l| l.window == "5-hour") + }) + }) + .unwrap_or(false) + }, + "the limit row to mirror into the workspace doc", + ) + .await; + core.shutdown().await; +} + +/// A run that ends on an UNCLASSIFIED error leaves the chat errored with no +/// limit record — and its queued messages still flush at turn end (the +/// queue-hold is a Limited behavior, not a general error behavior). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_plain_error_records_no_limit_and_flushes_the_queue_as_before() { + let (core, harness, prompts) = + setup(LimitHarness::new(HarnessId::ClaudeCode, None)).await; + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the failing run to start", + ) + .await; + core.doc_host + .queue_message(CHAT, "follow up", Vec::new()) + .expect("queue message"); + let _ = harness.finish.send(()); + + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored) + }, + "the chat to settle errored", + ) + .await; + assert_eq!(limit_row(&core), None, "unclassified errors are not Limited"); + // Today's behavior, unchanged: the turn ended, the queued row goes out. + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "follow up"), + "the queued message to flush after a plain error", + ) + .await; + let _ = harness.finish.send(()); + core.shutdown().await; +} + +/// The reset time the harness did not carry is filled from the harness +/// account's cached usage windows, and the record names that account: the +/// active login at run time, by its engine slot id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_missing_reset_time_is_filled_from_the_accounts_cached_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("accounts"); + let config = AgentAccountsConfig { + data_dir: root.join("data"), + claude_config_dir: root.join("claude"), + claude_config_file: root.join("claude.json"), + codex_home: root.join("codex"), + cursor_sdk_auth_file: root.join("cursor-sdk").join("auth.json"), + // File-only: a temp config must never reach the real Keychain login. + claude_keychain_service: None, + antigravity_home: Some(root.join("gemini")), + antigravity_keychain: false, + ..AgentAccountsConfig::isolated(&root) + }; + // A live Claude login (accountUuid uuid-limit-1 → usage key + // "claude-code:uuid-limit-1") with cached meters: the Session window is + // the fuller one, so it is the reset the record should fill. + std::fs::create_dir_all(&config.claude_config_dir).unwrap(); + std::fs::write( + &config.claude_config_file, + serde_json::json!({ + "oauthAccount": { + "accountUuid": "uuid-limit-1", + "emailAddress": "dev@roboco.test", + "displayName": "Dev Tester", + "organizationName": "Test Org", + }, + "userID": "user-uuid-limit-1", + }) + .to_string(), + ) + .unwrap(); + std::fs::write( + config.claude_config_dir.join(".credentials.json"), + serde_json::json!({ + "claudeAiOauth": { + "accessToken": "token-limit-1", + "refreshToken": "refresh-limit-1", + "expiresAt": 4_102_444_800_000i64, + } + }) + .to_string(), + ) + .unwrap(); + let session_reset = chrono::DateTime::parse_from_rfc3339("2026-11-09T10:30:00Z") + .unwrap() + .to_utc(); + let cache = root.join("data").join("agent-accounts"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write( + cache.join("usage-cache.json"), + serde_json::json!({ + "entries": { + "claude-code:uuid-limit-1": { + "usage": { + "windows": [ + { "label": "Session", "usedFraction": 0.92, "resetsAt": "2026-11-09T10:30:00Z" }, + { "label": "Week", "usedFraction": 0.4, "resetsAt": "2026-11-12T09:00:00Z" }, + ], + }, + "fetchedAt": 1_762_670_000_000i64, + "checkedAt": 1_762_670_000_000i64, + }, + }, + }) + .to_string(), + ) + .unwrap(); + let accounts = AgentAccounts::new(config); + // The account the limit "happened on": the active login's slot id. + let expected_account = accounts + .active_account(HarnessId::ClaudeCode) + .await + .expect("the fake login is the active account"); + + let (core, harness, prompts) = setup(LimitHarness::new( + HarnessId::ClaudeCode, + Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + )) + .await; + // Mount the SEEDED store the engine should read the fill from. + core.sessions.set_agent_accounts(accounts); + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the limited run to start", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored) + }, + "the chat to settle errored", + ) + .await; + assert_eq!( + limit_row(&core), + Some(SessionLimit { + harness: HarnessId::ClaudeCode, + window: "usage".into(), + // Filled from the account's fuller window (the likely blocker). + resets_at: Some(session_reset.timestamp_millis()), + account_id: Some(expected_account.id), + }), + "the reset time and account come from the harness account" + ); + core.shutdown().await; +} + +/// Resume sends the continue prompt as a NORMAL run — the composer's Run +/// command — which dispatches ahead of the queue the limited chat is +/// holding; the queued messages are delivered after the continuation, in +/// order; a new turn clears the limit record. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_runs_the_continue_prompt_with_the_queue_behind_it() { + let (core, harness, prompts) = setup(LimitHarness::new( + HarnessId::ClaudeCode, + Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: None, + }), + )) + .await; + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the limited run to start", + ) + .await; + // Messages the user sent while the doomed run was working. + core.doc_host + .queue_message(CHAT, "queued one", Vec::new()) + .expect("queue one"); + core.doc_host + .queue_message(CHAT, "queued two", Vec::new()) + .expect("queue two"); + let _ = harness.finish.send(()); + + wait_for(|| limit_row(&core).is_some(), "the chat to go Limited").await; + // Limited: the queued rows WAIT instead of dispatching into the limit. + assert_eq!(queue_texts(&core), vec!["queued one", "queued two"]); + assert_eq!( + prompts.lock().unwrap().clone(), + vec!["do the work"], + "no queued message dispatches into the limited chat" + ); + + // Resume — the composer's normal-send path with the fixed prompt. + queue_run( + &core, + roboco_proto::RESUME_CONTINUE_PROMPT, + HarnessId::ClaudeCode, + "m-resume", + ); + wait_for( + || { + prompts + .lock() + .unwrap() + .iter() + .any(|p| p == roboco_proto::RESUME_CONTINUE_PROMPT) + }, + "the continue prompt to run ahead of the queue", + ) + .await; + // The continuation owns the turn; the queued rows still wait behind it. + assert_eq!(queue_texts(&core), vec!["queued one", "queued two"]); + // A new turn started: the limit record is gone. + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.limit.is_none()) + }, + "the new turn to clear the limit record", + ) + .await; + + // The continuation ends → the queue drains behind it, head first. + let _ = harness.finish.send(()); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "queued one"), + "the first queued message to follow the continuation", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "queued two"), + "the second queued message to follow in order", + ) + .await; + let _ = harness.finish.send(()); + + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the last turn to settle", + ) + .await; + let sent: Vec = prompts.lock().unwrap().clone(); + assert_eq!( + sent, + vec![ + "do the work", + roboco_proto::RESUME_CONTINUE_PROMPT, + "queued one", + "queued two", + ], + "resume runs first, queued messages follow in order" + ); + assert!(queue_texts(&core).is_empty(), "the queue drained"); + // The transcript carries the continue prompt as the run's user entry. + assert!(user_texts(&core).contains(&roboco_proto::RESUME_CONTINUE_PROMPT.to_string())); + assert_eq!( + limit_row(&core), + None, + "the limit record stayed cleared after the resumed turns" + ); + core.shutdown().await; +} + +/// ANY new turn clears the record, not just a Resume: an ordinary follow-up +/// message starts a fresh turn and the chat stops reading as Limited. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_plain_new_turn_clears_the_limit_record() { + let (core, harness, prompts) = setup(LimitHarness::new( + HarnessId::ClaudeCode, + Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: None, + }), + )) + .await; + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the limited run to start", + ) + .await; + let _ = harness.finish.send(()); + wait_for(|| limit_row(&core).is_some(), "the chat to go Limited").await; + + // The user's own next message (a normal send, not the resume prompt). + queue_run(&core, "try a different angle", HarnessId::ClaudeCode, "m-next"); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the new turn to start", + ) + .await; + assert_eq!( + limit_row(&core), + None, + "the limit record clears when the new turn starts" + ); + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the new turn to settle", + ) + .await; + assert_eq!(limit_row(&core), None); + core.shutdown().await; +} diff --git a/crates/mobile-client/src/clock.rs b/crates/mobile-client/src/clock.rs index b003df8c6d..5711437bb9 100644 --- a/crates/mobile-client/src/clock.rs +++ b/crates/mobile-client/src/clock.rs @@ -83,6 +83,7 @@ mod tests { status, started_at: Some(updated - TimeDelta::minutes(5)), updated_at: updated, + limit: None, } } diff --git a/crates/mobile-client/src/notify.rs b/crates/mobile-client/src/notify.rs index 8763415a40..9d7d67dc8a 100644 --- a/crates/mobile-client/src/notify.rs +++ b/crates/mobile-client/src/notify.rs @@ -692,6 +692,7 @@ mod tests { status, started_at: Some(updated - chrono::TimeDelta::minutes(5)), updated_at: updated, + limit: None, } } diff --git a/crates/mobile-client/src/session/composer.rs b/crates/mobile-client/src/session/composer.rs index b02dac35c0..e95d9520b5 100644 --- a/crates/mobile-client/src/session/composer.rs +++ b/crates/mobile-client/src/session/composer.rs @@ -338,6 +338,7 @@ mod tests { status, started_at: Some(updated - TimeDelta::minutes(5)), updated_at: updated, + limit: None, } } diff --git a/crates/mobile-client/src/workspace/view.rs b/crates/mobile-client/src/workspace/view.rs index c9a8ffa5f8..d52e3f20ef 100644 --- a/crates/mobile-client/src/workspace/view.rs +++ b/crates/mobile-client/src/workspace/view.rs @@ -1294,6 +1294,7 @@ mod tests { status, started_at: Some(at(updated)), updated_at: at(updated), + limit: None, } } diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index 61fd324a5f..688e11ded2 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -29,6 +29,12 @@ pub enum HarnessId { Mock, } +/// The continue prompt a Limited chat's **Resume** sends as a normal run +/// (orchestration v2): one shared spelling so a resumed transcript reads +/// the same from every surface that sends it (the desktop banner today; +/// the engine's scheduled resumes join it in later tickets). +pub const RESUME_CONTINUE_PROMPT: &str = "Continue where you left off."; + /// Durable user preference for one agent's independently-installed CLI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] #[serde(rename_all = "kebab-case")] diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 6c791c6b0f..7dd5f1e72a 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -260,6 +260,31 @@ pub enum SessionStatus { Errored, } +/// A usage-limit record for a Limited chat: what the chat hit and when the +/// window resets. The chat stays `Errored` on the wire (ADR 0005: names are +/// frozen, this row is additive) — clients that know the field layer the +/// Limited presentation on top of the errored one. Written when a run ends +/// on a usage-limit error; cleared when a new turn starts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionLimit { + /// The harness the limited run was on. + pub harness: HarnessId, + /// The harness's own window label ("5-hour", "weekly", … — "usage" + /// when the harness did not name the window). + pub window: String, + /// Epoch millis when the window resets, when known — from the harness's + /// own error, else filled from the harness account's cached usage + /// windows (see `AgentUsageWindow`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resets_at: Option, + /// The harness account the limit hit (the engine slot id of the harness's + /// active login), when the engine could resolve one. Account switching + /// is addressed by this id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, +} + /// Live run status for a chat — drives the Working indicator and sidebar status dots. /// Staleness-checked client-side against `updated_at` so a crashed backend never shows /// an eternal "Working". @@ -276,6 +301,11 @@ pub struct Session { pub status: SessionStatus, pub started_at: Option>, pub updated_at: DateTime, + /// The usage-limit record when the last turn ended on a harness usage + /// limit — the additive "Limited" detail on the errored status (see + /// [`SessionLimit`]). Absent on rows from older engines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] @@ -1667,4 +1697,60 @@ mod tests { let encoded = serde_json::to_value(&old).unwrap(); assert!(encoded.get("agentOrigin").is_none()); } + + fn session_row(status: SessionStatus) -> Session { + Session { + last_completed_turn: None, + chat_id: "chat-1".into(), + device_id: "device-1".into(), + status, + started_at: None, + updated_at: Utc.with_ymd_and_hms(2026, 10, 16, 12, 0, 0).unwrap(), + limit: None, + } + } + + /// The limit record is additive on the session row (ADR 0005): it rides + /// the errored status under stable camelCase names, omits itself when + /// absent, and rows from older engines still parse. + #[test] + fn session_limit_row_is_additive_and_round_trips() { + let mut limited = session_row(SessionStatus::Errored); + limited.limit = Some(SessionLimit { + harness: HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + account_id: Some("slot-9f2c".into()), + }); + let value = serde_json::to_value(&limited).unwrap(); + assert_eq!(value["status"], "errored"); + assert_eq!(value["limit"]["harness"], "claude-code"); + assert_eq!(value["limit"]["window"], "5-hour"); + assert_eq!(value["limit"]["resetsAt"], 1_762_675_400_000); + assert_eq!(value["limit"]["accountId"], "slot-9f2c"); + assert_eq!(serde_json::from_value::(value).unwrap(), limited); + + // A missing reset or account is omitted, not null. + let unknown = SessionLimit { + harness: HarnessId::Codex, + window: "usage".into(), + resets_at: None, + account_id: None, + }; + let value = serde_json::to_value(&unknown).unwrap(); + assert!(value.get("resetsAt").is_none()); + assert!(value.get("accountId").is_none()); + + // Older engines never wrote `limit`; their rows parse as unlimited. + let legacy = serde_json::json!({ + "chatId": "chat-1", + "deviceId": "device-1", + "status": "errored", + "updatedAt": "2026-10-16T12:00:00Z", + }); + let old = serde_json::from_value::(legacy).unwrap(); + assert_eq!(old.limit, None); + let encoded = serde_json::to_value(&old).unwrap(); + assert!(encoded.get("limit").is_none()); + } } diff --git a/crates/proto/src/view.rs b/crates/proto/src/view.rs index 1f7cedaf72..e250a0ac37 100644 --- a/crates/proto/src/view.rs +++ b/crates/proto/src/view.rs @@ -33,6 +33,9 @@ pub enum Indicator { Working, AwaitingInput, Errored, + /// Errored with a recorded usage limit ([`Session::limit`]): the same + /// status row, presented as Limited — reset time known, Resume offered. + Limited, } /// A `Working`/`AwaitingInput` session older than this is treated as dead — a @@ -47,7 +50,13 @@ pub fn effective_indicator(session: Option<&Session>, now: DateTime) -> Ind }; match session.status { SessionStatus::Idle => Indicator::None, - SessionStatus::Errored => Indicator::Errored, + SessionStatus::Errored => { + if session.limit.is_some() { + Indicator::Limited + } else { + Indicator::Errored + } + } SessionStatus::Working | SessionStatus::AwaitingInput => { let age_ms = now .signed_duration_since(session.updated_at) @@ -63,6 +72,30 @@ pub fn effective_indicator(session: Option<&Session>, now: DateTime) -> Ind } } +/// Banner text for a Limited chat's reset time ("resets in 45m", "resets in +/// 2h 05m"). A reset already past reads as due — the honest offer is Resume, +/// not a countdown into the past. Pure. +pub fn format_limit_reset(resets_at: i64, now: DateTime) -> String { + let until = chrono::TimeDelta::milliseconds(resets_at - now.timestamp_millis()); + if until.num_seconds() <= 0 { + return "resets now".to_string(); + } + let minutes = until.num_minutes(); + if minutes < 60 { + return format!("resets in {minutes}m"); + } + let hours = minutes / 60; + let rest = minutes % 60; + if hours < 24 { + return if rest == 0 { + format!("resets in {hours}h") + } else { + format!("resets in {hours}h {rest:02}m") + }; + } + format!("resets in {}d", hours / 24) +} + /// The full display status for a chat row / tab dot: live states win, then the /// synced seen marker decides completed-vs-idle. Staleness gating rides on /// [`effective_indicator`]; the derivation itself is [`crate::chat_indicator`]. @@ -136,9 +169,14 @@ impl SessionNotificationState { /// Call after saving the new baseline, including when delivery is /// pending or outputs are disabled. Suppressed pings must never be - /// replayed later. Precedence: error, then input, then done. + /// replayed later. Precedence: error, then input, then done. A Limited + /// row counts as the error attention state: a usage limit ended the + /// turn just as loudly as a crash did. pub fn sound_since(&self, prev: &Self, send_pending: bool) -> Option { - if self.indicator == Indicator::Errored && prev.indicator != Indicator::Errored { + let attention = |indicator: Indicator| { + matches!(indicator, Indicator::Errored | Indicator::Limited) + }; + if attention(self.indicator) && !attention(prev.indicator) { return Some(NotifyKind::Attention); } if self.indicator == Indicator::AwaitingInput @@ -559,6 +597,10 @@ pub mod dot { pub const AWAITING: (f32, f32, f32) = (0.673, 0.182, 276.935); /// Errored. Red-400. pub const ERRORED: (f32, f32, f32) = (0.704, 0.191, 22.216); + /// Limited (errored on a usage limit). Amber — "waiting out a limit", + /// not the failure red; it must read differently from Errored at a + /// glance so a limited chat stands out in the sidebar. + pub const LIMITED: (f32, f32, f32) = (0.766, 0.155, 75.0); /// Finished but unseen. Emerald — reads as "ready for you". pub const COMPLETED: (f32, f32, f32) = (0.765, 0.177, 163.223); } @@ -710,6 +752,68 @@ mod checkout_tests { // the rule now lives here so desktop, web and mobile share one copy). // --------------------------------------------------------------------------- +#[cfg(test)] +mod limited_tests { + use super::*; + use chrono::TimeZone; + + fn session(status: SessionStatus, limit: Option) -> Session { + Session { + last_completed_turn: None, + chat_id: "c".into(), + device_id: "d".into(), + status, + started_at: None, + updated_at: Utc::now(), + limit, + } + } + + fn limit() -> crate::SessionLimit { + crate::SessionLimit { + harness: crate::HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some(0), + account_id: None, + } + } + + #[test] + fn an_errored_row_with_a_limit_reads_as_limited() { + let now = Utc::now(); + assert_eq!( + effective_indicator(Some(&session(SessionStatus::Errored, Some(limit()))), now), + Indicator::Limited + ); + // The same record on any other status is inert — a row that is + // working again (the record cleared on the new turn) never shows a + // stale Limited marker. + assert_eq!( + effective_indicator(Some(&session(SessionStatus::Working, Some(limit()))), now), + Indicator::Working + ); + assert_eq!( + effective_indicator(Some(&session(SessionStatus::Errored, None)), now), + Indicator::Errored + ); + assert_eq!(effective_indicator(None, now), Indicator::None); + } + + #[test] + fn reset_text_is_relative_and_never_counts_into_the_past() { + let now = Utc.with_ymd_and_hms(2026, 10, 16, 12, 0, 0).unwrap(); + let ms = |secs: i64| (now + chrono::TimeDelta::seconds(secs)).timestamp_millis(); + assert_eq!(format_limit_reset(ms(45 * 60), now), "resets in 45m"); + assert_eq!(format_limit_reset(ms(30), now), "resets in 0m"); + assert_eq!(format_limit_reset(ms(3 * 3600 + 5 * 60), now), "resets in 3h 05m"); + assert_eq!(format_limit_reset(ms(7 * 3600), now), "resets in 7h"); + assert_eq!(format_limit_reset(ms(2 * 86_400 + 3 * 3600), now), "resets in 2d"); + // A reset already due: the offer is Resume, not a negative countdown. + assert_eq!(format_limit_reset(ms(-600), now), "resets now"); + assert_eq!(format_limit_reset(now.timestamp_millis(), now), "resets now"); + } +} + #[cfg(test)] mod notification_tests { use super::*; @@ -745,6 +849,21 @@ mod notification_tests { assert_eq!(errored.sound_since(&errored, false), None); } + #[test] + fn a_limited_transition_chimes_attention_like_any_error() { + let working = baseline(Indicator::Working, None); + let limited = baseline(Indicator::Limited, None); + assert_eq!( + limited.sound_since(&working, false), + Some(NotifyKind::Attention) + ); + // Staying limited (a heartbeat re-publishing the row) never + // re-chimes, and a later plain error is the same attention state. + assert_eq!(limited.sound_since(&limited, false), None); + let errored = baseline(Indicator::Errored, None); + assert_eq!(errored.sound_since(&limited, false), None); + } + #[test] fn ordinary_queue_completions_survive_coalesced_working_states() { let first = baseline(Indicator::Working, None); @@ -795,6 +914,7 @@ mod notification_tests { status, started_at: None, updated_at: updated, + limit: None, }; let working = SessionNotificationState::new(&session(SessionStatus::Working, fresh), now); assert_eq!(working.indicator, Indicator::Working); diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 7e966fe999..2e1bf758b3 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1501,6 +1501,7 @@ mod tests { started_at: None, updated_at: chrono::Utc::now() - chrono::Duration::minutes(10), last_completed_turn: None, + limit: None, }; // No mid-turn capability is required for a live mailbox delivery. let sent = tools diff --git a/crates/ui/examples/macos-resource-profile.rs b/crates/ui/examples/macos-resource-profile.rs index 13fa587e97..150e2e234a 100644 --- a/crates/ui/examples/macos-resource-profile.rs +++ b/crates/ui/examples/macos-resource-profile.rs @@ -157,6 +157,7 @@ fn main() -> anyhow::Result<()> { }, started_at: Some(chrono::Utc::now()), updated_at: chrono::Utc::now(), + limit: None, }]); }) }); diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 6356babc86..4a09d9713e 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8836,6 +8836,32 @@ impl Composer { self.interrupt_chat(chat_id, cx); } + /// **Resume** on a Limited chat (orchestration v2): send the fixed + /// continue prompt as a NORMAL run — same send path, same guards, same + /// queue semantics as typing it — never a special engine mode. The run + /// clears the limit record when it starts; messages queued on the chat + /// stay behind the continuation. + pub(crate) fn resume_limited(&mut self, cx: &mut Context) { + // A draft the user was typing (and files they staged) are theirs, not + // part of the resume: the send clears the composer, so hand them + // straight back. `send` takes its own copies, so restoring the slots + // after it returns cannot leak into the sent message. + let draft = self.input.read(cx).text().to_string(); + let staged = self.attachments.remove(&self.current_key).unwrap_or_default(); + let appshots = self.appshots.remove(&self.current_key).unwrap_or_default(); + self.send(roboco_proto::RESUME_CONTINUE_PROMPT.into(), false, cx); + if !draft.is_empty() { + self.input.update(cx, |input, cx| input.set_text(&draft, cx)); + self.drafts.insert(self.current_key.clone(), draft); + } + if !staged.is_empty() { + self.attachments.insert(self.current_key.clone(), staged); + } + if !appshots.is_empty() { + self.appshots.insert(self.current_key.clone(), appshots); + } + } + pub(crate) fn interrupt_chat(&mut self, chat_id: String, cx: &mut Context) { let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() else { return; @@ -12088,6 +12114,7 @@ mod tests { status: roboco_proto::SessionStatus::Working, started_at: Some(updated), updated_at: updated, + limit: None, }]; state.selected_chat = Some("c".into()); state.watch_clock_transitions(cx); diff --git a/crates/ui/src/files/sections.rs b/crates/ui/src/files/sections.rs index b3881a936e..6c171f29cd 100644 --- a/crates/ui/src/files/sections.rs +++ b/crates/ui/src/files/sections.rs @@ -310,6 +310,9 @@ pub(super) struct ChildChatRow { pub chat_id: String, pub title: SharedString, pub status: ChatIndicator, + /// The chat reads as Limited right now (errored with a recorded usage + /// limit): the row's dot wears the amber limit tone. + pub limited: bool, pub time_ago: SharedString, /// The chat's linked pull request, drawn as the sidebar's badge. pub change_request: Option, @@ -333,6 +336,7 @@ pub(super) fn child_chat_rows( chat_id: chat.id.clone(), title: child_chat_title(chat).into(), status: state.display_status_for(chat, now), + limited: state.is_limited(&chat.id, now), time_ago: roboco_proto::view::format_time_ago(activity, now).into(), change_request: state.change_request_for_chat(chat).cloned(), activity, @@ -784,6 +788,7 @@ impl FilesSurface { let glyph = status_glyph( format!("files-subagent-{}", row.doc_id), row.indicator(), + false, view, theme, cx, @@ -874,6 +879,7 @@ impl FilesSurface { let glyph = status_glyph( format!("files-chat-{}", row.chat_id), row.status, + row.limited, view, theme, cx, @@ -1098,15 +1104,24 @@ fn row_title(id: String, title: SharedString) -> impl IntoElement { } /// The compact row's 13px status slot: Working animates the glyph spinner, -/// Completed wears the check, the rest a 6px dot in the status color. +/// Completed wears the check, the rest a 6px dot in the status color. A +/// LIMITED chat's dot is the amber limit tone (not the failure red), so a +/// usage-limited chat stands out in the chat lists like it does in the +/// sidebar. fn status_glyph( key: String, status: ChatIndicator, + limited: bool, view: EntityId, theme: &Theme, cx: &mut gpui::App, ) -> AnyElement { - let color = crate::shell::spaces::status_dot_color(status, theme); + let color = if limited { + let (l, c, h) = roboco_proto::view::dot::LIMITED; + crate::theme::oklch(l, c, h) + } else { + crate::shell::spaces::status_dot_color(status, theme) + }; let glyph: AnyElement = match status { ChatIndicator::Completed => icon(icons::CHECK) .size(px(11.0)) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 62f902ac92..274f953b6a 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -50,7 +50,8 @@ use crate::settings::{ platform_combo, sidebar_pin_profile_key, }; use crate::state::{ - AppState, ConnectionStatus, EngineBootConfig, GatePhase, Indicator, format_time_ago, + AppState, ConnectionStatus, EngineBootConfig, GatePhase, Indicator, format_limit_reset, + format_time_ago, }; use crate::terminal::panel::{TerminalPanel, ToggleTerminal, clamp_terminal_height}; use crate::theme::Theme; @@ -6393,18 +6394,24 @@ impl Shell { // FAILED (explicit, with the transcript's retry affordance); a send // whose delivery path is degraded is QUEUED, not Working — the // pending pill tells the truth instead of faking a spinner. - let (queued, undelivered) = { + let (queued, undelivered, limited) = { let now = Utc::now(); let state = self.state.read(cx); ( state.send_queued(&id, now), state.send_undelivered(&id, now), + state.is_limited(&id, now), ) }; let status_color = if undelivered { theme.danger } else if queued { theme.warning + } else if limited { + // LIMITED (orchestration v2): amber, not the failure red — the + // row says a usage limit stopped the chat, not a crash. + let (l, c, h) = roboco_proto::view::dot::LIMITED; + crate::theme::oklch(l, c, h) } else { spaces::status_dot_color(status, theme) }; @@ -6412,6 +6419,8 @@ impl Shell { Some("Failed") } else if queued { Some("Queued") + } else if limited { + Some("Limited") } else { match status { roboco_proto::ChatIndicator::Working => Some("Working"), @@ -9514,6 +9523,60 @@ impl Shell { .text_color(theme.danger) .child(SharedString::from("Run failed")) .into_any_element(), + // LIMITED (orchestration v2): the run ended on a harness usage + // limit. The banner names the window and its reset (from the + // harness's own error, else the account's cached windows), and + // Resume sends the continue prompt as a normal run — queued + // messages stay behind it. + Indicator::Limited => { + let limit = state.limit_for(&chat_id, now); + let label: SharedString = match &limit { + Some(limit) => { + let reset = limit + .resets_at + .map(|at| format_limit_reset(at, now)) + .unwrap_or_else(|| "reset time unknown".into()); + format!("{} limit · {reset}", limit.window).into() + } + None => "Usage limit reached".into(), + }; + strip + .text_color(theme.warning) + .child(div() + .flex() + .items_center() + .gap(px(Theme::SPACE_SM)) + .child(div() + .size(px(6.0)) + .flex_none() + .rounded_full() + .bg(theme.warning)) + .child(label)) + .child( + div() + .id("limited-resume") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_click(cx.listener(|this, _, _, cx| { + this.composer + .update(cx, |composer, cx| composer.resume_limited(cx)); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Send the continue prompt now", + )) + .child(SharedString::from("Resume")), + ) + .into_any_element() + } Indicator::None if sending => strip .child(loaders::gradient_spinner( "sending-indicator", @@ -11849,6 +11912,7 @@ mod tests { status, started_at: Some(updated_at), updated_at, + limit: None, } } diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index 63a4d1582c..948fbdf264 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -607,8 +607,8 @@ async fn query_engine_info(client: &RpcClient) -> Result { // this crate reads them as `state::…`. pub use roboco_proto::view::{ ChatGroup, ConnectionStatus, GatePhase, Indicator, SESSION_STALE_MS, attention_rank, - chat_location, display_status, effective_indicator, format_time_ago, gate_phase, group_chats, - project_label, sort_active, sort_chats, sort_spaces, sort_tabs, + chat_location, display_status, effective_indicator, format_limit_reset, format_time_ago, + gate_phase, group_chats, project_label, sort_active, sort_chats, sort_spaces, sort_tabs, }; // --------------------------------------------------------------------------- @@ -2161,6 +2161,23 @@ impl AppState { effective_indicator(self.session_for(chat_id), now) } + /// The usage-limit record for `chat_id`, when its status row reads as + /// Limited right now (errored with a recorded limit): the window, reset + /// time and account the Limited banner, the list markers and Resume act + /// on. `None` for plain errors and non-limited rows. + pub fn limit_for(&self, chat_id: &str, now: DateTime) -> Option { + let session = self.session_for(chat_id)?; + (self.indicator_for(chat_id, now) == Indicator::Limited) + .then(|| session.limit.clone()) + .flatten() + } + + /// Whether `chat_id` reads as Limited right now — the cheap marker + /// predicate for row dots (the record itself is [`Self::limit_for`]). + pub fn is_limited(&self, chat_id: &str, now: DateTime) -> bool { + self.indicator_for(chat_id, now) == Indicator::Limited + } + /// The earliest moment after `now` at which a clock-derived value flips /// with no frame to announce it: a Working/AwaitingInput session going /// stale ([`SESSION_STALE_MS`]), a remote device leaving its online window @@ -3982,6 +3999,7 @@ mod tests { status, started_at: None, updated_at: now - TimeDelta::seconds(updated_secs_ago), + limit: None, } } @@ -4023,6 +4041,7 @@ mod tests { status: SessionStatus::Working, started_at: Some(now), updated_at: now, + limit: None, }; assert!(state.apply_sessions_at(vec![row.clone()], now)); row.updated_at = now + TimeDelta::seconds(30); @@ -4054,6 +4073,7 @@ mod tests { status: SessionStatus::Working, started_at: Some(now), updated_at: now, + limit: None, }]; let mut row = device("host", "Host"); row.last_seen_at = Some(now); @@ -4297,6 +4317,44 @@ mod tests { ); } + /// A Limited chat is an errored row carrying the additive limit record: + /// the state derivation surfaces the record (banner + markers + Resume) + /// and only while the row is errored — a row that is working again + /// (a new turn clears the record) shows plain Working. + #[test] + fn limited_rows_expose_their_limit_record_while_errored() { + let mut state = AppState::new(); + let now = Utc::now(); + let mut limited = session("c", SessionStatus::Errored, 10, now); + assert_eq!(state.indicator_for("c", now), Indicator::None); + state.sessions = vec![limited.clone()]; + assert_eq!(state.indicator_for("c", now), Indicator::Errored); + assert_eq!(state.limit_for("c", now), None, "no record yet"); + + let record = roboco_proto::SessionLimit { + harness: roboco_proto::HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some((now + TimeDelta::hours(2)).timestamp_millis()), + account_id: Some("slot-9f2c".into()), + }; + limited.limit = Some(record.clone()); + state.sessions = vec![limited.clone()]; + assert_eq!(state.indicator_for("c", now), Indicator::Limited); + assert_eq!(state.limit_for("c", now), Some(record)); + + // The new turn clears the record: a Working row never reads Limited. + limited.status = SessionStatus::Working; + state.sessions = vec![limited.clone()]; + assert_eq!(state.indicator_for("c", now), Indicator::Working); + assert_eq!(state.limit_for("c", now), None); + // Even back on a settled row, no record means no Limited read. + limited.status = SessionStatus::Errored; + limited.limit = None; + state.sessions = vec![limited]; + assert_eq!(state.indicator_for("c", now), Indicator::Errored); + assert_eq!(state.limit_for("c", now), None); + } + #[test] fn display_status_derivation() { let now = Utc::now(); diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index ed7ff998c7..8f7b28ae6d 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -9955,6 +9955,7 @@ mod tests { last_completed_turn: None, started_at: None, updated_at: now, + limit: None, }]; }); assert!( @@ -13617,6 +13618,7 @@ mod tests { status: roboco_proto::SessionStatus::Working, started_at: Some(chrono::Utc::now()), updated_at: chrono::Utc::now(), + limit: None, }) }); this.on_own_send("chat".into(), "prompt".into(), cx); diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index 3691a28cdd..a653ff1649 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -131,6 +131,7 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::ChatIndicator, roboco_proto::SessionStatus, roboco_proto::Session, + roboco_proto::SessionLimit, roboco_proto::Repo, roboco_proto::RepoRef, roboco_proto::GitHistoryRefKind, diff --git a/web/packages/proto/src/generated/Session.ts b/web/packages/proto/src/generated/Session.ts index e46e3d030f..2739d99d9f 100644 --- a/web/packages/proto/src/generated/Session.ts +++ b/web/packages/proto/src/generated/Session.ts @@ -1,6 +1,7 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). // Legacy wire name (ADR 0005): the canonical term is "Chat status" — see CONTEXT.md. +import type { SessionLimit } from "./SessionLimit"; import type { SessionStatus } from "./SessionStatus"; /** @@ -14,4 +15,10 @@ export type Session = { * runs so coalesced status watches do not lose normal queue completions. * Interrupts, failures and liveness expiry never advance this marker. */ -lastCompletedTurn: string | null, chatId: string, deviceId: string, status: SessionStatus, startedAt: string | null, updatedAt: string, }; +lastCompletedTurn: string | null, chatId: string, deviceId: string, status: SessionStatus, startedAt: string | null, updatedAt: string, +/** + * The usage-limit record when the last turn ended on a harness usage + * limit — the additive "Limited" detail on the errored status (see + * [`SessionLimit`]). Absent on rows from older engines. + */ +limit?: SessionLimit | null, }; diff --git a/web/packages/proto/src/generated/SessionLimit.ts b/web/packages/proto/src/generated/SessionLimit.ts new file mode 100644 index 0000000000..7926b67f2c --- /dev/null +++ b/web/packages/proto/src/generated/SessionLimit.ts @@ -0,0 +1,33 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +import type { HarnessId } from "./HarnessId"; + +/** + * A usage-limit record for a Limited chat: what the chat hit and when the + * window resets. The chat stays `Errored` on the wire (ADR 0005: names are + * frozen, this row is additive) — clients that know the field layer the + * Limited presentation on top of the errored one. Written when a run ends + * on a usage-limit error; cleared when a new turn starts. + */ +export type SessionLimit = { +/** + * The harness the limited run was on. + */ +harness: HarnessId, +/** + * The harness's own window label ("5-hour", "weekly", … — "usage" + * when the harness did not name the window). + */ +window: string, +/** + * Epoch millis when the window resets, when known — from the harness's + * own error, else filled from the harness account's cached usage + * windows (see `AgentUsageWindow`). + */ +resetsAt?: number | null, +/** + * The harness account the limit hit (the engine slot id of the harness's + * active login), when the engine could resolve one. Account switching + * is addressed by this id. + */ +accountId?: string | null, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 908b430388..78b2f92ce0 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -101,6 +101,7 @@ export * from "./SessionCommandKind"; export * from "./SessionCommandPayload"; export * from "./SessionCommandStatus"; export * from "./SessionGrant"; +export * from "./SessionLimit"; export * from "./SessionMessageEntry"; export * from "./SessionStatus"; export * from "./SessionTail"; From ccf29200dae1483d20a95a25f294fe083df68010 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 17:58:54 +0700 Subject: [PATCH 23/96] feat(engine): completion notices wait for busy parents, merge, and survive restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 13 (orchestration v2) on top of ticket 12's outcome records: - Busy parent: a notice is never steered mid-turn. The run loop's turn-end sites (final exit, both parked-turn settles, the startup-failure path) deliver the outcomes that piled up while the parent worked, as the start of its next turn. An interrupted turn end (the user's Stop) wakes nobody yet — ticket 14 owns the stopped-parent rule. - Merging: outcomes that arrive before a delivery merge into one notice (the pending set is per parent and taken whole). - Durability: the pending set is mirrored to an engine-local ledger (completion-notices.json in the store root) on every change, loaded before boot recovery, and delivered after boot. Teardown never delivers (outcomes stay pending, durably) — same rule as the queue freeze. - Runs lost to a restart: boot recovery records an Interrupted outcome for a notifiable run the restart killed without auto-resume. New NoticeOutcome::Interrupted wire variant (additive, ADR 0005) renders as "was interrupted: engine restart"; auto-resumed runs notify from the resumed turn's own end, as before. - Archived chats are quiet both ways: an archived side chat records nothing, an archived parent is owed nothing (record-time and delivery-time checks; the ledger drops its rows). CONTEXT.md gains the Completion notice glossary entry. --- CONTEXT.md | 4 + crates/doc/src/parts.rs | 16 +- crates/engine/src/completion_notices.rs | 498 ++++++++++-- crates/engine/src/lib.rs | 8 + crates/engine/src/sessions.rs | 114 ++- crates/engine/tests/completion_notices.rs | 706 +++++++++++++++++- .../proto/src/generated/NoticeOutcome.ts | 2 +- .../proto/src/generated/NoticeSide.ts | 9 +- 8 files changed, 1284 insertions(+), 73 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 87dd992ad1..7fd9792455 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,6 +48,10 @@ _Avoid_: Conversation, thread, session (when it means the chat) A chat with a parent chat: forked from it in the UI, opened blank next to it, or created by its agent through the Roboco MCP tools. Agent-created side chats record their creator; user-created ones never notify it. Lists under the parent instead of the sidebar's main list. _Avoid_: Child chat, subagent (that is a harness-spawned agent inside a run) +**Completion notice**: +The engine-authored system message that tells an agent-created side chat's creator how a turn it started ended: finished, failed (one-line reason), stopped, interrupted (lost to an engine restart without auto-resume), or waiting for input. One merged notice per delivery; it points the parent at `read_chat` rather than carrying the side chat's output. +_Avoid_: Report, callback, acknowledgement + **Chat status**: A chat's live run state: idle, working, awaiting input, or errored. _Avoid_: Session, state (when it means chat status) diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 6e410ff998..933a519502 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -128,12 +128,16 @@ pub enum NoticeOutcome { Completed, Errored, Stopped, + /// The run was lost to an engine restart without auto-resume (ticket + /// 13): the work never finished — distinct from a stop, which the + /// user chose. + Interrupted, WaitingForInput, } /// One side chat a completion notice reports: its id and title, the -/// recorded outcome, and the one-line reason (error text or question -/// summary) when the outcome carries one. +/// recorded outcome, and the one-line reason (error text, restart reason or +/// question summary) when the outcome carries one. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct NoticeSide { @@ -144,8 +148,9 @@ pub struct NoticeSide { /// without a registry lookup (a later rename never rewrites history). pub title: String, pub outcome: NoticeOutcome, - /// The one-line detail for errored (reason) and waiting-for-input - /// (question summary) rows; absent otherwise. + /// The one-line detail for errored (reason), interrupted (restart + /// reason) and waiting-for-input (question summary) rows; absent + /// otherwise. #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, } @@ -252,7 +257,8 @@ pub enum MessagePart { /// The engine's completion notice (orchestration v2): an engine-authored /// SYSTEM entry in a parent chat reporting the recorded outcomes of its /// agent-created side chats' turns — finished, failed (one-line reason), - /// stopped, or waiting for input (one-line summary). The entry's sibling + /// stopped, interrupted by an engine restart without auto-resume, or + /// waiting for input (one-line summary). The entry's sibling /// text part carries the same notice as plain text, so older layouts — /// whose unknown-kind fallback yields an empty part — stay readable. /// Written only by the engine when it delivers a notice; never a user diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 1a6943ca15..bd623e78d5 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -1,15 +1,29 @@ //! Completion notices (orchestration v2): when a turn of an agent-created, -//! notifying side chat comes to rest, the engine records an outcome for the -//! chat that created it — based on its own recorded turn-end state, never on -//! anything the agent calls — and wakes an idle parent with one +//! notifying side chat comes to rest, the engine records an outcome for +//! the chat that created it — based on its own recorded turn-end state, +//! never on anything the agent calls — and wakes the parent with one //! engine-authored notice message that starts a parent turn. //! -//! This module is the tracer bullet (ticket 12): outcome records for -//! completed / errored / stopped / awaiting-input turns, and delivery to an -//! IDLE parent. A busy parent is left to ticket 13 (its notice waits for the -//! turn to end, and several outcomes merge into one notice); restart -//! durability, acknowledgement and the loop cap land in tickets 13–14. The -//! pending records here are the seam those tickets build on. +//! Ticket 12 was the tracer bullet (outcome records, idle-parent +//! delivery). This module carries ticket 13's rules on top: +//! +//! - **Busy parent:** a notice is never steered into a working parent. The +//! outcome stays pending until the parent's turn ends, and the notice +//! then starts its NEXT turn (the run loop calls [`deliver`] from its +//! turn-end sites — see `spawn_notice_delivery` in `sessions.rs`). +//! - **Merging:** outcomes that arrive before a delivery — several side +//! chats finishing while the parent works, or one chat's later turns — +//! pile up in the same pending set and are delivered as ONE notice. +//! - **Durability:** the pending set is mirrored to an engine-local ledger +//! (ADR 0004) on every change and loaded at assembly, so undelivered +//! outcomes survive an engine restart and reach their parent after boot. +//! - **Runs lost to a restart:** boot recovery records an [`Outcome::Interrupted`] +//! outcome for a notifiable run the restart killed and did not +//! auto-resume. An auto-resumed run owes nothing at boot — the +//! re-dispatch keeps the starting message's origin, so the notice +//! follows the resumed turn's own end. +//! - **Archived chats are quiet both ways:** an archived side chat records +//! nothing for its parent, and an archived parent is owed nothing. //! //! The notice message is a SYSTEM entry — never a user message — carrying a //! [`roboco_doc::MessagePart::CompletionNotice`] marker so clients can render @@ -18,8 +32,11 @@ //! id with its outcome, and points the parent agent at `read_chat`. use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, PoisonError}; +use serde::{Deserialize, Serialize}; + use roboco_doc::{ MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, SessionMessageEntry, }; @@ -33,6 +50,10 @@ use crate::{new_id, now_ms}; /// is a pointer to `read_chat`, never a report. pub(crate) const NOTICE_DETAIL_MAX: usize = 160; +/// The reason a restart-lost outcome carries: one line, from the engine's +/// own interruption note ("Run interrupted by engine restart"). +pub(crate) const INTERRUPTED_REASON: &str = "engine restart"; + /// The engine's recorded turn-end state for one notifiable side-chat turn. #[derive(Debug, Clone, PartialEq)] pub(crate) enum Outcome { @@ -40,13 +61,47 @@ pub(crate) enum Outcome { Completed, /// The turn errored, with its one-line reason. Errored { reason: Option }, - /// The turn was stopped or interrupted. + /// The turn was stopped or interrupted by the user. Stopped, + /// The run was lost to an engine restart and not auto-resumed (ticket + /// 13): the parent is told the work never finished. An auto-resumed + /// run owes nothing here — its notice follows the resumed turn's end. + Interrupted { reason: Option }, /// The turn is waiting for input, with the one-line summary of the /// question or approval it is waiting on. WaitingForInput { summary: String }, } +impl Outcome { + /// The wire row this outcome records as: the outcome kind plus the + /// one-line detail it carries. One shape for the notice row, the + /// durable ledger and the notice text. + fn as_row(&self) -> (NoticeOutcome, Option) { + match self { + Outcome::Completed => (NoticeOutcome::Completed, None), + Outcome::Errored { reason } => (NoticeOutcome::Errored, reason.clone()), + Outcome::Stopped => (NoticeOutcome::Stopped, None), + Outcome::Interrupted { reason } => (NoticeOutcome::Interrupted, reason.clone()), + Outcome::WaitingForInput { summary } => { + (NoticeOutcome::WaitingForInput, Some(summary.clone())) + } + } + } + + /// Rebuild an outcome from a stored row ([`Outcome::as_row`]'s inverse). + fn from_row(outcome: NoticeOutcome, detail: Option) -> Outcome { + match outcome { + NoticeOutcome::Completed => Outcome::Completed, + NoticeOutcome::Errored => Outcome::Errored { reason: detail }, + NoticeOutcome::Stopped => Outcome::Stopped, + NoticeOutcome::Interrupted => Outcome::Interrupted { reason: detail }, + NoticeOutcome::WaitingForInput => Outcome::WaitingForInput { + summary: detail.unwrap_or_else(|| "input".into()), + }, + } + } +} + /// An outcome recorded for a parent, awaiting delivery. #[derive(Debug, Clone)] pub(crate) struct PendingOutcome { @@ -55,28 +110,66 @@ pub(crate) struct PendingOutcome { pub(crate) outcome: Outcome, } -/// Outcome records owed to parents, keyed by parent chat id. In-memory for -/// the tracer bullet; ticket 13 makes them durable and merges the ones a -/// busy parent accumulates into one notice. +/// One pending outcome as it rests on disk. The wire row's own shape +/// (camelCase, additive — ADR 0005), so the ledger needs no schema of its +/// own and a row written by an older engine still reads. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredOutcome { + side_chat_id: String, + outcome: NoticeOutcome, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, +} + +impl From<&PendingOutcome> for StoredOutcome { + fn from(pending: &PendingOutcome) -> Self { + let (outcome, detail) = pending.outcome.as_row(); + Self { + side_chat_id: pending.side_chat_id.clone(), + outcome, + detail, + } + } +} + +impl From for PendingOutcome { + fn from(stored: StoredOutcome) -> Self { + Self { + side_chat_id: stored.side_chat_id, + outcome: Outcome::from_row(stored.outcome, stored.detail), + } + } +} + +/// Outcome records owed to parents, keyed by parent chat id. #[derive(Debug, Default)] pub(crate) struct CompletionNotices { pending: Mutex>>, + /// Where `pending` is mirrored (ticket 13): an outcome is a result the + /// parent has not received yet, and losing one means a child's work + /// silently never reaches its creator. `None` until a store path is + /// wired — bare `SessionsEngine` tests run in-memory, like the + /// journal's. + store: Mutex>, } impl CompletionNotices { pub(crate) fn record(&self, parent: &str, outcome: PendingOutcome) { - lock(&self.pending) - .entry(parent.to_string()) - .or_default() - .push(outcome); + let mut pending = lock(&self.pending); + pending.entry(parent.to_string()).or_default().push(outcome); + self.persist(&pending); } /// Take everything owed to `parent` for a delivery. `None` when the /// parent is owed nothing. pub(crate) fn take_for_delivery(&self, parent: &str) -> Option> { - lock(&self.pending) - .remove(parent) - .filter(|outcomes| !outcomes.is_empty()) + let mut pending = lock(&self.pending); + let outcomes = pending.remove(parent).filter(|outcomes| !outcomes.is_empty()); + if outcomes.is_some() { + self.persist(&pending); + } + outcomes } /// A delivery that could not start a parent turn (busy again, no run @@ -86,11 +179,103 @@ impl CompletionNotices { if outcomes.is_empty() { return; } - let mut pending = lock(&self.pending) - .entry(parent.to_string()) - .or_default(); + let mut pending = lock(&self.pending); + let owed = pending.entry(parent.to_string()).or_default(); for outcome in outcomes.into_iter().rev() { - pending.insert(0, outcome); + owed.insert(0, outcome); + } + self.persist(&pending); + } + + /// Archived and deleted parents are owed nothing (ticket 13): the + /// pending records go, and the ledger forgets them. + pub(crate) fn drop_for(&self, parent: &str) { + let mut pending = lock(&self.pending); + if pending.remove(parent).is_some() { + self.persist(&pending); + } + } + + /// The parents owed outcomes right now — the boot delivery's iteration. + pub(crate) fn parents(&self) -> Vec { + lock(&self.pending).keys().cloned().collect() + } + + /// Wire the durable ledger and load what a previous engine run left: + /// outcomes recorded but never delivered survive the restart. A + /// missing file is a first boot (empty ledger, not a failure); an + /// unreadable or malformed one starts empty rather than failing boot — + /// the worst outcome is one undelivered notice, never a dead engine. + pub(crate) fn load(&self, path: &Path) { + { + let mut store = lock(&self.store); + *store = Some(path.to_path_buf()); + } + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "completion-notice ledger unreadable; starting empty" + ); + return; + } + }; + let stored: HashMap> = match serde_json::from_slice(&bytes) { + Ok(stored) => stored, + Err(err) => { + tracing::warn!( + path = %path.display(), + error = %err, + "completion-notice ledger malformed; starting empty" + ); + return; + } + }; + let mut pending = lock(&self.pending); + for (parent, rows) in stored { + pending + .entry(parent) + .or_default() + .extend(rows.into_iter().map(PendingOutcome::from)); + } + tracing::info!( + parents = pending.len(), + "completion-notice ledger loaded" + ); + } + + /// Mirror `pending` into the ledger. Called under the pending lock, so + /// a record and its write are atomic against other records — two + /// outcomes racing cannot write snapshots that miss each other. Synchronous + /// and best-effort: the map is small (one row per undelivered outcome), + /// writes are rare (a record, a take, a requeue), and a lost write + /// costs at most one notice. + fn persist(&self, pending: &HashMap>) { + let Some(path) = lock(&self.store).clone() else { + return; + }; + let stored: HashMap<&str, Vec> = pending + .iter() + .map(|(parent, outcomes)| { + ( + parent.as_str(), + outcomes.iter().map(StoredOutcome::from).collect(), + ) + }) + .collect(); + let result = (|| -> Result<(), std::io::Error> { + let bytes = serde_json::to_vec(&stored) + .map_err(|err| std::io::Error::other(err.to_string()))?; + let temporary = path.with_extension("json.tmp"); + std::fs::write(&temporary, &bytes)?; + std::fs::rename(&temporary, &path)?; + Ok(()) + })(); + if let Err(err) = result { + tracing::warn!(error = %err, "completion-notice ledger write failed"); } } } @@ -137,6 +322,17 @@ pub(crate) fn question_summary(questions: &[roboco_proto::UserInputQuestion]) -> one_line(Some(text)).unwrap_or_else(|| "input".into()) } +/// Is this chat archived (ticket 13)? A missing or unreadable row reads as +/// active: only a row that says archived silences notices — a row the +/// engine cannot read must not silently eat a report. +pub(crate) fn chat_archived(engine: &SessionsEngine, chat_id: &str) -> bool { + engine + .host() + .and_then(|host| host.workspace()) + .and_then(|workspace| workspace.chat(chat_id).ok().flatten()) + .is_some_and(|chat| chat.archived) +} + /// Record an outcome for a side-chat turn and deliver what its parent is /// owed. Called from the run loop, fire-and-forget: the side chat's own /// event stream never waits on the parent's turn. @@ -146,6 +342,12 @@ pub(crate) async fn record_and_deliver( parent: &str, outcome: Outcome, ) { + // Archived chats are quiet both ways (ticket 13): an archived side chat + // sends nothing, an archived parent is owed nothing. Both read the row + // as it is now — archiving is a deliberate state change, not a flap. + if chat_archived(engine, side_chat) || chat_archived(engine, parent) { + return; + } engine.notices().record( parent, PendingOutcome { @@ -157,13 +359,29 @@ pub(crate) async fn record_and_deliver( } /// Deliver the parent's pending outcomes as one engine-authored notice that -/// starts a parent turn. An idle parent is woken immediately; a busy one -/// receives nothing here — its outcomes stay pending for ticket 13's -/// end-of-turn delivery, and the notice is never steered mid-turn. +/// starts a parent turn. Called when an outcome lands — an idle parent is +/// woken immediately — and whenever a parent's turn ends (ticket 13's +/// busy-parent rule: the notice was never steered mid-turn; it starts the +/// parent's next turn now). A parent that turns busy again while the +/// delivery is being built requeues its outcomes untouched, for the next +/// turn end. pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { + // Teardown never starts a parent's notice turn (the same rule that + // freezes the message queues): the outcomes stay pending — durably — + // and the next boot delivers them. + if engine.shutting_down() { + return; + } if engine.turn_in_flight(parent) { return; } + // An archived parent receives nothing (ticket 13): drop what piled up + // rather than holding it — an archived chat owes no wakeups, even after + // it is unarchived. + if chat_archived(engine, parent) { + engine.notices().drop_for(parent); + return; + } let Some(outcomes) = engine.notices().take_for_delivery(parent) else { return; }; @@ -200,9 +418,10 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { return; }; // A parent turn that started while this delivery was being built - // receives the notice after its turn instead (ticket 13): it is never + // receives the notice after that turn instead (ticket 13): it is never // steered mid-turn. The entry has not been written yet, so the outcomes - // go back to the front of the pending queue untouched. + // go back to the front of the pending queue untouched — the run loop's + // turn-end delivery picks them up when that turn ends. if engine.turn_in_flight(parent) { engine.notices().requeue(parent, outcomes); return; @@ -233,14 +452,7 @@ fn side_row(host: &DocHost, outcome: &PendingOutcome) -> NoticeSide { .map(|title| title.trim().to_owned()) .filter(|title| !title.is_empty()) .unwrap_or_else(|| "Side chat".into()); - let (recorded, detail) = match &outcome.outcome { - Outcome::Completed => (NoticeOutcome::Completed, None), - Outcome::Errored { reason } => (NoticeOutcome::Errored, reason.clone()), - Outcome::Stopped => (NoticeOutcome::Stopped, None), - Outcome::WaitingForInput { summary } => { - (NoticeOutcome::WaitingForInput, Some(summary.clone())) - } - }; + let (recorded, detail) = outcome.outcome.as_row(); NoticeSide { chat_id: outcome.side_chat_id.clone(), title, @@ -281,19 +493,23 @@ fn read_line(sides: &[NoticeSide]) -> String { fn side_line(side: &NoticeSide) -> String { let outcome = match side.outcome { NoticeOutcome::Completed => "finished".into(), - NoticeOutcome::Errored => match &side.detail { - Some(reason) => format!("failed: {reason}"), - None => "failed".into(), - }, + NoticeOutcome::Errored => with_detail(side, "failed"), NoticeOutcome::Stopped => "was stopped".into(), - NoticeOutcome::WaitingForInput => match &side.detail { - Some(summary) => format!("is waiting for input: {summary}"), - None => "is waiting for input".into(), - }, + NoticeOutcome::Interrupted => with_detail(side, "was interrupted"), + NoticeOutcome::WaitingForInput => with_detail(side, "is waiting for input"), }; format!("\"{}\" ({}) {}", side.title, short_id(&side.chat_id), outcome) } +/// `": "` when the row carries a one-liner, else `""` — +/// the shape every detail-bearing outcome shares. +fn with_detail(side: &NoticeSide, verb: &str) -> String { + match &side.detail { + Some(text) => format!("{verb}: {text}"), + None => verb.into(), + } +} + /// Eight-char id prefix — the sidebar's convention. fn short_id(id: &str) -> &str { &id[..id.len().min(8)] @@ -417,6 +633,11 @@ mod tests { // A stopped row carries no detail. let stopped = side("chat-side-cdef", "Migration", NoticeOutcome::Stopped); assert!(notice_text(&[stopped]).contains("\"Migration\" (chat-si) was stopped.")); + // A restart-lost row says interrupted, with its one-line reason. + let mut interrupted = side("chat-side-cdef", "Migration", NoticeOutcome::Interrupted); + interrupted.detail = Some(INTERRUPTED_REASON.into()); + let text = notice_text(&[interrupted]); + assert!(text.contains("\"Migration\" (chat-si) was interrupted: engine restart.")); // Degenerate empty input still renders a pointer, never a panic. assert!(notice_text(&[]).contains("read_chat")); } @@ -450,4 +671,191 @@ mod tests { assert_eq!(requeued[0].side_chat_id, "chat-side-1"); assert_eq!(requeued[1].side_chat_id, "chat-side-2"); } + + #[test] + fn drop_for_forgets_a_parent_and_parents_lists_the_owed() { + let notices = CompletionNotices::default(); + assert!(notices.parents().is_empty()); + notices.record( + "parent-a", + PendingOutcome { + side_chat_id: "chat-side-1".into(), + outcome: Outcome::Completed, + }, + ); + notices.record( + "parent-b", + PendingOutcome { + side_chat_id: "chat-side-2".into(), + outcome: Outcome::Interrupted { + reason: Some(INTERRUPTED_REASON.into()), + }, + }, + ); + let mut owed = notices.parents(); + owed.sort(); + assert_eq!(owed, vec!["parent-a".to_string(), "parent-b".to_string()]); + notices.drop_for("parent-a"); + assert_eq!(notices.parents(), vec!["parent-b".to_string()]); + // Dropping a parent that is owed nothing stays a no-op. + notices.drop_for("parent-a"); + assert_eq!(notices.parents(), vec!["parent-b".to_string()]); + } + + #[test] + fn pending_outcomes_survive_a_store_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("completion-notices.json"); + + let first = CompletionNotices::default(); + // Before a store is wired, records are in-memory only. + first.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-ephemeral".into(), + outcome: Outcome::Completed, + }, + ); + // Wiring the ledger on a fresh engine: no file, no outcomes. + let second = CompletionNotices::default(); + second.load(&path); + assert!(second.take_for_delivery("parent").is_none()); + + second.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-1234".into(), + outcome: Outcome::Completed, + }, + ); + second.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-5678".into(), + outcome: Outcome::Errored { + reason: Some("provider 502: bad gateway".into()), + }, + }, + ); + second.record( + "other-parent", + PendingOutcome { + side_chat_id: "chat-side-90ab".into(), + outcome: Outcome::Interrupted { + reason: Some(INTERRUPTED_REASON.into()), + }, + }, + ); + // A requeue (a delivery that could not start) is as durable as a + // record: the next engine still owes the outcomes. + second.requeue( + "parent", + vec![PendingOutcome { + side_chat_id: "chat-side-cdef".into(), + outcome: Outcome::WaitingForInput { + summary: "Should we bump the major version?".into(), + }, + }], + ); + + let reloaded = CompletionNotices::default(); + reloaded.load(&path); + let mut owed = reloaded.parents(); + owed.sort(); + assert_eq!( + owed, + vec!["other-parent".to_string(), "parent".to_string()] + ); + let mut parent = reloaded.take_for_delivery("parent").unwrap(); + assert_eq!(parent.len(), 3); + // The requeued outcome keeps its place at the front. + assert_eq!(parent.remove(0).side_chat_id, "chat-side-cdef"); + assert_eq!(parent[0].side_chat_id, "chat-side-1234"); + assert_eq!( + parent[0].outcome, + Outcome::Completed + ); + assert_eq!( + parent[1].outcome, + Outcome::Errored { + reason: Some("provider 502: bad gateway".into()) + } + ); + assert_eq!( + reloaded.take_for_delivery("other-parent").unwrap()[0].outcome, + Outcome::Interrupted { + reason: Some(INTERRUPTED_REASON.into()) + } + ); + // A ledger taken is a ledger written: re-delivery owes nothing. + let third = CompletionNotices::default(); + third.load(&path); + assert!(third.take_for_delivery("parent").is_none()); + assert!(third.take_for_delivery("other-parent").is_none()); + } + + #[test] + fn a_malformed_ledger_starts_empty_without_panicking() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("completion-notices.json"); + std::fs::write(&path, b"{not json").unwrap(); + let notices = CompletionNotices::default(); + notices.load(&path); + assert!(notices.parents().is_empty()); + // The wired ledger still accepts new records. + notices.record( + "parent", + PendingOutcome { + side_chat_id: "chat-side-1".into(), + outcome: Outcome::Completed, + }, + ); + assert_eq!(notices.parents(), vec!["parent".to_string()]); + } + + #[test] + fn outcome_rows_round_trip_through_every_kind() { + for (outcome, recorded, detail) in [ + (Outcome::Completed, NoticeOutcome::Completed, None), + ( + Outcome::Errored { + reason: Some("boom".into()), + }, + NoticeOutcome::Errored, + Some("boom".to_string()), + ), + (Outcome::Stopped, NoticeOutcome::Stopped, None), + ( + Outcome::Interrupted { + reason: Some(INTERRUPTED_REASON.into()), + }, + NoticeOutcome::Interrupted, + Some(INTERRUPTED_REASON.to_string()), + ), + ( + Outcome::WaitingForInput { + summary: "Proceed?".into(), + }, + NoticeOutcome::WaitingForInput, + Some("Proceed?".to_string()), + ), + ] { + let (row, row_detail) = outcome.as_row(); + assert_eq!(row, recorded); + assert_eq!(row_detail, detail); + assert_eq!(Outcome::from_row(row, row_detail), outcome); + } + // A stored row with no detail still rebuilds: errored keeps `None`, + // waiting falls back to the generic question summary. + assert_eq!( + Outcome::from_row(NoticeOutcome::Errored, None), + Outcome::Errored { reason: None } + ); + assert_eq!( + Outcome::from_row(NoticeOutcome::WaitingForInput, None), + Outcome::WaitingForInput { + summary: "input".into() + } + ); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 922770f249..12fa2e33fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -231,11 +231,19 @@ impl EngineCore { doc_host.set_workspace(workspace.clone()); doc_host.set_sessions(sessions.clone()); sessions.set_doc_host(doc_host.clone()); + // Pending completion notices are undelivered outcomes + // (orchestration v2, ticket 13): load the previous engine run's + // ledger BEFORE boot recovery, so the interrupted outcomes + // recovery records for runs the restart lost land in the same + // ledger — then deliver everything once recovery has settled the + // chats. + sessions.load_pending_notices(&profile.store_root().join("completion-notices.json")); match sessions.recover_stale() { Ok(0) => {} Ok(recovered) => tracing::info!(recovered, "stale sessions recovered on boot"), Err(err) => tracing::error!(error = %err, "stale-session recovery failed"), } + sessions.deliver_pending_notices(); doc_host.spawn_transcript_salvage(profile.store_root().join("journals")); let repos = Repos::new(data_dir, &device_id); doc_host.set_repos(repos.clone()); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 8f40e05715..abb072a89a 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -210,8 +210,9 @@ struct Inner { /// session id is the "do not resume" tombstone after a rejected resume. harness_sessions: Mutex>, /// Completion-notice bookkeeping (orchestration v2): the outcomes - /// side-chat turns owe their agent parents. Durability lands with - /// ticket 13; this is the in-memory seam. + /// side-chat turns owe their agent parents, mirrored to a durable + /// engine-local ledger so undelivered outcomes survive restarts + /// (ticket 13). notices: crate::completion_notices::CompletionNotices, /// Auto-titler for untitled chats (wired at engine assembly; absent in bare tests). titles: OnceLock, @@ -351,11 +352,42 @@ impl SessionsEngine { &self.inner.notices } + /// Load the durable completion-notice ledger a previous engine run + /// left (ticket 13): outcomes recorded but never delivered survive the + /// restart. Called at assembly, BEFORE boot recovery, so the + /// interrupted outcomes recovery records for runs the restart lost + /// land in the same ledger. + pub(crate) fn load_pending_notices(&self, path: &std::path::Path) { + self.inner.notices.load(path); + } + + /// Boot delivery (ticket 13): every parent the ledger owes gets its + /// notice now, unless it is already busy again. A parent whose crashed + /// run is auto-resumed may still read idle here — the staggered + /// revival lands up to a couple of seconds later — in which case the + /// notice turn starts and the revival joins it like any later message; + /// either order loses nothing, and the outcome always reaches the + /// parent. + pub(crate) fn deliver_pending_notices(&self) { + for parent in self.inner.notices.parents() { + spawn_notice_delivery(&self.inner, &parent); + } + } + /// The doc host, for the completion-notice delivery path (crate-internal). pub(crate) fn host(&self) -> Option { self.inner.doc_host() } + /// Whether the engine is tearing down (crate-internal): notice + /// delivery defers — outcomes stay durably pending, and the next boot + /// delivers them. + pub(crate) fn shutting_down(&self) -> bool { + self.inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + } + /// The parent a message from `origin` makes a turn of `chat_id` /// notifiable for: only the chat the durable agent-origin row /// (`roboco_proto::AgentOrigin` on the chat) names, and only while it @@ -1267,6 +1299,36 @@ impl SessionsEngine { recovered += 1; if !will_resume { + // Completion notices (orchestration v2, ticket 13): a + // notifiable run lost to the restart with no auto-resume + // owes its parent an interrupted outcome — the work never + // finished, and the parent may want to restart it. Recorded + // synchronously here (not through `spawn_notice_record`) + // so the boot delivery that runs after recovery sees it in + // the ledger. Archived chats are quiet both ways: an + // archived side chat sends nothing, an archived parent is + // owed nothing. + let notifiable = prompt + .as_ref() + .and_then(|(_, _, origin)| origin.as_deref()) + .and_then(|origin| self.notifiable_parent_for(&chat_id, Some(origin))) + .filter(|parent| { + !crate::completion_notices::chat_archived(self, &chat_id) + && !crate::completion_notices::chat_archived(self, parent) + }); + if let Some(parent) = notifiable { + self.inner.notices.record( + &parent, + crate::completion_notices::PendingOutcome { + side_chat_id: chat_id.clone(), + outcome: crate::completion_notices::Outcome::Interrupted { + reason: Some( + crate::completion_notices::INTERRUPTED_REASON.into(), + ), + }, + }, + ); + } continue; } let attempt = self.inner.journal.note_resume_attempt(&chat_id); @@ -2289,8 +2351,9 @@ fn run_notifiable_parent(inner: &Inner, chat_id: &str, run_id: &str) -> Option, side_chat: String, @@ -2309,6 +2372,31 @@ fn spawn_notice_record( }); } +/// Deliver the completion notices `chat_id` is owed as a PARENT, now that +/// its turn has ended (ticket 13's busy-parent rule): the outcomes that +/// piled up while it worked were never steered in, so they start the +/// chat's next turn now. Fire-and-forget, like the records: the run loop +/// never waits on another chat's turn. Delivery re-checks `turn_in_flight` +/// (a queued user message may have claimed the boundary first — then the +/// notice waits for that turn's own end) and `shutting_down` (teardown +/// never starts recovered work). A turn ended by an interrupt is the +/// user's Stop: no wakeup — ticket 14 owns the stopped-parent rule. +fn spawn_notice_delivery(inner: &Arc, chat_id: &str) { + if inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + return; + } + let engine = SessionsEngine { + inner: inner.clone(), + }; + let chat = chat_id.to_string(); + tokio::spawn(async move { + crate::completion_notices::deliver(&engine, &chat).await; + }); +} + /// Record the outcome of a turn that settled INSIDE the run loop (a parked /// persistent session, the quiesce watchdog): only real turn ends record — /// an internal handoff (a pending steer or a sent message owns the @@ -2563,6 +2651,10 @@ async fn drive_run( } inner.remove_run(&chat_id, &run_id); inner.set_status(&chat_id, SessionStatus::Errored, false); + // The chat's own turn is over (it errored before it started): + // notices that piled up while it was working start its next + // turn now (ticket 13). + spawn_notice_delivery(&inner, &chat_id); return; } }; @@ -2944,6 +3036,10 @@ async fn drive_run( completed_turn.is_some(), false, ); + // The watchdog-finalized turn is over: notices that + // piled up while the chat worked start its next turn + // (ticket 13). + spawn_notice_delivery(&inner, &chat_id); continue; } }; @@ -3589,6 +3685,9 @@ async fn drive_run( completed_turn.is_some(), stopped_in_place, ); + // The parked turn is over: notices that piled up while the + // chat worked start its next turn (ticket 13). + spawn_notice_delivery(&inner, &chat_id); continue; } final_completed_turn = completed_turn; @@ -3645,6 +3744,13 @@ async fn drive_run( spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); } } + // A busy parent's notices waited — never steered mid-turn (ticket 13): + // its turn is over, so they start its next one now. A turn ended by an + // interrupt is the user's Stop: no wakeup (ticket 14 owns the full + // stopped-parent rule). + if !interrupted { + spawn_notice_delivery(&inner, &chat_id); + } // A Stop cancels the messages it found waiting; one accepted after it // began is still the user's next message and must run. let orphans: Vec = if inner diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 245691f87f..bd9778cf70 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -1,6 +1,7 @@ -//! Completion notices wake an idle parent (orchestration v2, ticket 12). +//! Completion notices (orchestration v2, tickets 12 + 13). //! -//! The tracer bullet for the spec's completion-notice stories 13–17 and 19: +//! Ticket 12's tracer bullet for the spec's completion-notice stories +//! 13–17 and 19: //! - a turn of an agent-created, NOTIFYING side chat is notifiable when its //! starting message, or a steer within it, came from the parent; //! - when such a turn ends, the engine records an outcome from its OWN @@ -14,6 +15,19 @@ //! the agent to use `read_chat` for the full result; //! - a user-started turn in the child, a user-created side chat, and a //! `notify: false` child never wake the parent. +//! +//! Ticket 13's rules on top: +//! - a BUSY parent is never steered: the notice waits for the parent's +//! turn to end, then starts its next turn; +//! - several children finishing while the parent works merge into ONE +//! notice; +//! - pending outcomes survive an engine restart and are delivered after +//! boot; +//! - a notifiable run lost to a restart without auto-resume yields an +//! INTERRUPTED outcome; with auto-resume, the notice follows the resumed +//! turn; +//! - archived parents receive nothing and archived side chats send +//! nothing. use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -24,14 +38,16 @@ use futures::StreamExt; use futures::stream::BoxStream; use roboco_doc::{ - MessagePart, MessageRole, NoticeOutcome, NoticeSide, SessionMessageEntry, + MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, SessionCommandPayload, + SessionDoc, SessionMessageEntry, }; -use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_engine::{EngineCore, HarnessRegistry, RunJournal}; use roboco_harness::{Harness, HarnessError, RunControls}; use roboco_proto::{ AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SandboxLevel, SessionStatus, SteeringMode, UserInputQuestion, }; +use roboco_sync::DocsStore; const PARENT: &str = "chat-parent"; const SIDE: &str = "chat-side"; @@ -50,7 +66,8 @@ enum Turn { AskInput { question: &'static str }, /// Hang until a steer arrives: consume it, emit the `Steered` boundary, /// then settle the turn Completed (what a steered run does when its - /// work finishes). + /// work finishes). A Stop (the engine's interrupt token) ends the + /// parked turn as Interrupted, the way a real harness does. Hang, } @@ -123,6 +140,7 @@ impl Harness for Scripted { let steered = self.steered.clone(); let mut steering = controls.steering; let request_input = controls.request_input; + let interrupt = controls.interrupt; let started_session = session.clone(); let cwd = request.cwd.clone(); tokio::spawn(async move { @@ -206,6 +224,19 @@ impl Harness for Scripted { } None => break, }, + _ = interrupt.cancelled() => { + // A Stop: the turn ends interrupted, the runtime + // goes with it. + let _ = tx + .send(Ok(AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(session), + })) + .await; + break; + }, _ = tx.closed() => break, } }, @@ -305,13 +336,9 @@ async fn wait_idle(core: &EngineCore, chat: &str) { .await; } -/// Create a parent (with a cwd, so a notice turn can run on it) and an -/// agent-created side chat under it, titled, with `notify`. -async fn create_parent_and_side( - core: &EngineCore, - client: &roboco_rpc::RpcClient, - notify: Option, -) { +/// Create the orchestrating parent: a cwd'd, titled chat a notice turn can +/// run on. +async fn create_parent(core: &EngineCore, client: &roboco_rpc::RpcClient) { client .call( roboco_rpc::methods::MUTATE, @@ -325,6 +352,18 @@ async fn create_parent_and_side( .await .expect("createChat parent"); core.workspace.rename_chat(PARENT, "Orchestrator").unwrap(); +} + +/// Create an agent-created, notifying side chat under the parent: titled, +/// with a cwd (a boot auto-resume re-derives its run config from the row) +/// and the `notify` opt in/out. +async fn create_side( + core: &EngineCore, + client: &roboco_rpc::RpcClient, + chat_id: &str, + title: &str, + notify: Option, +) { let mut agent_origin = serde_json::json!({ "chatId": PARENT }); if let Some(notify) = notify { agent_origin["notify"] = serde_json::json!(notify); @@ -334,15 +373,26 @@ async fn create_parent_and_side( roboco_rpc::methods::MUTATE, serde_json::json!({ "op": "createChat", - "chatId": SIDE, + "chatId": chat_id, "deviceId": core.device_id, "parentChatId": PARENT, + "cwd": "/tmp", "agentOrigin": agent_origin, }), ) .await .expect("createChat side"); - core.workspace.rename_chat(SIDE, "Implement login").unwrap(); + core.workspace.rename_chat(chat_id, title).unwrap(); +} + +/// The ticket-12 shape: one parent and one titled side chat. +async fn create_parent_and_side( + core: &EngineCore, + client: &roboco_rpc::RpcClient, + notify: Option, +) { + create_parent(core, client).await; + create_side(core, client, SIDE, "Implement login", notify).await; } /// Queue a Run command for `chat`, optionally sent by the parent through the @@ -371,6 +421,56 @@ async fn queue_run( .expect("queue run"); } +/// Assemble an engine over `dir` whose runs the Scripted mock serves. +fn assemble(dir: &std::path::Path, scripted: Arc) -> EngineCore { + let registry = HarnessRegistry::new(); + registry.register(scripted); + EngineCore::assemble(dir, Arc::new(registry), HarnessId::Mock).expect("engine core assembles") +} + +/// A user's steer (no origin chat): the queue routes it into a working +/// chat's mailbox, and the mock's Hang turn consumes it and settles — the +/// way a working parent's turn ends. +async fn queue_user_steer( + client: &roboco_rpc::RpcClient, + chat: &str, + prompt: &str, + message_id: &str, +) { + client + .call( + roboco_rpc::methods::QUEUE_COMMAND, + serde_json::json!({ + "chatId": chat, + "command": serde_json::to_value(SessionCommandPayload::Steer { + prompt: prompt.into(), + message_id: Some(message_id.into()), + }) + .unwrap(), + }), + ) + .await + .expect("queue steer"); +} + +/// Rows the durable completion-notice ledger under this engine's data dir +/// currently holds — the engine-local surface restart round-trips assert +/// on, like the run journals. +fn ledger_rows(data_dir: &std::path::Path) -> usize { + std::fs::read(data_dir.join("orgs/dev-org/dev-user/completion-notices.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|ledger| { + ledger.as_object().map(|parents| { + parents + .values() + .map(|rows| rows.as_array().map_or(0, Vec::len)) + .sum() + }) + }) + .unwrap_or(0) +} + /// An idle parent is woken by a notice when its side chat's parent-started /// turn completes: the notice is a system entry (never a user message) /// carrying the marker, names the child by title and short id, points at @@ -832,3 +932,581 @@ async fn a_parent_steer_within_the_turn_makes_it_notifiable() { core.shutdown().await; } + +/// A busy parent is never steered: the child's outcome waits for the +/// parent's turn to end, and the notice then starts the parent's NEXT turn +/// (ticket 13). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_working_parent_receives_the_notice_after_its_turn_as_its_next_turn() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn: hangs until the user steers it. + Turn::Hang, + // The side chat's turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's notice turn, after its turn ends. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // The user starts the parent's turn; it hangs (the parent is working). + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + + // The parent starts the child's turn through the MCP surface; the + // child completes while the parent still works. + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + + // No notice while the parent is busy: nothing steered in, no parent + // turn started, no entry written. The outcome is recorded durably + // (one ledger row) before the parent's turn ends. + assert!( + core.sessions.turn_in_flight(PARENT), + "the parent is still working" + ); + assert!(notice_entries(&core, PARENT).is_empty()); + assert_eq!(scripted.run_count(), 2); + assert!( + scripted.steered.lock().unwrap().is_empty(), + "nothing was steered into the parent mid-turn" + ); + wait_for( + || ledger_rows(dir.path()) == 1, + "the child's outcome to be recorded durably", + ) + .await; + + // The user's steer ends the parent's turn. + queue_user_steer(&client, PARENT, "please wrap up", "m-steer-1").await; + wait_for( + || scripted.prompts_containing("finished"), + "the notice to start the parent's next turn", + ) + .await; + + // ONE notice, delivered as the parent's next turn — never steered in. + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + assert_eq!(scripted.run_count(), 3); + assert_eq!( + scripted.steered.lock().unwrap().clone(), + vec!["please wrap up".to_string()], + "the only consumed steer is the user's" + ); + wait_idle(&core, PARENT).await; + + core.shutdown().await; +} + +/// Three children finishing while the parent works produce ONE merged +/// notice naming each of them (ticket 13). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn three_children_finishing_while_the_parent_works_merge_into_one_notice() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn. + Turn::Hang, + // The three children's turns. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's merged notice turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent(&core, &client).await; + let sides = [ + ("chat-side-1", "Implement login"), + ("chat-side-2", "Write the docs"), + ("chat-side-3", "Run the tests"), + ]; + for (chat_id, title) in sides { + create_side(&core, &client, chat_id, title, None).await; + } + + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + for (index, (chat_id, _)) in sides.iter().enumerate() { + queue_run( + &client, + chat_id, + "please do your part", + &format!("m-run-{index}"), + Some(PARENT), + ) + .await; + } + for (chat_id, _) in sides { + wait_idle(&core, chat_id).await; + } + + // All three outcomes are owed while the parent works: no notice yet, + // and all three are recorded durably before the turn ends — the merged + // notice is what the ledger holds at the boundary. + assert!(core.sessions.turn_in_flight(PARENT)); + assert!(notice_entries(&core, PARENT).is_empty()); + assert_eq!(scripted.run_count(), 4); + wait_for( + || ledger_rows(dir.path()) == 3, + "the three children's outcomes to be recorded durably", + ) + .await; + + // The user's steer ends the parent's turn: the merged notice is the + // next one. + queue_user_steer(&client, PARENT, "please wrap up", "m-steer-1").await; + wait_for( + || scripted.prompts_containing("3 side chats reached outcomes"), + "the merged notice to start the parent's next turn", + ) + .await; + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1, "one merged notice, not one per child"); + let mut rows = notice_sides(¬ices[0]); + rows.sort_by(|a, b| a.chat_id.cmp(&b.chat_id)); + let mut expected: Vec = sides + .iter() + .map(|(chat_id, title)| NoticeSide { + chat_id: (*chat_id).into(), + title: (*title).into(), + outcome: NoticeOutcome::Completed, + detail: None, + }) + .collect(); + expected.sort_by(|a, b| a.chat_id.cmp(&b.chat_id)); + assert_eq!(rows, expected); + let text = notice_text(¬ices[0]); + assert!(text.starts_with("3 side chats reached outcomes:")); + for (chat_id, title) in sides { + assert!(text.contains(&format!("\"{title}\" ({}) finished", &chat_id[..8]))); + } + assert!(text.contains("Read each chat's full result")); + assert_eq!( + scripted.run_count(), + 5, + "the parent ran its own turn and one notice turn" + ); + wait_idle(&core, PARENT).await; + + core.shutdown().await; +} + +/// A pending notice survives an engine restart: the outcome a busy parent +/// never received is loaded from the durable ledger and delivered after +/// boot (restart-resume prior art: shutdown → relaunch over one dir). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pending_notice_survives_an_engine_restart_and_is_delivered_after_boot() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + + // Engine 1: the child completes while the parent works, so the outcome + // is recorded into the durable ledger and stays pending. + let first = Scripted::new(vec![ + Turn::Hang, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(&dir, first.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || first.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + assert!(core.sessions.turn_in_flight(PARENT)); + assert!(notice_entries(&core, PARENT).is_empty()); + // The outcome is recorded durably before the engine dies — that is the + // whole point of the ledger. + wait_for( + || ledger_rows(&dir) == 1, + "the child's outcome to be recorded durably", + ) + .await; + + // The engine dies: shutdown interrupts the parent's turn, and an + // interrupted turn wakes nobody, so the notice stays pending. + core.shutdown().await; + drop(core); + + // Engine 2 over the same data dir: the ledger survives, and the notice + // is delivered after boot. + let second = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let core = assemble(&dir, second.clone()); + wait_for( + || second.prompts_containing("finished"), + "the pending notice to be delivered after boot", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + assert_eq!( + second.run_count(), + 1, + "the notice turn is the relaunch's only run" + ); + + core.shutdown().await; +} + +/// The kill -9 state for a notifiable side-chat run: rows from a live +/// engine, the doc and journal written by hand (restart-resume prior art). +/// `age_ms` backs the crash, deciding whether boot recovery auto-resumes +/// the run; the side chat's last user message carries the parent origin. +fn manufacture_lost_side_chat_run(dir: &std::path::Path, device_id: &str, age_ms: i64) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); + let doc = SessionDoc::init(SIDE).unwrap(); + doc.push_message(&SessionMessageEntry { + origin_chat_id: Some(PARENT.into()), + id: "m-run-1".into(), + role: MessageRole::User, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: "please implement login".into(), + }], + created_at: now - age_ms, + device_id: device_id.into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + provider_turn: None, + }) + .unwrap(); + store + .save_snapshot(SIDE, &doc.export_snapshot().unwrap()) + .unwrap(); + let journal = RunJournal::open(dir.join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + SIDE, + &AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "hs-side".into(), + assistant_message_id: "a-1".into(), + }, + ) + .unwrap(); +} + +/// A child run lost to a restart with NO auto-resume (the crashed turn is +/// older than the revival window) yields an INTERRUPTED outcome at boot: +/// the parent is told the work never finished, and it may restart it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_run_lost_to_a_restart_without_auto_resume_notifies_interrupted() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + + // Engine 1 creates the rows only. + { + let scripted = Scripted::new(vec![]); + let core = assemble(&dir, scripted); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + let device_id = core.device_id.clone(); + core.shutdown().await; + // The run died 13h ago — outside the 12h auto-resume window. + manufacture_lost_side_chat_run(&dir, &device_id, 13 * 60 * 60 * 1000); + } + + // Engine 2: recovery records the interrupted outcome, and the boot + // delivery wakes the parent with it. + let scripted = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let core = assemble(&dir, scripted.clone()); + wait_for( + || scripted.prompts_containing("was interrupted"), + "the interrupted notice to be delivered after boot", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Interrupted, + detail: Some("engine restart".into()), + }] + ); + let text = notice_text(¬ices[0]); + assert!(text.contains("was interrupted: engine restart")); + assert!(text.contains("read_chat")); + // The lost run was not revived: the notice turn is the only run. + assert_eq!(scripted.run_count(), 1); + + core.shutdown().await; +} + +/// A child run lost to a restart WITH auto-resume (a fresh crash) owes no +/// interrupted outcome: the revival re-dispatch keeps the starting +/// message's parent origin, so the notice follows the RESUMED turn's end. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_auto_resumed_run_notifies_when_the_resumed_turn_ends() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + + // Engine 1 creates the rows only. + { + let scripted = Scripted::new(vec![]); + let core = assemble(&dir, scripted); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + let device_id = core.device_id.clone(); + core.shutdown().await; + // The run died a minute ago — inside the revival window. + manufacture_lost_side_chat_run(&dir, &device_id, 60_000); + } + + // Engine 2: the crashed run is picked back up (staggered start) under + // the same user message, and the notice follows its completion. + let scripted = Scripted::new(vec![ + // The revived side-chat turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's notice turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(&dir, scripted.clone()); + wait_for( + || scripted.prompts_containing("please implement login"), + "the crashed turn to be auto-resumed", + ) + .await; + wait_for( + || scripted.prompts_containing("finished"), + "the completion notice to reach the parent", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + // The notice reports the RESUMED turn's completion, not an + // interruption. + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + let text = notice_text(¬ices[0]); + assert!(!text.contains("interrupted")); + // The revival ran the crashed prompt; the notice is the second turn. + assert_eq!(scripted.run_count(), 2); + assert_eq!( + scripted.prompts.lock().unwrap()[0], + "please implement login", + "the revival re-dispatched the crashed turn's own message" + ); + + core.shutdown().await; +} + +/// An archived parent receives nothing: new outcomes are never recorded +/// for it (ticket 13). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_archived_parent_receives_no_notices() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + core.workspace.set_chat_archived(PARENT, true).unwrap(); + + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + // Let a wrongly recorded outcome settle before asserting absence. + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "an archived parent must receive no notices" + ); + assert_eq!(scripted.run_count(), 1, "no parent turn was started"); + + core.shutdown().await; +} + +/// An archived side chat sends nothing: archiving the child mid-run +/// suppresses the outcome its stopped turn would otherwise owe (an +/// interrupt is not a message, so it never revives the archived chat). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_archived_side_chat_sends_no_notices() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![Turn::Hang]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_for( + || { + core.sessions + .session_status(SIDE) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the side chat's turn to start", + ) + .await; + // Archive the child while its turn runs; a Stop ends the turn. + core.workspace.set_chat_archived(SIDE, true).unwrap(); + core.doc_host + .queue_command(SIDE, SessionCommandPayload::Interrupt {}) + .unwrap(); + wait_idle(&core, SIDE).await; + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "an archived side chat must send no notices" + ); + assert_eq!(scripted.run_count(), 1, "no parent turn was started"); + + core.shutdown().await; +} + +/// Outcomes already owed to a parent when it is ARCHIVED are dropped, not +/// held: after a restart the boot delivery finds the parent archived and +/// the ledger forgets its rows — an archived parent receives nothing even +/// from the pending set (ticket 13's delivery-path rule, beside the +/// record-time one). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notices_owed_to_a_parent_archived_before_delivery_are_dropped_at_boot() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + + // Engine 1: the child completes while the parent works, so the outcome + // is recorded — the parent is owed it — and then the parent is + // archived before it could ever receive the notice. + let first = Scripted::new(vec![ + Turn::Hang, + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(&dir, first.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || first.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(&dir) == 1, + "the child's outcome to be recorded durably", + ) + .await; + core.workspace.set_chat_archived(PARENT, true).unwrap(); + core.shutdown().await; + drop(core); + + // Engine 2: the boot delivery finds the archived parent and drops the + // owed outcome — no notice turn ever starts, and the ledger is empty. + let second = Scripted::new(vec![Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let core = assemble(&dir, second.clone()); + wait_for( + || ledger_rows(&dir) == 0, + "the archived parent's owed outcomes to be dropped", + ) + .await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + notice_entries(&core, PARENT).is_empty(), + "an archived parent receives nothing, even outcomes it was already owed" + ); + assert_eq!( + second.run_count(), + 0, + "no notice turn was started for the archived parent" + ); + + core.shutdown().await; +} diff --git a/web/packages/proto/src/generated/NoticeOutcome.ts b/web/packages/proto/src/generated/NoticeOutcome.ts index 929c8825fa..b4ac005c41 100644 --- a/web/packages/proto/src/generated/NoticeOutcome.ts +++ b/web/packages/proto/src/generated/NoticeOutcome.ts @@ -4,4 +4,4 @@ * What the engine recorded when a notifiable side-chat turn came to rest * (orchestration v2). The outcome of one row of a completion notice. */ -export type NoticeOutcome = "completed" | "errored" | "stopped" | "waitingForInput"; +export type NoticeOutcome = "completed" | "errored" | "stopped" | "interrupted" | "waitingForInput"; diff --git a/web/packages/proto/src/generated/NoticeSide.ts b/web/packages/proto/src/generated/NoticeSide.ts index 2de3c92a6f..1d36dbc2ae 100644 --- a/web/packages/proto/src/generated/NoticeSide.ts +++ b/web/packages/proto/src/generated/NoticeSide.ts @@ -4,8 +4,8 @@ import type { NoticeOutcome } from "./NoticeOutcome"; /** * One side chat a completion notice reports: its id and title, the - * recorded outcome, and the one-line reason (error text or question - * summary) when the outcome carries one. + * recorded outcome, and the one-line reason (error text, restart reason or + * question summary) when the outcome carries one. */ export type NoticeSide = { /** @@ -20,7 +20,8 @@ chatId: string, title: string, outcome: NoticeOutcome, /** - * The one-line detail for errored (reason) and waiting-for-input - * (question summary) rows; absent otherwise. + * The one-line detail for errored (reason), interrupted (restart + * reason) and waiting-for-input (question summary) rows; absent + * otherwise. */ detail?: string | null, }; From 6a4c304178d8ec48744e665661d7aba0ca9a8904 Mon Sep 17 00:00:00 2001 From: hoangvu12 Date: Fri, 16 Oct 2026 18:20:50 +0700 Subject: [PATCH 24/96] feat(forks): fork a chat into a different harness and model Ticket 22 (t3-orchestration-v2): forking lets the user choose the harness and model the fork runs on, defaulting to the source's. - ForkSideChat RPC gains optional `harness`/`model` overrides (wire names, trimmed; unknown harness refused). A CROSS-harness fork rewrites the copy's run config - harness and model chosen, reasoning/model options reset (old-harness vocabulary), sandbox kept - and never records the native-fork intent, so its first send carries the budgeted history (ticket 01) instead of continuing the source's provider session. A pick of the source's own harness - or a model-only override - keeps native fork behaviour and replaces exactly the model. A source that hit a usage limit forks the same way; nothing in the path consults chat status. - Desktop: both entry points (Fork this chat, ticket 21's Fork from here) open a fork dialog instead of forking immediately. It defaults to the source's harness+model, offers the source device's agents (the source's own harness always stays pickable) with per-harness model lists, resets the model when another harness is picked and restores it when the source's comes back, and notes the budgeted-transcript handoff on a cross-harness pick. The picker is local to the fork flow; the composer's picker stays untouched for ticket 24. Engine tests: cross-harness row config + queue-path dispatch + budgeted history + no native fork + session resume + once-only history; same- harness and model-only overrides keep the native fork; a usage-limited source forks into another harness. UI tests: params carry only chosen overrides, offered list keeps the source harness, dialog defaults/picks/ restore/close/confirm, Escape. Not run locally (CI runs them). --- crates/engine/src/rpc.rs | 103 +++- crates/engine/tests/side_chats.rs | 547 ++++++++++++++++++ crates/rpc/src/lib.rs | 11 +- crates/ui/src/shell.rs | 22 +- crates/ui/src/shell/fork_dialog.rs | 896 +++++++++++++++++++++++++++++ crates/ui/src/shell/side_chats.rs | 38 +- docs/mcp.md | 20 +- 7 files changed, 1602 insertions(+), 35 deletions(-) create mode 100644 crates/ui/src/shell/fork_dialog.rs diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 21d4b67c75..12586c656b 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1530,6 +1530,20 @@ impl RpcService for EngineRpc { /// keeps today's behaviour: the latest complete reply. #[serde(default)] boundary: Option, + /// Harness override (t3-orchestration-v2 ticket 22, "fork + /// into another harness"): the copy runs on this harness + /// instead of the source's, its wire name ("codex", + /// "claude-code", …). A pick of the source's own harness + /// — or no pick at all — keeps the native fork behaviour. + #[serde(default)] + harness: Option, + /// Model override (ticket 22): rides the copy's run + /// config. Without a harness change it replaces only the + /// model; into another harness the old model ids are + /// meaningless, so it (or the new harness's default at + /// run time) is all the copy gets. + #[serde(default)] + model: Option, } let p: ForkParams = parse_params(params)?; let parent_chat_id = p @@ -1601,6 +1615,45 @@ impl RpcService for EngineRpc { // where a turn ref is stamped. let boundary = logical_message_end(&entries, chosen); let ref_entry = logical_message_root(&entries, chosen); + // Ticket 22's overrides: a chosen harness (and model). A + // trimmed empty string counts as absent — the web client and + // the MCP tool hand through whatever the caller typed. An + // unknown harness name is refused rather than silently + // ignored: the fork would run on the default harness while + // the user believes it runs on the one they picked. + let requested_harness = p + .harness + .as_deref() + .map(str::trim) + .filter(|harness| !harness.is_empty()); + let requested_model = p + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()); + let override_harness = match requested_harness { + None => None, + Some(name) => Some( + serde_json::from_value::( + serde_json::Value::String(name.to_owned()), + ) + .map_err(|_| RpcError::Failed(format!("Unknown harness: {name}")))?, + ), + }; + // The harness the source's conversation lives on: the + // boundary entry's provider turn ref names it (the + // native-fork contract), else the row's config. An override + // onto any other harness is a CROSS-harness fork: native + // fork only works within one harness, so the copy never + // records the intent and its first send carries the budgeted + // history instead (ticket 01's builder). + let source_harness = entries[ref_entry] + .provider_turn + .as_ref() + .map(|reference| reference.harness) + .or_else(|| source.config.as_ref().map(|config| config.harness)); + let cross_harness = + override_harness.is_some_and(|harness| source_harness != Some(harness)); let mut chat = source.clone(); chat.id = p.chat_id; chat.parent_chat_id = Some(parent_chat_id); @@ -1613,6 +1666,41 @@ impl RpcService for EngineRpc { chat.harness_session_id = None; chat.harness_session_cwd = None; chat.room_gen = Some(2); + // Ticket 22's override on the row's run config. A + // cross-harness fork keeps only harness-agnostic settings: + // the sandbox. Model, reasoning and model options are ids in + // the OLD harness's vocabulary — the override's model (or + // the new harness's own default at run time) is all the copy + // gets, mirroring the harness-switch rule. A same-harness + // pick (or a model-only override) replaces exactly the + // model and inherits the rest, like the composer's own + // model change. A chat that never carried a config is + // pinned to the harness it actually runs on (the engine + // default) so a configless source stays honest. + if override_harness.is_some() || requested_model.is_some() { + let base = chat.config.clone().unwrap_or(roboco_proto::ChatConfig { + harness: self.doc_host.harness_for(&p.source_chat_id), + model: None, + reasoning: None, + model_options: Default::default(), + sandbox: roboco_proto::SandboxLevel::WorkspaceWrite, + }); + chat.config = Some(roboco_proto::ChatConfig { + harness: override_harness.unwrap_or(base.harness), + model: if cross_harness { + requested_model.map(str::to_owned) + } else { + requested_model.map(str::to_owned).or(base.model) + }, + reasoning: if cross_harness { None } else { base.reasoning }, + model_options: if cross_harness { + Default::default() + } else { + base.model_options + }, + sandbox: base.sandbox, + }); + } // Missing rows open on chat2. Persist history before publishing // the registry row so a crash cannot leave a discoverable empty fork. let target = self.doc_host.open(&chat.id).map_err(failed)?; @@ -1677,11 +1765,16 @@ impl RpcService for EngineRpc { // The boundary is whatever reply the caller chose — the // latest by default, an earlier/errored/interrupted one // explicitly — so every harness's driver (17–20) forks at - // exactly that turn. No ref — old chats, or a harness that - // records none — keeps today's behavior: a fresh session - // plus the budgeted paste, cut at the boundary because the - // fork's own doc only holds the copied prefix. - if let Some(reference) = entries[ref_entry].provider_turn.clone() { + // exactly that turn. A CROSS-harness fork (ticket 22) never + // records the intent: native fork only works within one + // harness, so the copy takes the budgeted-paste path. + // No ref — old chats, or a harness that records none — + // keeps today's behavior: a fresh session plus the budgeted + // paste, cut at the boundary because the fork's own doc only + // holds the copied prefix. + if !cross_harness + && let Some(reference) = entries[ref_entry].provider_turn.clone() + { target .doc() .set_provider_fork( diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 408be18e4c..8326f9ddde 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -2015,3 +2015,550 @@ async fn an_oversized_fork_message_is_truncated_not_dropped() { core.shutdown().await; } + +// ── Fork into another harness and model (t3-orchestration-v2 ticket 22) ── + +/// A capture harness pinned to a chosen harness id and provider session: +/// the fork's target harness in ticket 22's tests. Like [`Capture`] it never +/// supports title runs, so the auto-titler's throwaway attempts never touch +/// the recorded requests. +struct CaptureOn { + id: HarnessId, + session: &'static str, + requests: Arc>>, +} + +#[async_trait] +impl Harness for CaptureOn { + fn id(&self) -> HarnessId { + self.id + } + fn display_name(&self) -> &str { + "CaptureOn" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result>, HarnessError> { + self.requests.lock().unwrap().push(request); + let session = self.session; + Ok(futures::stream::iter(vec![ + Ok(AgentEvent::TextDelta { + text: "Side answer".into(), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session.into()), + }), + ]) + .boxed()) + } +} + +/// A harness whose turn hits a plan usage limit (ticket 05's structured +/// error kind): it streams a little, reports the limit, and ends errored — +/// the state a Limited chat sits in until ticket 06 gives it its own +/// status. The boundary ref rides the entry like any turn. +struct LimitHarness { + requests: Arc>>, +} + +#[async_trait] +impl Harness for LimitHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Limit" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result>, HarnessError> { + self.requests.lock().unwrap().push(request); + Ok(futures::stream::iter(vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "parent-provider-session".into(), + assistant_message_id: uuid::Uuid::new_v4().to_string(), + }), + Ok(AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Mock, + session_id: "parent-provider-session".into(), + turn_id: "turn-9".into(), + }, + }), + Ok(AgentEvent::TextDelta { + text: "half an answer".into(), + }), + Ok(AgentEvent::Error { + message: "Claude 5-hour limit reached — the turn was blocked.".into(), + kind: Some(roboco_proto::AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(4102444800000), + }), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("Claude 5-hour limit reached — the turn was blocked.".into()), + session_id: Some("parent-provider-session".into()), + }), + ]) + .boxed()) + } +} + +/// Queue a message and let the doc host deliver it — the path a composer +/// send takes, so the run's harness, model, reasoning and options come from +/// the chat ROW (the fork's recorded config), not a hand-built request. +/// Waits for the turn to settle and returns the delivered request. +async fn queued_side_turn( + core: &EngineCore, + requests: &Arc>>, + chat: &str, + prompt: &str, +) -> RunRequest { + let before = requests.lock().unwrap().len(); + core.doc_host.queue_message(chat, prompt, Vec::new()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let delivered = requests.lock().unwrap().len() > before; + let settled = core + .sessions + .session_status(chat) + .is_none_or(|status| status.status != roboco_proto::SessionStatus::Working); + if delivered && settled { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + requests.lock().unwrap()[before].clone() +} + +/// A fork with a harness and model override (ticket 22, spec story 61) runs +/// on that harness and model: the copy's row carries them, and its first +/// send goes to the TARGET harness with the budgeted history in front — +/// never a native fork of the source's provider session, and never a resume +/// of it. The turn after that resumes the target harness's own session with +/// nothing pasted again. +#[tokio::test] +async fn a_fork_with_a_harness_and_model_override_runs_on_that_harness() { + let dir = tempfile::tempdir().unwrap(); + let mock_requests = Arc::new(Mutex::new(Vec::new())); + let codex_requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(mock_requests.clone()))); + registry.register(Arc::new(CaptureOn { + id: HarnessId::Codex, + session: "codex-provider-session", + requests: codex_requests.clone(), + })); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + // The source runs on Mock with a pinned model, reasoning level and model + // option of its own — a cross-harness fork must inherit none of them + // (they are ids in the old harness's vocabulary), while the sandbox is + // harness-agnostic and rides along. + core.workspace + .set_chat_config( + "main", + &roboco_proto::ChatConfig { + harness: HarnessId::Mock, + model: Some("mock-9".into()), + reasoning: Some(ReasoningLevel::Max), + model_options: serde_json::Map::from([( + "contextWindow".to_string(), + serde_json::json!("1m"), + )]), + sandbox: SandboxLevel::ReadOnly, + }, + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + let fork = client + .call_as::( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side", + "sourceChatId": "main", + "harness": "codex", + "model": "gpt-6-codex", + }), + ) + .await + .unwrap(); + let config = fork.config.expect("the override writes the run config"); + assert_eq!(config.harness, HarnessId::Codex); + assert_eq!(config.model.as_deref(), Some("gpt-6-codex")); + assert_eq!(config.reasoning, None, "the old reasoning never crosses"); + assert!( + config.model_options.is_empty(), + "the old model options never cross" + ); + assert_eq!(config.sandbox, SandboxLevel::ReadOnly); + assert_eq!( + core.workspace.chat("side").unwrap().unwrap().config, + Some(config), + "the imported row carries the override" + ); + + // The first send — through the queue, the way a composer send goes, so + // the row's config (not a hand-built request) routes the harness. + let request = queued_side_turn(&core, &codex_requests, "side", "What did I ask?").await; + assert_eq!( + request.provider_fork, None, + "a cross-harness fork never forks the source provider session" + ); + assert_eq!(request.resume, None, "the target harness starts fresh"); + assert_eq!(request.model.as_deref(), Some("gpt-6-codex")); + assert_eq!(request.reasoning, None); + assert!( + request.model_options.is_empty(), + "the old model options never ride the run" + ); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!( + request.prompt.ends_with("What did I ask?"), + "{}", + request.prompt + ); + assert!( + mock_requests.lock().unwrap().is_empty(), + "the fork's sends never touch the source's harness" + ); + + // The next turn resumes the TARGET harness's own session, and the + // budgeted history is never delivered twice. + let request = queued_side_turn(&core, &codex_requests, "side", "And now?").await; + assert_eq!(request.resume.as_deref(), Some("codex-provider-session")); + assert_eq!(request.prompt, "And now?", "{}", request.prompt); + core.shutdown().await; +} + +/// A fork whose override names the SAME harness — or overrides only the +/// model — keeps the native fork behaviour (ticket 22): the boundary +/// reply's provider turn ref drives the first run, nothing is pasted, and +/// the model override rides the row's config out with the run. +#[tokio::test] +async fn a_same_harness_fork_keeps_the_native_fork_behaviour() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + core.workspace + .set_chat_config( + "main", + &roboco_proto::ChatConfig { + harness: HarnessId::Mock, + model: Some("mock-9".into()), + reasoning: Some(ReasoningLevel::Medium), + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + }, + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + + // An explicit override naming the source's own harness. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side", + "sourceChatId": "main", + "harness": "mock", + "model": "mock-2", + }), + ) + .await + .unwrap(); + let config = core.workspace.chat("side").unwrap().unwrap().config.unwrap(); + assert_eq!(config.harness, HarnessId::Mock); + assert_eq!(config.model.as_deref(), Some("mock-2")); + // A same-harness pick inherits the rest of the source's config: only + // the model was overridden. + assert_eq!(config.reasoning, Some(ReasoningLevel::Medium)); + let request = queued_side_turn(&core, &requests, "side", "What did I ask?").await; + let fork = request + .provider_fork + .expect("a same-harness fork keeps the native fork"); + assert_eq!(fork.session_id, "parent-provider-session"); + assert_eq!(fork.last_turn, "turn-1"); + assert_eq!(request.resume, None); + assert_eq!(request.prompt, "What did I ask?", "{}", request.prompt); + assert_eq!(request.model.as_deref(), Some("mock-2")); + + // A model-only override, no harness named: native fork again. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side-model-only", + "sourceChatId": "main", + "model": "mock-3", + }), + ) + .await + .unwrap(); + let config = core + .workspace + .chat("side-model-only") + .unwrap() + .unwrap() + .config + .unwrap(); + assert_eq!(config.harness, HarnessId::Mock); + assert_eq!(config.model.as_deref(), Some("mock-3")); + let request = queued_side_turn(&core, &requests, "side-model-only", "And now?").await; + let fork = request + .provider_fork + .expect("a model-only override never disables the native fork"); + assert_eq!(fork.last_turn, "turn-1"); + assert_eq!(request.prompt, "And now?", "{}", request.prompt); + core.shutdown().await; +} + +/// A source chat whose last turn hit a usage limit can still be forked into +/// another harness (spec story 64): the errored reply is the boundary, the +/// copy exists, and its first send carries the budgeted history on the new +/// harness — no native fork, even though the boundary reply carries the +/// provider turn ref a same-harness fork would have used. +#[tokio::test] +async fn a_limited_source_chat_can_be_forked_into_another_harness() { + let dir = tempfile::tempdir().unwrap(); + let limited_requests = Arc::new(Mutex::new(Vec::new())); + let codex_requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(LimitHarness { + requests: limited_requests.clone(), + })); + registry.register(Arc::new(CaptureOn { + id: HarnessId::Codex, + session: "codex-provider-session", + requests: codex_requests.clone(), + })); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + // The next turn hits the plan limit for real: an Error event with the + // structured usage-limit kind, an errored Done. The chat sits errored — + // the state a Limited chat occupies before ticket 06's own status. + core.sessions + .dispatch( + "main", + HarnessId::Mock, + RunRequest { + mcp: None, + prompt: "keep going".into(), + harness: Some(HarnessId::Mock), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + resume: Some("parent-provider-session".into()), + provider_fork: None, + attachments: vec![], + worktree: None, + }, + Some("u2".into()), + None, + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while core + .sessions + .session_status("main") + .is_none_or(|status| status.status != roboco_proto::SessionStatus::Errored) + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let entries = source.doc().read_entries().unwrap(); + let errored = entries + .iter() + .find(|entry| entry.id != "a1" && entry.role == MessageRole::Assistant) + .expect("the limited turn left an errored reply"); + assert!( + errored + .parts + .iter() + .any(|part| matches!(part, MessagePart::Error { .. })), + "the reply carries the limit error: {:?}", + errored.parts + ); + assert_eq!( + errored.provider_turn.as_ref().map(|r| r.turn_id.as_str()), + Some("turn-9") + ); + + // Fork the limited chat into another harness — the default boundary is + // the latest complete reply, which is the errored one. + let client = roboco_rpc::memory_client(core.rpc_service()); + let fork = client + .call_as::( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side", + "sourceChatId": "main", + "harness": "codex", + "model": "gpt-6-codex", + }), + ) + .await + .unwrap(); + assert_eq!( + fork.config.as_ref().map(|config| config.harness), + Some(HarnessId::Codex), + "the fork exists despite the source's errored state" + ); + let copied = core + .doc_host + .open("side") + .unwrap() + .doc() + .read_entries() + .unwrap(); + assert!(copied.iter().any(|entry| entry.id == errored.id)); + + // Its first send: budgeted history on the new harness, no native fork + // even though the boundary reply carries a ref, and no resume. + let request = queued_side_turn(&core, &codex_requests, "side", "What did I ask?").await; + assert_eq!(request.provider_fork, None); + assert_eq!(request.resume, None); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!( + request.prompt.ends_with("What did I ask?"), + "{}", + request.prompt + ); + core.shutdown().await; +} diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 752398c04e..93cf20a179 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -71,10 +71,13 @@ pub mod methods { pub const RETRY_DELIVERY: &str = "RetryDelivery"; /// Fork a chat into a side chat: copies the source's completed history, /// appends the `fork` seam, and lists the copy under its parent. - /// Params `{chatId, sourceChatId, parentChatId?, boundary?}` → `Chat`; - /// `boundary` (t3-orchestration-v2 ticket 21) names the assistant entry - /// to fork at — any settled reply, not just the latest complete one — - /// and the copy stops there, inclusive. + /// Params `{chatId, sourceChatId, parentChatId?, boundary?, harness?, + /// model?}` → `Chat`; `boundary` (t3-orchestration-v2 ticket 21) names + /// the assistant entry to fork at — any settled reply, not just the + /// latest complete one — and the copy stops there, inclusive; + /// `harness`/`model` (ticket 22) override what the copy runs on — a + /// different harness takes the budgeted-transcript path instead of a + /// native session fork, the same harness keeps it. pub const FORK_SIDE_CHAT: &str = "ForkSideChat"; pub const WATCH_DOC_MESSAGES: &str = "WatchDocMessages"; /// Messages typed while the agent was busy, held on the chat doc so every diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 2a6ed28911..a1137fac04 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -65,6 +65,7 @@ mod chat_rename_tests; mod command_palette; mod file_mutations; mod files_panel; +mod fork_dialog; mod harness_updates; mod navigation_focus; #[cfg(test)] @@ -1707,6 +1708,10 @@ pub struct Shell { section_menu_active: Option, /// Space id awaiting delete confirmation (hard delete + session cascade). delete_space_confirm: Option, + /// The fork dialog (t3-orchestration-v2 ticket 22): the harness and + /// model a pending fork runs on. `Some` while open; the confirm hands + /// the fork to `fork_chat` with the chosen overrides. + fork_dialog: Option, /// The add-space palette (device tabs + folder search), `Some` while open. add_space: Option, /// The New project palette's collapsed-breadcrumbs (`…`) menu. @@ -2114,6 +2119,7 @@ impl Shell { section_menu_focus: cx.focus_handle(), section_menu_active: None, delete_space_confirm: None, + fork_dialog: None, add_space: None, command_palette: None, pending_workspace_command: None, @@ -3682,7 +3688,10 @@ impl Shell { // The reply may live in the selected chat or in an open side // chat's transcript. The fork hangs under the source — a // side chat's own fork lands as a sibling, like its fork - // button — and copies only up to the chosen reply. + // button — and copies only up to the chosen reply. Ticket + // 22: the fork dialog opens first, so the user picks the + // harness and model the fork runs on (defaulting to the + // source's) before anything is sent. let Some(source) = self .state .read(cx) @@ -3697,7 +3706,7 @@ impl Shell { .parent_chat_id .clone() .unwrap_or_else(|| source.id.clone()); - self.fork_chat(source, parent, Some(entry_id.clone()), cx); + self.open_fork_dialog(source, parent, Some(entry_id.clone()), cx); } } } @@ -5242,6 +5251,7 @@ impl Shell { || self.section_dialog.is_some() || self.section_menu.is_some() || self.add_space.is_some() + || self.fork_dialog.is_some() || self.composer.read(cx).pickers().read(cx).is_open() || self.open_side_chat_pickers(cx).is_some() } @@ -8240,6 +8250,11 @@ impl Shell { self.close_project_crumb_menu(cx); return true; } + if self.fork_dialog.is_some() { + // The fork dialog is a modal over everything else open here. + self.close_fork_dialog(cx); + return true; + } if self.add_space.is_some() { self.add_space = None; cx.notify(); @@ -8641,6 +8656,9 @@ impl Shell { if let Some(overlay) = self.render_project_action_overlay(viewport, window, cx) { overlays.push(overlay); } + if let Some(overlay) = self.render_fork_dialog(viewport, cx) { + overlays.push(overlay); + } if let Some(chat_id) = self.delete_confirm.clone() { let title = transcript::single_line( diff --git a/crates/ui/src/shell/fork_dialog.rs b/crates/ui/src/shell/fork_dialog.rs new file mode 100644 index 0000000000..95bcc2d5ca --- /dev/null +++ b/crates/ui/src/shell/fork_dialog.rs @@ -0,0 +1,896 @@ +//! The fork dialog (t3-orchestration-v2 ticket 22): the harness and model a +//! fork runs on, chosen when the fork starts. +//! +//! Both desktop fork entry points — the classic "Fork this chat" button and +//! ticket 21's "Fork from here" — open this dialog instead of forking +//! immediately. It defaults to the source's own harness and model (spec +//! story 61), so confirming without touching anything forks exactly as +//! before; picking another harness makes the fork a cross-harness one, which +//! the engine serves with the budgeted history instead of a native session +//! fork. The dialog is deliberately local to the fork flow: the composer's +//! own harness/model picker (and its unlock in ticket 24's one-way harness +//! switch) stays untouched, so the two features never share state. + +use super::*; + +use roboco_engine::registry::HarnessDescriptor; +use roboco_proto::{HarnessId, Model}; + +/// A pending fork: what is being forked, from where, and onto which harness +/// and model. Held by [`Shell::fork_dialog`] while the dialog is open. +pub(super) struct ForkDialog { + /// The chat being forked — the row the FORK_SIDE_CHAT RPC copies. + source: roboco_proto::Chat, + /// Where the copy hangs in the tree: the source, or the source's parent + /// when a side chat forks itself (the copy lists as a sibling). + parent_id: String, + /// Ticket 21's boundary entry id: the reply the copy stops at, + /// inclusive. `None` is the classic fork (the latest complete reply). + boundary: Option, + /// The harness the fork will run on. `None` until a default resolves — + /// the source's own config first, the first offered harness when the + /// source carries no config. `None` at confirm time means "no override": + /// the fork keeps the source's harness and its native fork behaviour. + harness: Option, + /// The model the fork will run on. `None` = the harness's default at run + /// time. The source's model only applies while the source's harness is + /// the pick; another harness resets it. + model: Option, + /// The source device's harness catalog, loaded once per dialog. + harnesses: Loadable>, + /// Model lists per harness, loaded once per dialog per harness. + models: std::collections::HashMap>>, + /// Stale-response guard: bumped when the dialog closes, so an in-flight + /// catalog load lands on a closed (or reopened) dialog and no-ops. + generation: u64, + /// The model list's scroll. + model_scroll: gpui::ScrollHandle, +} + +/// The ForkSideChat params for a fork (t3-orchestration-v2 tickets 21+22): +/// the idempotent chat id, the source, where the copy hangs, and the chosen +/// boundary / harness / model. Overrides ride only when chosen — an engine +/// that predates them keeps its classic fork behaviour, and a dialog that +/// confirmed the defaults sends no override at all. +pub(super) fn fork_params( + chat_id: &str, + source: &roboco_proto::Chat, + parent_id: &str, + boundary: Option<&str>, + harness: Option, + model: Option<&str>, +) -> serde_json::Value { + let mut params = serde_json::json!({ + "chatId": chat_id, + "sourceChatId": source.id, + "parentChatId": parent_id, + "targetDeviceId": source.device_id, + }); + // Only send what was chosen: absent keys are the old payload verbatim. + if let Some(boundary) = boundary.map(str::trim).filter(|id| !id.is_empty()) { + params["boundary"] = serde_json::Value::String(boundary.to_owned()); + } + if let Some(harness) = harness { + // The kebab-case wire name ("codex", "claude-code"). + if let Ok(value) = serde_json::to_value(harness) { + params["harness"] = value; + } + } + if let Some(model) = model.map(str::trim).filter(|id| !id.is_empty()) { + params["model"] = serde_json::Value::String(model.to_owned()); + } + params +} + +/// The harnesses the fork dialog offers: the same offered set the composer's +/// picker uses (installed and enabled on the listing device), plus the +/// harness `fallback` names (the source's own, or the current pick) whenever +/// the catalog carries it — a chat can outlive its harness's enablement, and +/// the default must stay pickable so "fork as-is" is always one click. +pub(super) fn fork_offered( + catalog: &[HarnessDescriptor], + fallback: Option, +) -> Vec { + let mut offered = crate::pickers::offered_harnesses(catalog); + if let Some(harness) = fallback + && !offered.iter().any(|descriptor| descriptor.id == harness) + && let Some(descriptor) = catalog.iter().find(|descriptor| descriptor.id == harness) + { + offered.push(descriptor.clone()); + } + offered +} + +impl Shell { + /// Open the fork dialog for `source`, hanging the copy under + /// `parent_id`, from `boundary` (ticket 21's "Fork from here") or the + /// latest reply. The selection starts as the source's own harness and + /// model; confirming performs the fork. + pub(super) fn open_fork_dialog( + &mut self, + source: roboco_proto::Chat, + parent_id: String, + boundary: Option, + cx: &mut Context, + ) { + if self.side_chat_creating { + return; + } + let harness = source.config.as_ref().map(|config| config.harness); + let model = source.config.as_ref().and_then(|config| config.model.clone()); + self.fork_dialog = Some(ForkDialog { + source, + parent_id, + boundary, + harness, + model, + harnesses: Loadable::Idle, + models: std::collections::HashMap::new(), + generation: 0, + model_scroll: gpui::ScrollHandle::new(), + }); + self.ensure_fork_loads(cx); + cx.notify(); + } + + /// Close the dialog without forking. The generation bump retires + /// in-flight catalog loads. + pub(super) fn close_fork_dialog(&mut self, cx: &mut Context) { + if let Some(dialog) = self.fork_dialog.as_mut() { + dialog.generation += 1; + } + self.fork_dialog = None; + cx.notify(); + } + + /// Pick the fork's harness. A pick of another harness resets the model + /// (the source's model id is meaningless there; the new harness's own + /// default applies at run time); a pick back onto the source's harness + /// restores it. + pub(super) fn pick_fork_harness(&mut self, harness: HarnessId, cx: &mut Context) { + { + let Some(dialog) = self.fork_dialog.as_mut() else { + return; + }; + if dialog.harness == Some(harness) { + return; + } + let source_harness = dialog + .source + .config + .as_ref() + .map(|config| config.harness); + let source_model = dialog + .source + .config + .as_ref() + .and_then(|config| config.model.clone()); + dialog.harness = Some(harness); + dialog.model = (source_harness == Some(harness)) + .then_some(source_model) + .flatten(); + } + self.ensure_fork_models(harness, cx); + cx.notify(); + } + + /// Pick the fork's model on the selected harness. + pub(super) fn pick_fork_model(&mut self, model: String, cx: &mut Context) { + if let Some(dialog) = self.fork_dialog.as_mut() { + dialog.model = Some(model); + } + cx.notify(); + } + + /// Confirm: hand the dialog's fork — source, parent, boundary and the + /// chosen harness/model — to the fork RPC flow. + pub(super) fn confirm_fork(&mut self, cx: &mut Context) { + let Some(dialog) = self.fork_dialog.take() else { + return; + }; + let ForkDialog { + source, + parent_id, + boundary, + harness, + model, + .. + } = dialog; + self.fork_chat(source, parent_id, boundary, harness, model, cx); + } + + /// Kick every load the dialog's current selection needs: the harness + /// catalog when still idle, and the selected harness's model list. + fn ensure_fork_loads(&mut self, cx: &mut Context) { + self.ensure_fork_harnesses(cx); + let harness = self.fork_dialog.as_ref().and_then(|dialog| dialog.harness); + if let Some(harness) = harness { + self.ensure_fork_models(harness, cx); + } + } + + /// The catalog load's `targetDeviceId`: the fork runs on the SOURCE's + /// device (the RPC refuses a source hosted elsewhere), so its installed + /// agents are the ones that matter. `None` for a locally-hosted source — + /// the connected engine serves it directly. + fn fork_catalog_target(&self, cx: &App) -> Option { + let local = self.state.read(cx).local_device_id.as_deref()?; + let dialog = self.fork_dialog.as_ref()?; + (local != dialog.source.device_id.as_str()).then(|| dialog.source.device_id.clone()) + } + + /// Load the harness catalog once per dialog: the list is short-lived, so + /// no refresh-on-open and no error retry — reopening the dialog retries. + fn ensure_fork_harnesses(&mut self, cx: &mut Context) { + let idle = self + .fork_dialog + .as_ref() + .is_some_and(|dialog| matches!(dialog.harnesses, Loadable::Idle)); + if !idle { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + let target = self.fork_catalog_target(cx); + let generation = self + .fork_dialog + .as_ref() + .map(|dialog| dialog.generation) + .unwrap_or_default(); + if let Some(dialog) = self.fork_dialog.as_mut() { + dialog.harnesses = Loadable::Loading; + } + cx.spawn(async move |this, cx| { + let mut params = serde_json::Map::new(); + if let Some(target) = &target { + params.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.clone()), + ); + } + let result = engine + .call( + methods::LIST_HARNESSES, + serde_json::Value::Object(params), + ) + .await; + this.update(cx, |shell, cx| { + let Some(dialog) = shell.fork_dialog.as_mut() else { + return; + }; + if dialog.generation != generation { + return; + } + dialog.harnesses = match result { + Ok(value) => match serde_json::from_value::>(value) { + Ok(list) => Loadable::Ready(list), + Err(error) => Loadable::Error(error.to_string()), + }, + Err(error) => Loadable::Error(error.to_string()), + }; + // A source with no config keeps no default of its own: the + // first offered harness becomes the pick once the catalog + // lands, mirroring the composer's picker. + let first = dialog + .fork_offered() + .first() + .map(|descriptor| descriptor.id); + if dialog.harness.is_none() + && let Some(harness) = first + { + dialog.harness = Some(harness); + } + cx.notify(); + }) + .ok(); + // The default may have just resolved; its model list is next. + this.update(cx, |shell, cx| shell.ensure_fork_loads(cx)).ok(); + }) + .detach(); + } + + /// Load one harness's model list, once per dialog per harness. + fn ensure_fork_models(&mut self, harness: HarnessId, cx: &mut Context) { + let needs_load = self + .fork_dialog + .as_ref() + .and_then(|dialog| dialog.models.get(&harness)) + .is_none_or(|slot| matches!(slot, Loadable::Idle)); + if !needs_load { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + let target = self.fork_catalog_target(cx); + let generation = self + .fork_dialog + .as_ref() + .map(|dialog| dialog.generation) + .unwrap_or_default(); + if let Some(dialog) = self.fork_dialog.as_mut() { + dialog.models.insert(harness, Loadable::Loading); + } + cx.spawn(async move |this, cx| { + let mut params = serde_json::json!({ "harness": harness }); + if let (Some(target), Some(object)) = (&target, params.as_object_mut()) { + object.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.clone()), + ); + } + let result = engine.call(methods::LIST_MODELS, params).await; + this.update(cx, |shell, cx| { + let Some(dialog) = shell.fork_dialog.as_mut() else { + return; + }; + if dialog.generation != generation { + return; + } + let loaded = match result { + Ok(value) => match serde_json::from_value::>(value) { + Ok(models) => Loadable::Ready(models), + Err(error) => Loadable::Error(error.to_string()), + }, + Err(error) => Loadable::Error(error.to_string()), + }; + dialog.models.insert(harness, loaded); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The fork dialog overlay: a modal card with the harness chips, the + /// selected harness's models, and the fork/cancel pair. + pub(super) fn render_fork_dialog( + &mut self, + viewport: gpui::Size, + cx: &mut Context, + ) -> Option { + let dialog = self.fork_dialog.as_ref()?; + let theme = Theme::of(cx).for_popup(); + let source_harness = dialog.source.config.as_ref().map(|config| config.harness); + let catalog: Vec = dialog.harnesses.ready().cloned().unwrap_or_default(); + let offered = fork_offered(&catalog, source_harness.or(dialog.harness)); + let harness_loading = matches!(dialog.harnesses, Loadable::Loading); + let harness_error = dialog.harnesses.error().map(str::to_owned); + let selected_harness = dialog.harness; + let selected_model = dialog.model.clone(); + let models: Vec = selected_harness + .and_then(|harness| dialog.models.get(&harness)) + .and_then(|slot| slot.ready().cloned()) + .unwrap_or_default(); + let models_loading = selected_harness.is_some_and(|harness| { + matches!(dialog.models.get(&harness), Some(Loadable::Loading)) + }); + let models_error = selected_harness + .and_then(|harness| match dialog.models.get(&harness) { + Some(Loadable::Error(error)) => Some(error.clone()), + _ => None, + }); + let boundary_set = dialog.boundary.is_some(); + let cross_harness = selected_harness.is_some_and(|harness| source_harness != Some(harness)); + let scroll = dialog.model_scroll.clone(); + + let title = if boundary_set { + "Fork from here" + } else { + "Fork this chat" + }; + let body = if boundary_set { + "A new side chat continues from this reply. Files are not rewound." + } else { + "A new side chat continues from the latest reply. Files are not rewound." + }; + let mut card = popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, title)) + .child(div().mt(px(6.0)).child(popover::dialog_body(&theme, body))); + + // ── Agent: one chip per offered harness; the pick rides the border + // and fill, like the composer's rail tabs. + let label_style = |el: gpui::Div| { + el.mt(px(14.0)) + .mb(px(6.0)) + .text_size(crate::typography::ui_rems(11.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text_muted) + }; + card = card.child(label_style(div()).child(SharedString::from("AGENT"))); + let mut chips = div().flex().flex_row().flex_wrap().gap(px(6.0)); + for (ix, descriptor) in offered.iter().enumerate() { + let harness = descriptor.id; + let selected = selected_harness == Some(harness); + let label: SharedString = SharedString::from(descriptor.name.clone()); + let (icon_path, tint) = crate::pickers::harness_brand_icon(harness); + let icon_color = tint.unwrap_or(if selected { + theme.text + } else { + theme.text_muted + }); + chips = chips.child( + div() + .id(("fork-harness-chip", ix)) + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .px(px(10.0)) + .py(px(5.0)) + .rounded(px(8.0)) + .border_1() + .border_color(if selected { + theme.border_strong + } else { + theme.border + }) + .when(selected, |el| el.bg(crate::theme::card_selected_bg())) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.pick_fork_harness(harness, cx); + })) + .child(icon(icon_path).size(px(13.0)).text_color(icon_color)) + .child( + div() + .text_size(crate::typography::ui_rems(12.5)) + .text_color(if selected { + theme.text + } else { + theme.text_muted + }) + .child(label), + ), + ); + } + if offered.is_empty() { + let text = if let Some(error) = harness_error { + SharedString::from(format!("Couldn't load the agents: {error}")) + } else if harness_loading { + SharedString::from("Loading this device's agents…") + } else { + SharedString::from("No agents are available on this device.") + }; + chips = chips.child( + div() + .px(px(10.0)) + .py(px(5.0)) + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .text_size(crate::typography::ui_rems(12.5)) + .text_color(theme.text_muted) + .child(text), + ); + } + card = card.child(chips); + + // ── Model: the selected harness's list; the highlighted row is the + // pick, or the harness's default row (first) when nothing is + // picked — a `None` model means "the default at run time". + card = card.child(label_style(div()).child(SharedString::from("MODEL"))); + let mut list = div() + .id("fork-model-list") + .max_h(px(216.0)) + .overflow_y_scroll() + .track_scroll(&scroll) + .flex() + .flex_col() + .gap(px(2.0)); + if models.is_empty() { + let text = if let Some(error) = models_error { + SharedString::from(format!("Couldn't load the models: {error}")) + } else if models_loading { + SharedString::from("Loading models…") + } else { + SharedString::from("This agent lists no models.") + }; + list = list.child( + div() + .py(px(6.0)) + .text_size(crate::typography::ui_rems(12.5)) + .text_color(theme.text_muted) + .child(text), + ); + } + for (ix, model) in models.iter().enumerate() { + let model_id = model.id.clone(); + let selected = + selected_model.as_deref() == Some(model.id.as_str()) || (selected_model.is_none() && ix == 0); + let default_row = selected_model.is_none() && ix == 0; + let label: SharedString = SharedString::from(model.label.clone()); + let subline: Option = model + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(SharedString::from); + let mut row = div() + .id(("fork-model-row", ix)) + .px(px(8.0)) + .py(px(6.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .flex_col() + .gap(px(2.0)) + .cursor_pointer(); + if selected { + row = row + .bg(crate::theme::card_selected_bg()) + .shadow(crate::theme::card_selected_shadows()); + } + row = row + .on_click(cx.listener(move |this, _, _, cx| { + this.pick_fork_model(model_id.clone(), cx); + })) + .child( + div() + .w_full() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_size(crate::typography::ui_rems(12.5)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .child(label), + ) + .when(default_row, |el| { + el.child( + div() + .flex_none() + .text_size(crate::typography::ui_rems(10.0)) + .text_color(theme.text_muted) + .child(SharedString::from("Default")), + ) + }), + ) + .when_some(subline, |el, subline| { + el.child( + div() + .w_full() + .truncate() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(subline), + ) + }); + list = list.child(row); + } + card = card.child(list); + + // ── Cross-harness note: the one behaviour difference the pick made. + if cross_harness { + card = card.child( + div() + .mt(px(12.0)) + .text_size(crate::typography::ui_rems(12.0)) + .line_height(px(17.0)) + .text_color(theme.text_muted) + .child(SharedString::from( + "A different agent starts a fresh session and receives a \ + size-limited transcript of this conversation, not the full history.", + )), + ); + } + + card = card.child( + div() + .mt(px(16.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "fork-dialog-cancel") + .id("fork-dialog-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.close_fork_dialog(cx); + })), + ) + .child( + popover::btn_primary(&theme, "Fork") + .id("fork-dialog-confirm") + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_fork(cx); + })), + ), + ); + Some(popover::modal("fork-dialog", viewport, card.into_any_element())) + } +} + +impl ForkDialog { + /// The harnesses this dialog offers — `fork_offered` over its catalog + /// with the source's harness as the guaranteed member. + fn fork_offered(&self) -> Vec { + let source_harness = self.source.config.as_ref().map(|config| config.harness); + let catalog = self + .harnesses + .ready() + .map(|list| list.as_slice()) + .unwrap_or_default(); + fork_offered(catalog, source_harness.or(self.harness)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + fn chat_row(id: &str, config: Option) -> roboco_proto::Chat { + let mut json = serde_json::json!({ + "id": id, + "deviceId": "local", + "cwd": "/tmp/main", + "archived": false, + "createdAt": Utc::now(), + }); + if let (Some(config), Some(object)) = (config, json.as_object_mut()) { + object.insert("config".into(), config); + } + serde_json::from_value(json).unwrap() + } + + fn shell_window(dir: &std::path::Path, cx: &mut TestAppContext) -> gpui::WindowHandle { + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir, cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.into(), + ipc_port: 0, + default_harness: roboco_proto::HarnessId::Mock, + }, + cx, + ) + }) + } + + /// The wire payload: the four identity keys always ride; the boundary, + /// harness and model ride only when chosen, so a pre-ticket-22 engine + /// still reads its classic fork and a defaults-confirm is byte-for-byte + /// the old request. + #[test] + fn fork_params_carries_only_the_chosen_overrides() { + let source = chat_row( + "src", + Some(serde_json::json!({"harness": "codex", "sandbox": "workspace-write"})), + ); + let plain = fork_params("new", &source, "src", None, None, None); + assert_eq!( + plain, + serde_json::json!({ + "chatId": "new", + "sourceChatId": "src", + "parentChatId": "src", + "targetDeviceId": "local", + }), + "a defaults-confirm sends the classic payload" + ); + let overridden = fork_params( + "new", + &source, + "src", + Some(" a1 "), + Some(HarnessId::Pi), + Some(" glm-5.3 "), + ); + assert_eq!(overridden["boundary"], "a1"); + assert_eq!(overridden["harness"], "pi"); + assert_eq!(overridden["model"], "glm-5.3"); + // Blank strings are absent picks, not empty overrides. + let blank = fork_params("new", &source, "src", Some(" "), None, Some("")); + assert!(blank.get("boundary").is_none()); + assert!(blank.get("harness").is_none()); + assert!(blank.get("model").is_none()); + } + + /// The offered list: the composer's offered set, with the source's own + /// harness appended when the catalog carries it but it is not offered — + /// the default must stay pickable. + #[test] + fn fork_offered_keeps_the_source_harness_pickable() { + let descriptor = |id: HarnessId, name: &str, installed: bool| HarnessDescriptor { + id, + name: name.into(), + supports_steering: true, + steering_mode: roboco_proto::SteeringMode::StepBoundary, + reasoning_levels: vec![], + installed, + can_install: false, + enabled: Some(true), + }; + let catalog = vec![ + descriptor(HarnessId::Codex, "Codex", true), + descriptor(HarnessId::Pi, "Pi", true), + // Present but not installed: offered_harnesses drops it. + descriptor(HarnessId::ClaudeCode, "Claude Code", false), + ]; + let offered = fork_offered(&catalog, Some(HarnessId::Codex)); + assert_eq!( + offered.iter().map(|d| d.id).collect::>(), + vec![HarnessId::Codex, HarnessId::Pi] + ); + // The source's harness is not offered (its CLI is gone): it still + // rides the list, behind the offered ones. + let offered = fork_offered(&catalog, Some(HarnessId::ClaudeCode)); + assert_eq!( + offered.iter().map(|d| d.id).collect::>(), + vec![HarnessId::Codex, HarnessId::Pi, HarnessId::ClaudeCode] + ); + // Not in the catalog at all: nothing to append. + let offered = fork_offered(&catalog, Some(HarnessId::Grok)); + assert_eq!( + offered.iter().map(|d| d.id).collect::>(), + vec![HarnessId::Codex, HarnessId::Pi] + ); + assert!(fork_offered(&[], Some(HarnessId::Pi)).is_empty()); + } + + /// The dialog opens on both entry points with the source's own harness + /// and model — the classic fork button passes no boundary, "Fork from + /// here" passes the chosen reply's entry id. + #[gpui::test] + fn fork_dialog_opens_with_the_source_defaults(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let source = chat_row( + "src", + Some(serde_json::json!({ + "harness": "codex", + "model": "gpt-6-codex", + "sandbox": "workspace-write", + })), + ); + window + .update(cx, |shell, _, cx| { + // The classic button: no boundary, copy hangs under the + // source. + shell.open_fork_dialog(source.clone(), "src".into(), None, cx); + let dialog = shell.fork_dialog.as_ref().unwrap(); + assert_eq!(dialog.source.id, "src"); + assert_eq!(dialog.parent_id, "src"); + assert_eq!(dialog.boundary, None); + assert_eq!(dialog.harness, Some(HarnessId::Codex)); + assert_eq!(dialog.model.as_deref(), Some("gpt-6-codex")); + shell.close_fork_dialog(cx); + // "Fork from here": the boundary rides the dialog. + shell.open_fork_dialog(source.clone(), "src".into(), Some("a1".into()), cx); + let dialog = shell.fork_dialog.as_ref().unwrap(); + assert_eq!(dialog.boundary.as_deref(), Some("a1")); + assert_eq!(dialog.harness, Some(HarnessId::Codex)); + shell.close_fork_dialog(cx); + }) + .unwrap(); + } + + /// A source with no config yet: the dialog starts unpicked, and the + /// first offered harness becomes the default once a catalog lands. + #[gpui::test] + fn fork_dialog_defaults_a_configless_source_to_the_first_offered_harness( + cx: &mut TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let source = chat_row("src", None); + window + .update(cx, |shell, _, cx| { + shell.open_fork_dialog(source.clone(), "src".into(), None, cx); + assert!(shell.fork_dialog.as_ref().unwrap().harness.is_none()); + // The catalog lands (as the load task would write it): + let catalog = vec![ + HarnessDescriptor { + id: HarnessId::Codex, + name: "Codex".into(), + supports_steering: true, + steering_mode: roboco_proto::SteeringMode::StepBoundary, + reasoning_levels: vec![], + installed: true, + can_install: false, + enabled: Some(true), + }, + ]; + let dialog = shell.fork_dialog.as_mut().unwrap(); + dialog.harnesses = Loadable::Ready(catalog); + // The default-resolution the load completion applies — + // exercised through the dialog's own offered list here. + let first = dialog.fork_offered().first().map(|d| d.id); + if dialog.harness.is_none() && let Some(harness) = first { + dialog.harness = Some(harness); + } + assert_eq!(shell.fork_dialog.as_ref().unwrap().harness, Some(HarnessId::Codex)); + shell.close_fork_dialog(cx); + }) + .unwrap(); + } + + /// Picking another harness resets the model (the source's model id is + /// meaningless there); picking the source's harness back restores it. + /// Confirming hands the selection to the fork flow and clears the + /// dialog; closing retires it without one. + #[gpui::test] + fn picking_harnesses_and_models_steers_the_override(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let source = chat_row( + "src", + Some(serde_json::json!({ + "harness": "codex", + "model": "gpt-6-codex", + "sandbox": "workspace-write", + })), + ); + window + .update(cx, |shell, _, cx| { + shell.open_fork_dialog(source.clone(), "src".into(), Some("a1".into()), cx); + // Another harness: the model resets with it. + shell.pick_fork_harness(HarnessId::Pi, cx); + let dialog = shell.fork_dialog.as_ref().unwrap(); + assert_eq!(dialog.harness, Some(HarnessId::Pi)); + assert_eq!(dialog.model, None, "the source's model never crosses harnesses"); + // A model pick on the new harness rides the override. + shell.pick_fork_model("glm-5.3".into(), cx); + assert_eq!( + shell.fork_dialog.as_ref().unwrap().model.as_deref(), + Some("glm-5.3") + ); + // Back onto the source's harness: its own model returns. + shell.pick_fork_harness(HarnessId::Codex, cx); + let dialog = shell.fork_dialog.as_ref().unwrap(); + assert_eq!(dialog.harness, Some(HarnessId::Codex)); + assert_eq!(dialog.model.as_deref(), Some("gpt-6-codex")); + // Closing retires the dialog without forking. + shell.close_fork_dialog(cx); + assert!(shell.fork_dialog.is_none()); + // Confirm: the dialog hands its fork over (this fixture has + // no engine, so the RPC flow steps out early — the dialog is + // still consumed, and nothing panics). + shell.open_fork_dialog(source.clone(), "src".into(), Some("a1".into()), cx); + shell.pick_fork_harness(HarnessId::Pi, cx); + shell.confirm_fork(cx); + assert!(shell.fork_dialog.is_none()); + }) + .unwrap(); + } + + /// Escape closes the fork dialog, like the other modal overlays. + #[gpui::test] + fn escape_closes_the_fork_dialog(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let source = chat_row("src", None); + window + .update(cx, |shell, _, cx| { + shell.open_fork_dialog(source, "src".into(), None, cx); + assert!(shell.fork_dialog.is_some()); + assert!(shell.overlay_owns_keyboard(cx)); + assert!(shell.capture_escape_surface(cx)); + assert!(shell.fork_dialog.is_none()); + assert!(!shell.overlay_owns_keyboard(cx)); + }) + .unwrap(); + } +} diff --git a/crates/ui/src/shell/side_chats.rs b/crates/ui/src/shell/side_chats.rs index 753add2e35..ce1cfc358b 100644 --- a/crates/ui/src/shell/side_chats.rs +++ b/crates/ui/src/shell/side_chats.rs @@ -32,14 +32,16 @@ impl Shell { } /// The surface picker's "Side chat": fork the current conversation - /// through its latest completed response, as a child of it. + /// through its latest completed response, as a child of it. Ticket 22: + /// the fork dialog opens first so the harness and model can be chosen + /// (defaulting to this conversation's); its confirm performs the fork. pub(super) fn create_side_chat(&mut self, cx: &mut Context) { let Some(source) = self.state.read(cx).selected_chat_row().cloned() else { self.show_side_chat_error("Start a conversation before creating a side chat.", cx); return; }; let parent = source.id.clone(); - self.fork_chat(source, parent, None, cx); + self.open_fork_dialog(source, parent, None, cx); } /// Fork `source` into a new chat hanging under `parent_id`, and open it @@ -48,12 +50,17 @@ impl Shell { /// `boundary` (t3-orchestration-v2 ticket 21, "Fork from here") forks at /// a chosen assistant reply — any settled one, errored and interrupted /// included — instead of the latest completed response; `None` keeps - /// today's behaviour. + /// today's behaviour. `harness`/`model` (ticket 22, "fork into another + /// harness") override what the copy runs on — `None` keeps the source's + /// harness and its native fork behaviour, and the fork dialog passes + /// its picks here on confirm. pub(super) fn fork_chat( &mut self, source: roboco_proto::Chat, parent_id: String, boundary: Option, + harness: Option, + model: Option, cx: &mut Context, ) { if self.side_chat_creating { @@ -64,21 +71,16 @@ impl Shell { }; let key = self.panel_key(cx); self.side_chat_creating = true; - let mut params = serde_json::json!({ - "chatId": uuid::Uuid::new_v4().to_string(), - "sourceChatId": source.id, - "parentChatId": parent_id, - "targetDeviceId": source.device_id, - }); - // Only send the boundary when one was chosen: absent, the engine - // forks from the latest complete reply (the classic button). - if let Some(boundary) = boundary - .as_deref() - .map(str::trim) - .filter(|id| !id.is_empty()) - { - params["boundary"] = serde_json::Value::String(boundary.to_owned()); - } + // The wire payload: overrides ride only when chosen, so an engine + // predating tickets 21/22 still reads its classic fork. + let params = fork_dialog::fork_params( + &uuid::Uuid::new_v4().to_string(), + &source, + &parent_id, + boundary.as_deref(), + harness, + model.as_deref(), + ); cx.spawn(async move |this, cx| { let result = engine .client() diff --git a/docs/mcp.md b/docs/mcp.md index f9d67f45a5..377c6f9ef6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -108,12 +108,20 @@ lost the bind) gets no Roboco tools rather than a dead server. ### Forks -`ForkSideChat { chatId, sourceChatId, parentChatId? }` copies a chat's -history through its latest completed response into a new chat and appends -a `fork` part (a system entry: `sourceChatId`, `sourceTitle`) as the seam; -the transcript draws it as "This chat was forked from ". `parentChatId` -defaults to the source; a side chat's own fork button passes its parent so -the copy lists as a sibling. +`ForkSideChat { chatId, sourceChatId, parentChatId?, boundary?, harness?, +model? }` copies a chat's history through its latest completed response into +a new chat and appends a `fork` part (a system entry: `sourceChatId`, +`sourceTitle`) as the seam; the transcript draws it as "This chat was forked +from <title>". `parentChatId` defaults to the source; a side chat's own fork +button passes its parent so the copy lists as a sibling. `boundary` +(t3-orchestration-v2 ticket 21) names the assistant entry to fork at — any +settled reply, errored and interrupted included — and the copy stops there, +inclusive. `harness`/`model` (ticket 22) override what the copy runs on, by +wire name ("codex", "claude-code", …): a different harness never continues +the source's provider session — its first send carries the budgeted +transcript instead — while a pick of the source's own harness keeps the +native fork. A source chat that hit a usage limit can still be forked into +another harness this way. ## Tools From a8195bfbe904df14df181ccc5a0761904714eefe Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 18:26:23 +0700 Subject: [PATCH 25/96] feat(engine): one-way harness switch between turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 24 (t3-orchestration-v2). A new SwitchHarness RPC turns an existing chat's harness pick into a one-way switch while the chat is idle: the engine records a "Switched to <harness>" seam (a new MessagePart::HarnessSwitch system entry, additive on the wire), drops the chat's harness session from every resume source (live cache, row tombstone, journal scan, native-fork intent), and saves the new harness and model. The next send starts a fresh session on the new harness carrying the budgeted history (ticket 01's builder) of everything above the seam — including the queued-message path, and deferred past a native-command first send. Switching back is just another switch. The desktop picker unlocks between turns (locked while a turn runs or a send is pending), composes the new harness's default picks (model options reset unless the user chose them), stamps the row optimistically and calls the engine. The transcript renders the seam as a "Switched to <harness>" divider. CONTEXT.md gains the Harness switch glossary entry; web gets only the additive generated MessagePart member (web UI is ticket 26). --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 4 + crates/doc/src/parts.rs | 22 +- crates/doc/src/schema.rs | 62 ++ crates/engine/src/rpc.rs | 37 ++ crates/engine/src/sessions.rs | 144 ++++- crates/engine/src/transcript_budget.rs | 6 +- crates/engine/tests/harness_switch.rs | 606 ++++++++++++++++++ crates/roboco-mcp/src/transcript.rs | 12 +- crates/rpc/src/lib.rs | 8 + crates/ui/src/pickers.rs | 319 ++++++++- crates/ui/src/state.rs | 15 + crates/ui/src/transcript.rs | 132 +++- .../proto/src/generated/MessagePart.ts | 7 +- 14 files changed, 1328 insertions(+), 48 deletions(-) create mode 100644 crates/engine/tests/harness_switch.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 69e2b6791e..b34ca12fdc 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -66,7 +66,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test harness_switch # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index 87dd992ad1..c187235eae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -84,6 +84,10 @@ _Avoid_: Message (a command is not a message), request A user message waiting in the chat's queue, optionally held until the turn ends, delivered in order. _Avoid_: Draft, pending message +**Harness switch**: +A one-way switch of an existing chat to another harness between turns: the engine records a "Switched to <harness>" seam in the transcript, drops the chat's harness session, and the next send starts a fresh session carrying the budgeted history of everything above the seam. Switching back is another switch, never a resume. +_Avoid_: Harness handoff, migration, resume (the old session is dropped, not resumed) + ## Harnesses **Harness**: diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 6e410ff998..ca61dc6b7b 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; -use roboco_proto::{AgentEvent, SUBAGENT_INPUT_KEEP, ToolCall, ToolDiff, UserInputQuestion}; +use roboco_proto::{AgentEvent, HarnessId, SUBAGENT_INPUT_KEEP, ToolCall, ToolDiff, UserInputQuestion}; use crate::constants::MSG_INLINE_MAX; @@ -249,6 +249,20 @@ pub enum MessagePart { /// source does not rewrite history. source_title: String, }, + /// The seam a one-way harness switch leaves in a chat's transcript + /// (t3-orchestration-v2 ticket 24): everything above ran on another + /// harness whose provider session the switch dropped; the next send on + /// `harness` carries the budgeted history of everything above. Written + /// once per switch by the engine; renders as a "Switched to <harness>" + /// divider. Old desktop builds' unknown-kind fallback yields an empty + /// text part (invisible). There is no switch BACK: returning to the old + /// harness is just another one-way switch. + #[serde(rename_all = "camelCase")] + HarnessSwitch { + id: String, + /// The harness the chat switched TO (wire name, e.g. "codex"). + harness: HarnessId, + }, /// The engine's completion notice (orchestration v2): an engine-authored /// SYSTEM entry in a parent chat reporting the recorded outcomes of its /// agent-created side chats' turns — finished, failed (one-line reason), @@ -279,6 +293,7 @@ impl MessagePart { | MessagePart::Input { id, .. } | MessagePart::Error { id, .. } | MessagePart::Fork { id, .. } + | MessagePart::HarnessSwitch { id, .. } | MessagePart::CompletionNotice { id, .. } => id, } } @@ -317,6 +332,11 @@ impl MessagePart { source_title, .. } => source_chat_id.len() + source_title.len(), + MessagePart::HarnessSwitch { harness, .. } => { + // Fixed-size structure (the wire name): never grows, so it + // never drives a continuation split. + serde_json::to_vec(harness).map_or(0, |v| v.len()) + } MessagePart::CompletionNotice { sides, .. } => sides .iter() .map(|side| { diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index eaacbb93c9..f3fb8a5051 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -113,6 +113,11 @@ struct DocPartJson { source_chat_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] source_title: Option<String>, + /// Harness-switch seam (`kind: "harnessSwitch"`, additive): the harness + /// (wire name) the chat switched to; everything above the seam ran on + /// another harness whose provider session the switch dropped. + #[serde(default, skip_serializing_if = "Option::is_none")] + harness: Option<String>, /// Tool output summary (additive — absent on old rows and old writers; /// pre-strip writers stored up to 4KB of capped output here). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -244,6 +249,14 @@ fn to_doc_part(part: &MessagePart) -> Result<DocPartJson, DocError> { source_title: Some(source_title.clone()), ..Default::default() }, + MessagePart::HarnessSwitch { id, harness } => DocPartJson { + id: id.clone(), + kind: "harnessSwitch".into(), + harness: serde_json::to_value(harness) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)), + ..Default::default() + }, MessagePart::CompletionNotice { id, sides } => DocPartJson { id: id.clone(), kind: "completionNotice".into(), @@ -305,6 +318,24 @@ fn from_doc_part(p: DocPartJson) -> MessagePart { source_chat_id: p.source_chat_id.unwrap_or_default(), source_title: p.source_title.unwrap_or_default(), }, + "harnessSwitch" => { + // A harness name this build does not know (a future wire value) + // degrades to an invisible empty part rather than failing the doc. + let harness = p.harness.and_then(|name| { + serde_json::from_value::<roboco_proto::HarnessId>(serde_json::Value::String(name)) + .ok() + }); + match harness { + Some(harness) => MessagePart::HarnessSwitch { + id: p.id, + harness, + }, + None => MessagePart::Text { + id: p.id, + text: String::new(), + }, + } + } "completionNotice" => MessagePart::CompletionNotice { id: p.id, sides: p @@ -1597,6 +1628,37 @@ mod tests { assert_eq!(entries[0].parts, vec![seam]); } + /// The one-way harness switch seam (t3-orchestration-v2 ticket 24): a + /// SYSTEM entry whose marker part round-trips through the doc with the + /// harness wire name. Additive (ADR 0005): an older reader without the + /// kind degrades to an invisible empty part; a row from an older writer + /// simply never carries one. + #[test] + fn harness_switch_seam_round_trips_through_the_doc() { + let doc = SessionDoc::init("switch").unwrap(); + let seam = MessagePart::HarnessSwitch { + id: "switch:1".into(), + harness: roboco_proto::HarnessId::Codex, + }; + doc.push_message(&SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + provider_turn: None, + id: "switch:1".into(), + role: MessageRole::System, + parts: vec![seam.clone()], + created_at: 1, + device_id: "dev".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + }) + .unwrap(); + let entries = doc.read_entries().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].role, MessageRole::System); + assert_eq!(entries[0].parts, vec![seam]); + } + /// The engine-authored completion notice: a SYSTEM entry whose marker /// part (sides + outcomes) round-trips through the doc beside the plain /// text that keeps older layouts readable. Wire names stay camelCase and diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 882759e21a..58400c6da2 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1653,6 +1653,43 @@ impl RpcService for EngineRpc { self.workspace.import_chat_row(&chat).map_err(failed)?; RpcReply::value(&chat) } + methods::SWITCH_HARNESS => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct SwitchParams { + chat_id: String, + harness: HarnessId, + #[serde(default)] + model: Option<String>, + #[serde(default)] + reasoning: Option<roboco_proto::ReasoningLevel>, + #[serde(default)] + model_options: serde_json::Map<String, serde_json::Value>, + } + let p: SwitchParams = parse_params(params)?; + let failed = |e: crate::EngineError| RpcError::Failed(e.to_string()); + // Sandbox is the one field the switch does not own: whatever + // the chat already runs stays (the picker never offered it). + let sandbox = self + .workspace + .chat(&p.chat_id) + .map_err(failed)? + .and_then(|chat| chat.config) + .map(|config| config.sandbox) + .unwrap_or(roboco_proto::SandboxLevel::WorkspaceWrite); + let config = ChatConfig { + harness: p.harness, + model: p.model, + reasoning: p.reasoning, + model_options: p.model_options, + sandbox, + }; + self.sessions + .switch_harness(&p.chat_id, config) + .await + .map_err(failed)?; + RpcReply::value(&serde_json::json!({})) + } methods::RELAY_COMMAND => { let p: RelayCommandParams = parse_params(params)?; let outcome = self diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 8f40e05715..a40fb2f02e 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1150,6 +1150,86 @@ impl SessionsEngine { Ok(true) } + /// One-way harness switch between turns (t3-orchestration-v2 ticket 24). + /// Writes the "Switched to <harness>" seam into the transcript, drops + /// the chat's harness session (the tombstone also blocks the journal + /// scan from resurrecting the old harness's id), retires any stale + /// native-fork intent, and saves `config` — the caller's resolved picks + /// (model options reset to the new harness's defaults unless the user + /// chose them; the engine only stores what arrived). A warm runtime from + /// the old harness is torn down by the next dispatch (its run + /// configuration no longer routes). The next send starts a fresh + /// provider session on the new harness and carries the budgeted history + /// of everything above the seam. Switching back is just another switch. + /// + /// Refused while a turn is in flight (the picker stays locked while the + /// chat works), for an unknown chat, and for a switch onto the harness + /// the chat already runs. + pub async fn switch_harness( + &self, + chat_id: &str, + config: roboco_proto::ChatConfig, + ) -> Result<(), EngineError> { + let Some(ws) = self.workspace() else { + return Err(EngineError::Other("workspace unavailable".into())); + }; + let chat = ws + .chat(chat_id)? + .ok_or_else(|| EngineError::Other("Chat no longer exists".into()))?; + if !ws.is_host(chat_id) { + return Err(EngineError::Other( + "Harness switch must run on the chat's host device".into(), + )); + } + if self.turn_in_flight(chat_id) { + return Err(EngineError::Other( + "The chat is working; switch harnesses between turns".into(), + )); + } + // The target harness must resolve first: the switch is pointless (and + // the next dispatch would fail) against an unregistered harness, and + // its display name names it in the refusal below. + let harness = self.inner.registry.resolve(config.harness)?; + if chat.config.as_ref().is_some_and(|existing| existing.harness == config.harness) { + return Err(EngineError::Other(format!( + "Chat is already on {}", + harness.display_name() + ))); + } + // The seam first: once the row names the new harness, a crash between + // the two writes would at worst dispatch the next send on the new + // harness with the history still riding (the seam is what arms it). + let handle = self.doc_handle(chat_id)?; + handle.doc().clear_provider_fork(); + let marker_id = format!("switch:{}", new_id()); + handle + .doc() + .push_message(&SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + provider_turn: None, + id: marker_id.clone(), + role: MessageRole::System, + parts: vec![MessagePart::HarnessSwitch { + id: marker_id, + harness: config.harness, + }], + created_at: now_ms(), + device_id: self.inner.device_id.clone(), + status: Some(MessageStatus::Complete), + continuation_of: None, + }) + .map_err(|e| EngineError::Other(e.to_string()))?; + // Drop the session from every resume source: the live cache, the + // durable row (the empty-string tombstone also stops the journal + // scan — the old harness's session ids must never ride a resume on + // the new harness), and the native-fork intent above. + lock(&self.inner.harness_sessions).remove(chat_id); + ws.set_chat_harness_session(chat_id, "", ""); + ws.set_chat_config(chat_id, &config)?; + Ok(()) + } + /// Resolve a pending `request_input` question set. Returns `false` when no such /// request is pending (unknown id, or the run already settled). pub fn respond_input( @@ -1784,13 +1864,16 @@ fn note_fork_history_session(doc: &SessionDoc, carried: bool, session_id: &str) } impl Inner { - /// `prompt` with the transcript a side chat's provider session lacks in - /// front of it, or `None` when it lacks nothing. A fresh session gets the - /// budgeted transcript (`current` excluded); a continued one only the - /// fork's copied transcript before the seam, until that went out to that + /// `prompt` with the transcript a provider session lacks in front of it, + /// or `None` when it lacks nothing. A side chat's fresh session gets its + /// budgeted copied transcript (`current` excluded); a main chat's fresh + /// session gets the budgeted transcript only after a harness switch (the + /// seam marks what the dropped session held); a continued one only the + /// history above the chat's newest seam, until that went out to that /// session. The budget and the selection rules live in - /// [`crate::transcript_budget`]. Native commands are never wrapped: - /// providers route a leading `/name`. + /// [`crate::transcript_budget`]. Native commands are never wrapped — + /// providers route a leading `/name`, so their history rides the first + /// ordinary send instead. fn fork_history_prompt( &self, chat_id: &str, @@ -1818,12 +1901,7 @@ impl Inner { current: Option<&str>, provider: ProviderSession<'_>, ) -> Option<String> { - if native_command(prompt, harness_id) - || !self - .workspace() - .and_then(|ws| ws.chat(chat_id).ok().flatten()) - .is_some_and(|chat| chat.parent_chat_id.is_some()) - { + if native_command(prompt, harness_id) { return None; } let entries: Vec<_> = doc @@ -1838,6 +1916,20 @@ impl Inner { }) }) .collect(); + // A side chat owes its copied transcript to any session that lacks + // it. A main chat owes its transcript only after a one-way harness + // switch (t3-orchestration-v2 ticket 24): the seam marks the + // conversation the new harness's fresh session never saw, so the + // first send after the switch carries the budgeted history of + // everything above it; without a seam a main chat's fresh session + // starts empty (nothing was copied, nothing was dropped). + let side_chat = self + .workspace() + .and_then(|ws| ws.chat(chat_id).ok().flatten()) + .is_some_and(|chat| chat.parent_chat_id.is_some()); + if !side_chat && !entries.iter().any(is_harness_switch_seam) { + return None; + } let end = match provider { ProviderSession::Fresh => entries.len(), ProviderSession::Continued(session) => { @@ -1845,7 +1937,14 @@ impl Inner { if session.is_some() && delivered.as_deref() == session { return None; } - entries.iter().position(is_fork_seam)? + // The owed unit ends at the NEWEST seam, whichever kind: a + // fork's copied transcript above its seam, or the dropped + // conversation above a harness-switch seam (ticket 24). A + // session that changed below the newest seam lacks + // everything above it — including the case where the first + // post-switch send was a native command (never wrapped), so + // the history rides the first ordinary send instead. + entries.iter().rposition(is_history_seam)? } }; // The chat the omitted messages are readable in: the newest fork @@ -1870,6 +1969,25 @@ fn is_fork_seam(entry: &SessionMessageEntry) -> bool { .any(|part| matches!(part, MessagePart::Fork { .. })) } +/// A history seam of either kind: the divider below which the chat's +/// provider session started fresh — a fork's copy boundary or a harness +/// switch's drop boundary. The newest one bounds what a changed session is +/// owed. +fn is_history_seam(entry: &SessionMessageEntry) -> bool { + is_fork_seam(entry) || is_harness_switch_seam(entry) +} + +/// A harness-switch seam entry (t3-orchestration-v2 ticket 24): the divider +/// below which the chat runs on the harness the switch named. Everything +/// above it belongs to the provider session the switch dropped, so it is +/// the history the new harness's first session is owed. +fn is_harness_switch_seam(entry: &SessionMessageEntry) -> bool { + entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::HarnessSwitch { .. })) +} + /// The chat a fork seam copied its transcript from. fn fork_seam_source(entry: &SessionMessageEntry) -> Option<String> { entry diff --git a/crates/engine/src/transcript_budget.rs b/crates/engine/src/transcript_budget.rs index 5ab03cdad8..e06c4cc141 100644 --- a/crates/engine/src/transcript_budget.rs +++ b/crates/engine/src/transcript_budget.rs @@ -211,12 +211,14 @@ fn render_parts(parts: &[MessagePart]) -> String { MessagePart::Error { message, .. } => format!("Error: {message}"), MessagePart::Tool { .. } => render_command_run(part), // Reasoning is the model's private scratchpad; images are - // attachments; input requests, fork seams and completion notices - // are structure — none of them belong in a budgeted summary. + // attachments; input requests, fork seams, harness-switch seams + // and completion notices are structure — none of them belong in + // a budgeted summary. MessagePart::Reasoning { .. } | MessagePart::Image { .. } | MessagePart::Input { .. } | MessagePart::Fork { .. } + | MessagePart::HarnessSwitch { .. } | MessagePart::CompletionNotice { .. } => continue, }; blocks.push(block); diff --git a/crates/engine/tests/harness_switch.rs b/crates/engine/tests/harness_switch.rs new file mode 100644 index 0000000000..6935858d52 --- /dev/null +++ b/crates/engine/tests/harness_switch.rs @@ -0,0 +1,606 @@ +//! One-way harness switch between turns (t3-orchestration-v2 ticket 24). +//! +//! Engine-side machinery under test: +//! - the `SwitchHarness` RPC writes the "Switched to <harness>" seam as a +//! system entry, clears the chat's harness session (the empty-string +//! tombstone — which also stops the journal scan from resurrecting the old +//! harness's session ids), retires any stale native-fork intent, and saves +//! the new harness and model; +//! - it is refused while a turn runs, on a switch onto the harness the chat +//! already runs, for an unknown chat, and for a harness that is not +//! registered; +//! - the next send dispatches on the NEW harness with no resume, carrying +//! the budgeted transcript (ticket 01) of everything above the seam — for +//! a queued message too (the queue rides the chat row's config) — exactly +//! once per provider session; +//! - switching back is just another one-way switch: a second seam, another +//! fresh session, the whole conversation (old-harness turns included) in +//! the budgeted history; +//! - the cleared session stays cleared across an engine restart: the +//! journal's old-harness session ids never ride a resume on the new +//! harness. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry}; +use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentEvent, ChatConfig, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, + SandboxLevel, SteeringMode, +}; +use roboco_rpc::methods; + +const CHAT: &str = "chat-switch"; + +/// A scripted harness that answers at once and records every request it ran. +/// One instance per harness id, so a switch shows up as the requests moving +/// from one capture to the other. +struct Capture { + id: HarnessId, + name: &'static str, + /// The provider session id every Done reports (distinct per harness, so + /// assertions can tell which session the engine resumed). + session: &'static str, + reply: &'static str, + requests: Arc<Mutex<Vec<RunRequest>>>, +} + +impl Capture { + fn build( + id: HarnessId, + name: &'static str, + session: &'static str, + reply: &'static str, + ) -> (Arc<Self>, Arc<Mutex<Vec<RunRequest>>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + ( + Arc::new(Self { + id, + name, + session, + reply, + requests: requests.clone(), + }), + requests, + ) + } +} + +#[async_trait] +impl Harness for Capture { + fn id(&self) -> HarnessId { + self.id + } + fn display_name(&self) -> &str { + self.name + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.requests.lock().unwrap().push(request.clone()); + let reply = self.reply.to_string(); + let session = self.session.to_string(); + Ok(futures::stream::iter(vec![ + Ok(AgentEvent::TextDelta { text: reply }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + }), + ]) + .boxed()) + } +} + +/// A harness whose turn runs until the test says so — "the chat is working" +/// as a state the test controls rather than races. +struct Held { + finish: tokio::sync::broadcast::Sender<()>, +} + +impl Held { + fn new() -> (Arc<Self>, tokio::sync::broadcast::Sender<()>) { + let (finish, _) = tokio::sync::broadcast::channel(16); + (Arc::new(Self { finish: finish.clone() }), finish) + } +} + +#[async_trait] +impl Harness for Held { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Held" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + _: RunRequest, + _: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + let mut finish = self.finish.subscribe(); + Ok(futures::stream::once(async move { + let _ = finish.recv().await; + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("held-session".into()), + }) + }) + .boxed()) + } +} + +async fn wait_for<F>(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +fn create_chat(core: &EngineCore, config: ChatConfig) { + core.workspace + .create_chat(CHAT, None, Some(&core.device_id), Some(config), Some("/tmp".into())) + .expect("create chat"); + // Pre-title so the auto-titler never dispatches a harness request of its own. + core.workspace.rename_chat(CHAT, "Pre-titled").expect("rename chat"); +} + +fn entries(core: &EngineCore) -> Vec<SessionMessageEntry> { + core.doc_host + .open(CHAT) + .expect("open chat") + .doc() + .read_entries() + .expect("read entries") +} + +/// The chat row as the workspace doc sees it. +fn chat_row(core: &EngineCore) -> roboco_proto::Chat { + core.workspace.chat(CHAT).expect("chat row").expect("chat exists") +} + +fn mock_config() -> ChatConfig { + ChatConfig { + harness: HarnessId::Mock, + model: Some("mock-model".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + } +} + +/// Queue one message on the (idle) chat — the composer's send path; the +/// drain dispatches it on the chat row's harness. +fn send(core: &EngineCore, text: &str) { + core.doc_host.queue_message(CHAT, text, Vec::new()).expect("queue message"); +} + +async fn switch( + core: &EngineCore, + harness: HarnessId, + model: Option<&str>, +) -> Result<serde_json::Value, roboco_rpc::RpcError> { + let client = roboco_rpc::memory_client(core.rpc_service()); + let mut params = serde_json::json!({ "chatId": CHAT, "harness": harness }); + if let Some(model) = model { + params["model"] = serde_json::Value::String(model.into()); + } + client.call(methods::SWITCH_HARNESS, params).await +} + +/// The transcript's harness-switch seams naming `harness`: `count` of them, +/// each a complete system row. +fn expect_seams(core: &EngineCore, harness: HarnessId, count: usize) { + let seams: Vec<SessionMessageEntry> = entries(core) + .into_iter() + .filter(|entry| { + entry.role == MessageRole::System + && entry.parts.iter().any(|part| { + matches!( + part, + MessagePart::HarnessSwitch { harness: to, .. } if *to == harness + ) + }) + }) + .collect(); + assert_eq!(seams.len(), count, "seams for {harness:?}"); + for seam in &seams { + assert_eq!(seam.status, Some(MessageStatus::Complete)); + } +} + +/// One mock exchange leaves the transcript and the row session behind. +async fn run_mock_turn( + core: &EngineCore, + mock_requests: &Arc<Mutex<Vec<RunRequest>>>, + prompt: &str, +) { + send(core, prompt); + wait_for( + || mock_requests.lock().unwrap().iter().any(|r| r.prompt.contains(prompt)), + "the mock turn to run", + ) + .await; + wait_for( + || chat_row(core).harness_session_id.as_deref() == Some("mock-session"), + "the mock session to be recorded", + ) + .await; + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_none_or(|s| s.status != roboco_proto::SessionStatus::Working) + }, + "the mock turn to settle", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn switch_writes_the_seam_clears_the_session_and_saves_the_harness() { + let tmp = tempfile::tempdir().unwrap(); + let (mock, mock_requests) = + Capture::build(HarnessId::Mock, "Mock", "mock-session", "I remember PINEAPPLE"); + let (pi, _) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(mock); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + + run_mock_turn(&core, &mock_requests, "Remember PINEAPPLE").await; + let before = entries(&core).len(); + assert_eq!(chat_row(&core).harness_session_id.as_deref(), Some("mock-session")); + + switch(&core, HarnessId::Pi, Some("pi-default")).await.expect("switch"); + + // The seam: a system entry after the transcript, complete, naming the + // new harness. + expect_seams(&core, HarnessId::Pi, 1); + assert_eq!(entries(&core).len(), before + 1); + // The row: new harness and model, and the session cleared — the + // empty-string tombstone, not a missing field (the journal scan must + // not resurrect the mock session later). + let row = chat_row(&core); + assert_eq!(row.config.as_ref().map(|c| c.harness), Some(HarnessId::Pi)); + assert_eq!(row.config.as_ref().and_then(|c| c.model.as_deref()), Some("pi-default")); + assert_eq!(row.harness_session_id.as_deref(), Some("")); + assert!(row.harness_session_cwd.as_deref().is_none_or(|cwd| cwd.is_empty())); + + // Refusals: the same harness, an unregistered one, an unknown chat. + let again = switch(&core, HarnessId::Pi, None).await.unwrap_err(); + assert!(again.to_string().contains("already on"), "{again}"); + let unregistered = switch(&core, HarnessId::Codex, None).await.unwrap_err(); + assert!(unregistered.to_string().contains("not found"), "{unregistered}"); + let client = roboco_rpc::memory_client(core.rpc_service()); + let no_chat = client + .call( + methods::SWITCH_HARNESS, + serde_json::json!({ "chatId": "nope", "harness": "pi" }), + ) + .await + .unwrap_err(); + assert!(no_chat.to_string().contains("no longer exists"), "{no_chat}"); + // The refusals wrote nothing: one seam, the pi row. + expect_seams(&core, HarnessId::Pi, 1); + assert_eq!(chat_row(&core).config.as_ref().map(|c| c.harness), Some(HarnessId::Pi)); + + core.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_switch_is_refused_while_the_chat_works_and_allowed_once_idle() { + let tmp = tempfile::tempdir().unwrap(); + let (held, finish) = Held::new(); + let (pi, _) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(held); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + + send(&core, "hold this turn"); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == roboco_proto::SessionStatus::Working) + }, + "the turn to start", + ) + .await; + + let refused = switch(&core, HarnessId::Pi, None).await.unwrap_err(); + assert!(refused.to_string().contains("between turns"), "{refused}"); + // Nothing was written: no seam, no row change. + assert!(entries(&core).iter().all(|entry| entry.role != MessageRole::System)); + assert_eq!(chat_row(&core).config.as_ref().map(|c| c.harness), Some(HarnessId::Mock)); + + // The turn ends → the chat is idle → the same switch goes through. + let _ = finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_none_or(|s| s.status != roboco_proto::SessionStatus::Working) + }, + "the turn to settle", + ) + .await; + switch(&core, HarnessId::Pi, None).await.expect("switch once idle"); + expect_seams(&core, HarnessId::Pi, 1); + + core.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_next_send_carries_the_budgeted_history_exactly_once_on_the_new_harness() { + let tmp = tempfile::tempdir().unwrap(); + let (mock, mock_requests) = + Capture::build(HarnessId::Mock, "Mock", "mock-session", "I remember PINEAPPLE"); + let (pi, pi_requests) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(mock); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + + run_mock_turn(&core, &mock_requests, "Remember PINEAPPLE").await; + switch(&core, HarnessId::Pi, Some("pi-default")).await.expect("switch"); + + // A queued message after the switch: the chat row routes it to the new + // harness, the old session never rides a resume, and the budgeted + // transcript of everything above the seam rides in front of it. + send(&core, "What did I ask you to remember?"); + wait_for( + || { + pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.contains("What did I ask you to remember?")) + }, + "the post-switch send to reach pi", + ) + .await; + let request = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.harness, Some(HarnessId::Pi)); + assert_eq!(request.resume, None, "the old harness's session must not resume"); + assert!(request.prompt.contains("Remember PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.contains("I remember PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.contains("<transcript>"), "{}", request.prompt); + // The seam itself is structure, never agent-visible text. + assert!(!request.prompt.contains("switch:"), "{}", request.prompt); + wait_for( + || chat_row(&core).harness_session_id.as_deref() == Some("pi-session"), + "the new harness's session to be recorded", + ) + .await; + + // The send after that continues the pi session as-is: the history went + // out exactly once, to the session that now holds it. + send(&core, "and now?"); + wait_for( + || pi_requests.lock().unwrap().len() >= 2, + "the second post-switch send to reach pi", + ) + .await; + let request = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.resume.as_deref(), Some("pi-session")); + assert!( + !request.prompt.contains("PINEAPPLE"), + "the history must not ride twice: {}", + request.prompt + ); + assert!(request.prompt.contains("and now?"), "{}", request.prompt); + + core.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_native_command_first_turn_defers_the_history_to_the_next_ordinary_send() { + let tmp = tempfile::tempdir().unwrap(); + let (mock, mock_requests) = + Capture::build(HarnessId::Mock, "Mock", "mock-session", "I remember PINEAPPLE"); + let (pi, pi_requests) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(mock); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + + run_mock_turn(&core, &mock_requests, "Remember PINEAPPLE").await; + switch(&core, HarnessId::Pi, None).await.expect("switch"); + + // The first post-switch send is a native command: it goes out bare (no + // wrapper in front of a leading `/name`), so the history it owes the + // fresh session has to ride the first ORDINARY send instead. + send(&core, "/review"); + wait_for( + || pi_requests.lock().unwrap().iter().any(|r| r.prompt.contains("/review")), + "the native command to reach pi", + ) + .await; + let command = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(command.prompt, "/review"); + assert!(!command.prompt.contains("PINEAPPLE"), "{}", command.prompt); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_none_or(|s| s.status != roboco_proto::SessionStatus::Working) + }, + "the command turn to settle", + ) + .await; + + send(&core, "What did I ask you to remember?"); + wait_for( + || pi_requests.lock().unwrap().len() >= 2, + "the first ordinary send to reach pi", + ) + .await; + let request = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.resume.as_deref(), Some("pi-session")); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.contains("What did I ask you to remember?"), "{}", request.prompt); + // The command's own turn is below the seam: it already rode its run, + // and is never pasted as conversation text. + assert!(!request.prompt.contains("/review"), "{}", request.prompt); + assert!(!request.prompt.contains("switch:"), "{}", request.prompt); + + core.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn switching_back_is_another_one_way_switch_with_the_whole_conversation() { + let tmp = tempfile::tempdir().unwrap(); + let (mock, mock_requests) = + Capture::build(HarnessId::Mock, "Mock", "mock-session", "I remember PINEAPPLE"); + let (pi, pi_requests) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "the pi exchange"); + let registry = HarnessRegistry::new(); + registry.register(mock); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + + run_mock_turn(&core, &mock_requests, "Remember PINEAPPLE").await; + switch(&core, HarnessId::Pi, None).await.expect("switch to pi"); + send(&core, "continue on pi"); + wait_for( + || pi_requests.lock().unwrap().iter().any(|r| r.prompt.contains("continue on pi")), + "the pi turn to run", + ) + .await; + wait_for( + || chat_row(&core).harness_session_id.as_deref() == Some("pi-session"), + "the pi session to be recorded", + ) + .await; + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_none_or(|s| s.status != roboco_proto::SessionStatus::Working) + }, + "the pi turn to settle", + ) + .await; + + // Switching back: a second seam, the pi session dropped the same way. + switch(&core, HarnessId::Mock, Some("mock-model")).await.expect("switch back"); + expect_seams(&core, HarnessId::Pi, 1); + expect_seams(&core, HarnessId::Mock, 1); + assert_eq!(chat_row(&core).harness_session_id.as_deref(), Some("")); + let mock_turns = mock_requests.lock().unwrap().len(); + assert_eq!(mock_turns, 1, "the mock harness ran exactly its one turn"); + + send(&core, "back on mock"); + wait_for( + || mock_requests.lock().unwrap().len() >= 2, + "the post-switch send to reach the mock harness", + ) + .await; + let request = mock_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.harness, Some(HarnessId::Mock)); + assert_eq!(request.resume, None, "neither session may resume after the switch"); + // The budgeted history now covers BOTH sides of the earlier seam: the + // original mock exchange and the pi exchange above the switch back. + assert!(request.prompt.contains("Remember PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.contains("the pi exchange"), "{}", request.prompt); + assert!(request.prompt.contains("back on mock"), "{}", request.prompt); + assert!(!request.prompt.contains("switch:"), "{}", request.prompt); + + core.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_cleared_session_stays_cleared_across_an_engine_restart() { + let tmp = tempfile::tempdir().unwrap(); + let (mock, mock_requests) = + Capture::build(HarnessId::Mock, "Mock", "mock-session", "I remember PINEAPPLE"); + let (pi, _) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(mock); + registry.register(pi); + let core = EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + create_chat(&core, mock_config()); + run_mock_turn(&core, &mock_requests, "Remember PINEAPPLE").await; + switch(&core, HarnessId::Pi, None).await.expect("switch"); + + // Restart: the journal still holds the mock session ids; the tombstone + // on the row must keep them from riding a resume on the new harness. + core.shutdown().await; + drop(core); + let (pi, pi_requests) = Capture::build(HarnessId::Pi, "Pi", "pi-session", "pi answer"); + let registry = HarnessRegistry::new(); + registry.register(pi); + let restarted = + EngineCore::assemble(tmp.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + assert_eq!(chat_row(&restarted).harness_session_id.as_deref(), Some("")); + + send(&restarted, "What did I ask you to remember?"); + wait_for( + || { + pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.contains("What did I ask you to remember?")) + }, + "the post-restart send to reach pi", + ) + .await; + let request = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.harness, Some(HarnessId::Pi)); + assert_eq!( + request.resume, None, + "the journal must not resurrect the old harness's session" + ); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + + restarted.shutdown().await; +} diff --git a/crates/roboco-mcp/src/transcript.rs b/crates/roboco-mcp/src/transcript.rs index 626c5546ba..d96e3f1dad 100644 --- a/crates/roboco-mcp/src/transcript.rs +++ b/crates/roboco-mcp/src/transcript.rs @@ -146,11 +146,13 @@ fn render_one(entry: &SessionMessageEntry, options: RenderOptions) -> RenderedMe } } MessagePart::Error { message, .. } => errors.push(message.clone()), - // The fork seam and the completion notice are transcript - // markers, not agent content: an orchestrator reads the copied - // history as ordinary turns, and the notice's own text part - // already rendered above. - MessagePart::Fork { .. } | MessagePart::CompletionNotice { .. } => {} + // The fork seam, the harness-switch seam and the completion + // notice are transcript markers, not agent content: an + // orchestrator reads the copied history as ordinary turns, and + // the notice's own text part already rendered above. + MessagePart::Fork { .. } + | MessagePart::HarnessSwitch { .. } + | MessagePart::CompletionNotice { .. } => {} } } RenderedMessage { diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 562a68807f..860bc4b3ac 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -73,6 +73,14 @@ pub mod methods { /// appends the `fork` seam, and lists the copy under its parent. /// Params `{chatId, sourceChatId, parentChatId?}` → `Chat`. pub const FORK_SIDE_CHAT: &str = "ForkSideChat"; + /// One-way harness switch on an existing chat between turns + /// (t3-orchestration-v2 ticket 24). Params `{chatId, harness, model?, + /// reasoning?, modelOptions?}` → `{}`: the engine writes the + /// "Switched to <harness>" seam, clears the chat's harness session, and + /// saves the new harness with the picks that arrived (model options + /// reset to the new harness's defaults unless the user chose them). + /// Refused while the chat works; must run on the chat's host device. + pub const SWITCH_HARNESS: &str = "SwitchHarness"; pub const WATCH_DOC_MESSAGES: &str = "WatchDocMessages"; /// Messages typed while the agent was busy, held on the chat doc so every /// device sees the same queue. `{ chatId }` → `{ items: QueuedMessage[] }`. diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 846ad35bc1..e6cda9e070 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -922,10 +922,21 @@ impl Pickers { &self.config } - /// Harness is locked once the chat exists (feature-inventory §1.7). + /// Harness is locked while the chat exists and works (feature-inventory + /// §1.7): an unsaved side chat can pick until its first send, and an + /// existing chat unlocks BETWEEN turns for a one-way switch + /// (t3-orchestration-v2 ticket 24) — the engine writes the + /// "Switched to <harness>" seam and drops the old session. Thread + /// naming never offers a switch (the title's agent is fixed). fn harness_locked(&self, cx: &App) -> bool { let state = self.state.read(cx); - self.title.is_none() && state.selected_chat.is_some() && !state.side_chat_harness_editable() + self.title.is_none() + && state.selected_chat.is_some() + && !state.side_chat_harness_editable() + && !state + .selected_chat + .as_deref() + .is_some_and(|chat| state.harness_switchable(chat, chrono::Utc::now())) } /// The harnesses this picker offers: runnable ones for the composer, @@ -1758,13 +1769,7 @@ impl Pickers { // A side-chat draft already has a selected row. Replace its inherited // provider settings so both the picker and first createChat use the // new harness, even when its model catalog has not loaded yet. - let model = self.defaults.model_for(harness).map(|m| m.id.clone()); - let reasoning = self.defaults.reasoning_for(harness, model.as_deref()); - let options = model - .as_deref() - .and_then(|model| self.defaults.model_options_for(harness, model)) - .cloned() - .unwrap_or_default(); + let (model, reasoning, options) = self.default_picks(harness); self.update_chat_config(cx, move |config| { config.harness = harness; config.model = model; @@ -1779,7 +1784,20 @@ impl Pickers { self.config.reasoning = None; } self.config.harness = Some(harness); + // An existing chat's pick is a one-way harness switch + // (t3-orchestration-v2 ticket 24): the engine writes the + // "Switched to <harness>" seam, drops the old harness session, and + // saves this harness with its default picks — the next send carries + // the budgeted history of everything above the seam. Unsaved side + // chats never reach here (their createChat still owns the harness). if self.title.is_none() { + let (chat_id, unsaved) = { + let state = self.state.read(cx); + (state.selected_chat.clone(), state.side_chat_unsaved()) + }; + if let Some(chat_id) = chat_id.filter(|_| !unsaved) { + self.switch_chat_harness(&chat_id, harness, cx); + } self.defaults.harness = Some(harness); self.save_defaults(); } @@ -1907,6 +1925,35 @@ impl Pickers { config.sandbox = existing.sandbox; } change(&mut config); + self.clamp_config(&mut config, cx); + self.state.update(cx, |state, cx| { + state.apply_chat_config(&chat_id, config.clone()); + cx.notify(); + }); + // The first send creates this row. Until then all choices stay local. + if self.state.read(cx).side_chat_unsaved() { + return; + } + let Some(engine) = self.engine(cx) else { + return; + }; + self.mutate_task = Some(cx.spawn(async move |_, _| { + let params = serde_json::json!({ + "op": "setChatConfig", + "chatId": chat_id, + "config": config, + }); + if let Err(err) = engine.call(methods::MUTATE, params).await { + tracing::warn!(error = %err, "setChatConfig mutate failed"); + } + })); + } + + /// Keep a composed config concrete for the model it names: reasoning on + /// the model's ladder (else the harness's advertised ladder), and + /// options limited to the ones that model offers. No-op while the + /// harness's catalog is not loaded. + fn clamp_config(&self, config: &mut ChatConfig, cx: &App) { // Reasoning must stay concrete for whatever model the row now names — // same ladder resolution as [`Self::trait_ladder`] (model levels, else // the harness's advertised ladder). @@ -1939,25 +1986,90 @@ impl Pickers { offered_options(model, std::mem::take(&mut config.model_options)); } } + } + + /// The new harness's default picks — "model options reset to the new + /// harness's defaults unless the user chose them" (t3-orchestration-v2 + /// ticket 24): the remembered last-used model at its last-used reasoning + /// and options, else the harness's own (empty) ones. Both an unsaved + /// side chat's harness pick and an existing chat's one-way switch apply + /// the same reset. + fn default_picks( + &self, + harness: HarnessId, + ) -> ( + Option<String>, + Option<ReasoningLevel>, + serde_json::Map<String, serde_json::Value>, + ) { + let model = self.defaults.model_for(harness).map(|m| m.id.clone()); + let reasoning = self.defaults.reasoning_for(harness, model.as_deref()); + let options = model + .as_deref() + .and_then(|model| self.defaults.model_options_for(harness, model)) + .cloned() + .unwrap_or_default(); + (model, reasoning, options) + } + + /// The one-way harness switch for an existing chat + /// (t3-orchestration-v2 ticket 24): resolve the new harness's default + /// picks — model, reasoning, options reset to the harness's remembered + /// defaults unless the user chose them — stamp the row optimistically, + /// then tell the engine, which writes the "Switched to <harness>" seam, + /// drops the old harness session, and arms the budgeted history for the + /// next send. A failed switch leaves the engine's row untouched; the + /// chats watch reverts the optimistic stamp. + fn switch_chat_harness(&mut self, chat_id: &str, harness: HarnessId, cx: &mut Context<Self>) { + let busy = { + let state = self.state.read(cx); + !state.harness_switchable(chat_id, chrono::Utc::now()) + // Already on it: nothing to seam (a re-pick of the current + // harness is a UI noop, not a switch). + || state + .selected_chat_row() + .and_then(|chat| chat.config.as_ref()) + .is_some_and(|config| config.harness == harness) + }; + if busy { + return; + } + // Model options reset to the new harness's defaults unless the user + // chose them (see [`Self::default_picks`]). + let (model, reasoning, model_options) = self.default_picks(harness); + let sandbox = self + .state + .read(cx) + .selected_chat_row() + .and_then(|chat| chat.config.as_ref()) + .map(|config| config.sandbox) + .unwrap_or(SandboxLevel::WorkspaceWrite); + let mut config = ChatConfig { + harness, + model, + reasoning, + model_options, + sandbox, + }; + self.clamp_config(&mut config, cx); self.state.update(cx, |state, cx| { - state.apply_chat_config(&chat_id, config.clone()); + state.apply_chat_config(chat_id, config.clone()); cx.notify(); }); - // The first send creates this row. Until then all choices stay local. - if self.state.read(cx).side_chat_unsaved() { - return; - } let Some(engine) = self.engine(cx) else { return; }; + let chat_id = chat_id.to_string(); self.mutate_task = Some(cx.spawn(async move |_, _| { let params = serde_json::json!({ - "op": "setChatConfig", "chatId": chat_id, - "config": config, + "harness": config.harness, + "model": config.model, + "reasoning": config.reasoning, + "modelOptions": config.model_options, }); - if let Err(err) = engine.call(methods::MUTATE, params).await { - tracing::warn!(error = %err, "setChatConfig mutate failed"); + if let Err(err) = engine.call(methods::SWITCH_HARNESS, params).await { + tracing::warn!(error = %err, "harness switch failed"); } })); } @@ -1986,7 +2098,8 @@ impl Pickers { /// The harness descriptors the picker rail offers, with the committed /// harness force-included even when it's outside the offered set (a /// dev session's mock harness, or one disabled after the chat existed). - /// Existing chats only offer their own harness. + /// Working chats only offer their own harness (locked); an idle one + /// offers every harness — a pick is a one-way switch. fn rail_descriptors(&self, cx: &App) -> Vec<HarnessDescriptor> { let Some(list) = self.harnesses.ready() else { return Vec::new(); @@ -5938,7 +6051,7 @@ mod tests { } #[gpui::test] - fn side_chat_favorites_switch_harness_locally_and_lock_after_creation( + fn side_chat_favorites_pick_before_the_first_send_and_switch_after_it( cx: &mut gpui::TestAppContext, ) { let runtime = tokio::runtime::Builder::new_current_thread() @@ -5987,13 +6100,28 @@ mod tests { cx.update(|cx| { // Forks are already persisted when opened, even before their first - // own message. Saved empty side chats must have the same lock. + // own message. A saved side chat that is idle between turns no + // longer locks the picker (ticket 24): picking another harness is + // a one-way switch — optimistic row stamp, no RPC without an + // engine (the favorites flow above never wired one to this + // state). let (_, fork) = side_chat_picker(false, cx); fork.update(cx, |pickers, cx| { - assert!(pickers.harness_locked(cx)); - assert_eq!(pickers.rail_descriptors(cx).len(), 1); + assert!(!pickers.harness_locked(cx)); + assert_eq!(pickers.rail_descriptors(cx).len(), 2); pickers.pick_harness(HarnessId::Codex, cx); - assert_eq!(pickers.resolved(cx).harness, Some(HarnessId::ClaudeCode)); + assert_eq!(pickers.resolved(cx).harness, Some(HarnessId::Codex)); + let config = pickers + .state + .read(cx) + .selected_chat_row() + .and_then(|chat| chat.config.clone()); + assert_eq!(config.map(|c| c.harness), Some(HarnessId::Codex)); + // Model options reset to the new harness's defaults (none + // remembered): the old harness's model never lingers. + assert_eq!(config.as_ref().and_then(|c| c.model.as_deref()), None); + assert!(config.as_ref().map(|c| c.model_options.is_empty()).unwrap_or(false)); + assert!(pickers.mutate_task.is_none(), "no engine — no RPC"); }); let (state, pickers) = side_chat_picker(true, cx); state.update(cx, |state, _| { @@ -6013,12 +6141,155 @@ mod tests { "failed creation remains editable" ); state.update(cx, |state, cx| state.side_chat_saved("side", cx)); + // Saved and idle: the pick stays a switch, not a lock. + pickers.update(cx, |pickers, cx| { + assert!(!pickers.harness_locked(cx)); + pickers.pick_harness(HarnessId::Codex, cx); + assert_eq!(pickers.resolved(cx).harness, Some(HarnessId::Codex)); + }); + }); + } + + /// An existing main chat with a session row: the lock follows the turn. + fn working_chat( + status: roboco_proto::SessionStatus, + cx: &mut App, + ) -> (Entity<AppState>, Entity<Pickers>) { + let state = cx.new(|_| AppState::new()); + let chat: roboco_proto::Chat = serde_json::from_value(serde_json::json!({ + "id": "main", "deviceId": "local", "cwd": "/tmp", "archived": false, + "createdAt": chrono::Utc::now(), + "config": { + "harness": "claude-code", "model": "saved-model", "sandbox": "read-only" + } + })) + .unwrap(); + state.update(cx, |state, _| { + state.chats.push(chat); + state.selected_chat = Some("main".into()); + state.apply_sessions(vec![roboco_proto::Session { + last_completed_turn: None, + chat_id: "main".into(), + device_id: "local".into(), + status, + started_at: None, + updated_at: chrono::Utc::now(), + }]); + }); + let pickers = cx.new(|cx| Pickers::new(state.clone(), cx)); + pickers.update(cx, |pickers, _| { + pickers.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::ClaudeCode, "Claude Code"), + descriptor(HarnessId::Codex, "Codex"), + ]); + }); + (state, pickers) + } + + #[gpui::test] + fn the_picker_unlocks_between_turns_and_locks_again_while_the_chat_works( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + cx.set_global(Theme::dark()); + // Working: locked — a turn is in flight. + let (_, pickers) = working_chat(roboco_proto::SessionStatus::Working, cx); pickers.update(cx, |pickers, cx| { assert!(pickers.harness_locked(cx)); + assert_eq!(pickers.rail_descriptors(cx).len(), 1); pickers.pick_harness(HarnessId::Codex, cx); assert_eq!(pickers.resolved(cx).harness, Some(HarnessId::ClaudeCode)); }); + // Parked on a question: still a turn in flight. + let (_, pickers) = working_chat(roboco_proto::SessionStatus::AwaitingInput, cx); + pickers.update(cx, |pickers, cx| { + assert!(pickers.harness_locked(cx)); + }); + // Idle: unlocked — the pick is a one-way switch. + let (state, pickers) = working_chat(roboco_proto::SessionStatus::Idle, cx); + pickers.update(cx, |pickers, cx| { + assert!(!pickers.harness_locked(cx)); + assert_eq!(pickers.rail_descriptors(cx).len(), 2); + pickers.pick_harness(HarnessId::Codex, cx); + assert_eq!(pickers.resolved(cx).harness, Some(HarnessId::Codex)); + let config = pickers + .state + .read(cx) + .selected_chat_row() + .and_then(|chat| chat.config.clone()) + .expect("row stamped"); + assert_eq!(config.harness, HarnessId::Codex); + assert_eq!(config.model, None); + assert!(config.model_options.is_empty()); + assert_eq!(config.sandbox, SandboxLevel::ReadOnly); + }); + // Errored between turns: switchable too — escaping a limit is a + // first-class use of the switch. + let (_, errored) = working_chat(roboco_proto::SessionStatus::Errored, cx); + errored.update(cx, |pickers, cx| { + assert!(!pickers.harness_locked(cx)); + }); + // Working again after the switch: locked once more. + state.update(cx, |state, _| { + state.apply_sessions(vec![roboco_proto::Session { + last_completed_turn: None, + chat_id: "main".into(), + device_id: "local".into(), + status: roboco_proto::SessionStatus::Working, + started_at: None, + updated_at: chrono::Utc::now(), + }]); + }); + pickers.update(cx, |pickers, cx| { + assert!(pickers.harness_locked(cx), "the picker re-locks while working"); + }); + }); + } + + #[gpui::test] + fn switching_an_idle_chat_tells_the_engine_the_resolved_picks( + cx: &mut gpui::TestAppContext, + ) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel::<String>(256); + let (_replies, inbound) = tokio::sync::mpsc::channel::<String>(256); + cx.update(|cx| { + cx.set_global(Theme::dark()); + let (state, pickers) = working_chat(roboco_proto::SessionStatus::Idle, cx); + state.update(cx, |state, _| { + state.set_test_engine(crate::state::EngineHandle::from_test_client( + roboco_rpc::RpcClient::new(out, inbound), + )); + }); + pickers.update(cx, |pickers, cx| { + // Remembered defaults for the new harness ride the switch; + // nothing remembered means the engine resets to the + // harness's own defaults. + pickers + .defaults + .remember_model(HarnessId::Codex, "codex-remembered".into(), "Codex".into()); + pickers + .defaults + .model_options_mut(HarnessId::Codex, "codex-remembered") + .insert("context".into(), serde_json::Value::String("1m".into())); + pickers.pick_harness(HarnessId::Codex, cx); + }); }); + cx.run_until_parked(); + let frame = requests.try_recv().expect("the switch reached the engine"); + assert!(frame.contains("SwitchHarness"), "{frame}"); + assert!(frame.contains("\"chatId\":\"main\""), "{frame}"); + assert!(frame.contains("\"harness\":\"codex\""), "{frame}"); + assert!(frame.contains("codex-remembered"), "{frame}"); + assert!(frame.contains("\"modelOptions\":{\"context\":\"1m\"}"), "{frame}"); + assert!( + requests.try_recv().is_err(), + "one pick is one switch — no setChatConfig chaser" + ); } #[gpui::test] diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index 4a8adcc7c4..0ec72f90e5 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -2152,6 +2152,21 @@ impl AppState { self.sessions.iter().find(|s| s.chat_id == chat_id) } + /// Whether the selected chat's harness can be switched in place right + /// now (t3-orchestration-v2 ticket 24): an existing chat whose turn is + /// not in flight — neither Working nor parked on a question — and whose + /// send is not pending. The picker stays locked while the chat works; + /// the engine refuses the switch for the same window. + pub(crate) fn harness_switchable(&self, chat_id: &str, now: DateTime<Utc>) -> bool { + if self.send_pending(chat_id, now) { + return false; + } + !matches!( + self.session_for(chat_id).map(|session| session.status), + Some(roboco_proto::SessionStatus::Working | roboco_proto::SessionStatus::AwaitingInput) + ) + } + /// Staleness-checked status dot for a chat row. A send in flight reads as /// Working (see [`Self::display_status_for`]). pub fn indicator_for(&self, chat_id: &str, now: DateTime<Utc>) -> Indicator { diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 7789628c61..490c02df5b 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -1051,6 +1051,14 @@ pub enum RowKind { source_chat_id: SharedString, source_title: SharedString, }, + /// The harness-switch seam (t3-orchestration-v2 ticket 24): a labeled + /// divider under which the chat runs on the harness the switch named; + /// everything above it belonged to the dropped provider session. + HarnessSwitchMarker { + /// The full divider line ("Switched to Codex") — the label the + /// harness renders as, resolved once when the row is built. + label: SharedString, + }, } /// A transcript row: stable id + content version (diff key) + block payload. @@ -1598,6 +1606,28 @@ pub fn rows_for_entry( compact_fold: None, }); } + MessagePart::HarnessSwitch { id: part_id, harness } => { + // The divider line, resolved once at row build: the + // seam is fixed, so the row's version only moves + // when the label does. + let label: SharedString = format!( + "Switched to {}", + crate::settings::accounts::provider_name(*harness) + ) + .into(); + rows.push(Row { + id: format!("{}#{}", entry.id, part_id).into(), + version: fnv1a(label.as_bytes()), + turn_start: false, + kind: RowKind::HarnessSwitchMarker { + label: label.clone(), + }, + entry_id: entry_id.clone(), + timestamp: None, + copy_text: None, + compact_fold: None, + }); + } // Tools and thoughts are grouped by the outer arms; // nothing reaches here. MessagePart::Tool { .. } | MessagePart::Reasoning { .. } => {} @@ -1692,7 +1722,10 @@ pub fn rows_for_entry( // change when streaming flips off (chips). if !streaming && let Some(last) = rows.last_mut() - && !matches!(last.kind, RowKind::ForkMarker { .. }) + && !matches!( + last.kind, + RowKind::ForkMarker { .. } | RowKind::HarnessSwitchMarker { .. } + ) { last.timestamp = Some(entry.created_at); last.copy_text = assistant_copy_text(entry); @@ -7100,6 +7133,9 @@ impl Transcript { } RowKind::ErrorChip { message } => error_chip(message.clone(), &theme), RowKind::ForkMarker { source_title, .. } => fork_marker(source_title.clone(), &theme), + RowKind::HarnessSwitchMarker { label } => { + harness_switch_marker(label.clone(), &theme) + } }; // Diagram fences this row just requested start rendering after layout. self.ensure_diagram_worker(cx); @@ -8453,6 +8489,35 @@ fn fork_marker(source_title: SharedString, theme: &Theme) -> AnyElement { .into_any_element() } +/// A quiet harness-switch seam (t3-orchestration-v2 ticket 24): the same +/// divider treatment as the fork seam, one line — the label arrives +/// pre-resolved ("Switched to Codex") so nothing here needs a catalog. No +/// message metadata lane. +fn harness_switch_marker(label: SharedString, theme: &Theme) -> AnyElement { + let rule = || div().flex_1().min_w_0().h(px(1.0)).bg(theme.border_strong); + div() + .py(px(14.0)) + .w_full() + .min_w_0() + .overflow_hidden() + .flex() + .items_center() + .gap(px(10.0)) + .child(rule()) + .child( + div() + .flex_none() + .min_w_0() + .truncate() + .text_size(crate::typography::ui_rems(13.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted) + .child(label), + ) + .child(rule()) + .into_any_element() +} + /// A passive one-line chip marking a question the agent asked — the /// interactive controls live in the composer (chat-view.tsx `InputChip`): /// 34px row, `rounded-[10px] border-white/[0.08] bg-white/[0.045] px-2 @@ -9800,6 +9865,71 @@ mod tests { } use roboco_doc::MessagePart; + /// The harness-switch seam (t3-orchestration-v2 ticket 24) renders as a + /// "Switched to <harness>" divider row — system entry, no timestamp or + /// copy actions, one row per seam. + #[gpui::test] + fn a_harness_switch_seam_renders_as_a_switched_to_divider( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + let state = cx.new(|_| AppState::new()); + let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); + state.update(cx, |state, _| { + state.selected_chat = Some("chat-a".into()); + state.transcript_replayed = true; + state.transcript = vec![ + assistant( + "before", + MessageStatus::Complete, + vec![text_part("before-t", "the old harness's reply")], + ), + SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + provider_turn: None, + id: "switch:1".into(), + role: MessageRole::System, + parts: vec![MessagePart::HarnessSwitch { + id: "switch:1".into(), + harness: roboco_proto::HarnessId::Codex, + }], + created_at: 0, + device_id: "dev".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + }, + assistant( + "after", + MessageStatus::Complete, + vec![text_part("after-t", "the new harness's reply")], + ), + ]; + state.transcript_revision += 1; + }); + transcript.update(cx, |this, cx| this.sync(cx)); + transcript.update(cx, |this, _| { + let rows: Vec<&Row> = this + .rows + .iter() + .filter(|row| matches!(row.kind, RowKind::HarnessSwitchMarker { .. })) + .collect(); + assert_eq!(rows.len(), 1, "one seam, one divider"); + let RowKind::HarnessSwitchMarker { label } = &rows[0].kind else { + unreachable!() + }; + assert_eq!(label.as_ref(), "Switched to Codex"); + // A seam is structure: no hover timestamp, no copy action. + assert!(rows[0].timestamp.is_none()); + assert!(rows[0].copy_text.is_none()); + }); + }); + } + fn with_tool_group_navigation( cx: &mut gpui::TestAppContext, run: impl FnOnce(Entity<AppState>, Entity<Transcript>, &mut gpui::App), diff --git a/web/packages/proto/src/generated/MessagePart.ts b/web/packages/proto/src/generated/MessagePart.ts index 3c61c204bd..a6aec6cc54 100644 --- a/web/packages/proto/src/generated/MessagePart.ts +++ b/web/packages/proto/src/generated/MessagePart.ts @@ -1,5 +1,6 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +import type { HarnessId } from "./HarnessId"; import type { NoticeSide } from "./NoticeSide"; import type { SubagentStatus } from "./SubagentStatus"; import type { ToolCall } from "./ToolCall"; @@ -68,7 +69,11 @@ subagentTail?: string | null, } | { "kind": "input", id: string, requestId: stri * without a registry lookup, and a later rename or delete of the * source does not rewrite history. */ -sourceTitle: string, } | { "kind": "completionNotice", id: string, +sourceTitle: string, } | { "kind": "harnessSwitch", id: string, +/** + * The harness the chat switched TO (wire name, e.g. "codex"). + */ +harness: HarnessId, } | { "kind": "completionNotice", id: string, /** * One row per side chat the notice reports (a single outcome when * one side chat finished; several when outcomes merged — ticket 13). From 04697ff3ea58116f2fe56df998ae08c2aa6456f5 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 18:30:48 +0700 Subject: [PATCH 26/96] fix(wire): emit NoticeSide the way ts-rs lays out undocumented fields --- web/packages/proto/src/generated/NoticeSide.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/packages/proto/src/generated/NoticeSide.ts b/web/packages/proto/src/generated/NoticeSide.ts index 1d36dbc2ae..3c8fd3856f 100644 --- a/web/packages/proto/src/generated/NoticeSide.ts +++ b/web/packages/proto/src/generated/NoticeSide.ts @@ -17,8 +17,7 @@ chatId: string, * The side chat's title as of the notice, so the transcript reads it * without a registry lookup (a later rename never rewrites history). */ -title: string, -outcome: NoticeOutcome, +title: string, outcome: NoticeOutcome, /** * The one-line detail for errored (reason), interrupted (restart * reason) and waiting-for-input (question summary) rows; absent From b09d33ed6cb40a6fb584a3f49de7449980628ab8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 18:49:01 +0700 Subject: [PATCH 27/96] fix(forks): review pass on the fork dialog and its overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review findings on ticket 22 (both axes): - Glossary (CONTEXT.md Harness: "one word everywhere, including UI labels"; ADR 0005 avoid-words): the dialog's new user-facing strings said "agent" and "session" where the glossary wants harness/chat — the section label, loading/empty states and the cross-harness note now use the glossary words, as do the affected comments. - A defaults-confirm now sends the classic fork payload: the dialog hands the RPC only the picks that differ from the source's own (confirm_overrides), so untouched defaults fork exactly as the classic button did — and a model pick still rides whenever the harness does, since a different harness needs it spelled out. - fork_offered -> fork_offered_harnesses (name said what it returned); ForkDialog gains source_harness/source_model so the config reads stop repeating the same five-line idiom. - Coverage from the spec pass: an engine test combining ticket 21's boundary with the harness override (copy cut at the reply, budgeted paste only of the prefix, no native fork), a confirm_overrides test, and a dialog-overlay smoke test that builds the card from a landed catalog with a cross-harness pick. Not run locally (CI runs them). --- crates/engine/tests/side_chats.rs | 115 +++++++++++++++ crates/ui/src/shell/fork_dialog.rs | 221 ++++++++++++++++++++++++----- 2 files changed, 297 insertions(+), 39 deletions(-) diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 8326f9ddde..b04fcc4896 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -2562,3 +2562,118 @@ async fn a_limited_source_chat_can_be_forked_into_another_harness() { ); core.shutdown().await; } + +/// "Fork from here" INTO another harness (tickets 21+22 combined): the +/// boundary cuts the copy, and the cross-harness override rides on top — +/// no native fork even though the boundary reply carries a provider turn +/// ref, and the budgeted history the first send carries never mentions +/// anything after the boundary. +#[tokio::test] +async fn forking_from_an_earlier_reply_into_another_harness_cuts_and_pastes() { + let dir = tempfile::tempdir().unwrap(); + let mock_requests = Arc::new(Mutex::new(Vec::new())); + let codex_requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(mock_requests.clone()))); + registry.register(Arc::new(CaptureOn { + id: HarnessId::Codex, + session: "codex-provider-session", + requests: codex_requests.clone(), + })); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + source + .doc() + .push_message(&message( + "u2", + MessageRole::User, + "And the code?", + MessageStatus::Complete, + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a2", + "the code is fine", + MessageStatus::Complete, + "turn-2", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + let fork = client + .call_as::<roboco_proto::Chat>( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side", + "sourceChatId": "main", + "boundary": "a1", + "harness": "codex", + "model": "gpt-6-codex", + }), + ) + .await + .unwrap(); + assert_eq!( + fork.config.map(|config| (config.harness, config.model)), + Some((HarnessId::Codex, Some("gpt-6-codex".into()))) + ); + let ids = |chat: &str| { + core.doc_host + .open(chat) + .unwrap() + .doc() + .read_entries() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect::<Vec<_>>() + }; + assert_eq!( + ids("side"), + vec!["u1".to_string(), "a1".to_string(), "fork:side".to_string()], + "the copy stops at the boundary, inclusive" + ); + // The first send: budgeted history cut at the boundary, no native fork, + // no resume — and nothing from after the boundary rides along. + let request = queued_side_turn(&core, &codex_requests, "side", "What did I ask?").await; + assert_eq!(request.provider_fork, None); + assert_eq!(request.resume, None); + assert!(request.prompt.contains("PINEAPPLE"), "{}", request.prompt); + assert!( + !request.prompt.contains("the code is fine"), + "{}", + request.prompt + ); + assert!(request.prompt.ends_with("What did I ask?"), "{}", request.prompt); + core.shutdown().await; +} diff --git a/crates/ui/src/shell/fork_dialog.rs b/crates/ui/src/shell/fork_dialog.rs index 95bcc2d5ca..32485dafaf 100644 --- a/crates/ui/src/shell/fork_dialog.rs +++ b/crates/ui/src/shell/fork_dialog.rs @@ -87,7 +87,7 @@ pub(super) fn fork_params( /// harness `fallback` names (the source's own, or the current pick) whenever /// the catalog carries it — a chat can outlive its harness's enablement, and /// the default must stay pickable so "fork as-is" is always one click. -pub(super) fn fork_offered( +pub(super) fn fork_offered_harnesses( catalog: &[HarnessDescriptor], fallback: Option<HarnessId>, ) -> Vec<HarnessDescriptor> { @@ -101,6 +101,27 @@ pub(super) fn fork_offered( offered } +/// The overrides a dialog confirm sends (t3-orchestration-v2 ticket 22): +/// only the picks that differ from the source's own. A confirm of the +/// untouched defaults is therefore byte-for-byte the classic fork request; +/// a model pick rides along whenever the harness does (a different harness +/// needs the model spelled out, even an id that happens to match). +pub(super) fn confirm_overrides( + source: &roboco_proto::Chat, + harness: Option<HarnessId>, + model: Option<&str>, +) -> (Option<HarnessId>, Option<String>) { + let source_harness = source.config.as_ref().map(|config| config.harness); + let source_model = source.config.as_ref().and_then(|config| config.model.clone()); + let harness = harness.filter(|harness| source_harness != Some(*harness)); + let model = model + .map(str::trim) + .filter(|model| !model.is_empty()) + .map(str::to_owned) + .filter(|model| harness.is_some() || source_model.as_deref() != Some(model.as_str())); + (harness, model) +} + impl Shell { /// Open the fork dialog for `source`, hanging the copy under /// `parent_id`, from `boundary` (ticket 21's "Fork from here") or the @@ -117,7 +138,10 @@ impl Shell { return; } let harness = source.config.as_ref().map(|config| config.harness); - let model = source.config.as_ref().and_then(|config| config.model.clone()); + let model = source + .config + .as_ref() + .and_then(|config| config.model.clone()); self.fork_dialog = Some(ForkDialog { source, parent_id, @@ -155,16 +179,8 @@ impl Shell { if dialog.harness == Some(harness) { return; } - let source_harness = dialog - .source - .config - .as_ref() - .map(|config| config.harness); - let source_model = dialog - .source - .config - .as_ref() - .and_then(|config| config.model.clone()); + let source_harness = dialog.source_harness(); + let source_model = dialog.source_model(); dialog.harness = Some(harness); dialog.model = (source_harness == Some(harness)) .then_some(source_model) @@ -183,7 +199,9 @@ impl Shell { } /// Confirm: hand the dialog's fork — source, parent, boundary and the - /// chosen harness/model — to the fork RPC flow. + /// chosen harness/model — to the fork RPC flow. Only picks that differ + /// from the source's ride the wire, so untouched defaults fork exactly + /// as the classic button always did. pub(super) fn confirm_fork(&mut self, cx: &mut Context<Self>) { let Some(dialog) = self.fork_dialog.take() else { return; @@ -196,6 +214,7 @@ impl Shell { model, .. } = dialog; + let (harness, model) = confirm_overrides(&source, harness, model.as_deref()); self.fork_chat(source, parent_id, boundary, harness, model, cx); } @@ -211,8 +230,8 @@ impl Shell { /// The catalog load's `targetDeviceId`: the fork runs on the SOURCE's /// device (the RPC refuses a source hosted elsewhere), so its installed - /// agents are the ones that matter. `None` for a locally-hosted source — - /// the connected engine serves it directly. + /// harnesses are the ones that matter. `None` for a locally-hosted + /// source — the connected engine serves it directly. fn fork_catalog_target(&self, cx: &App) -> Option<String> { let local = self.state.read(cx).local_device_id.as_deref()?; let dialog = self.fork_dialog.as_ref()?; @@ -273,10 +292,7 @@ impl Shell { // A source with no config keeps no default of its own: the // first offered harness becomes the pick once the catalog // lands, mirroring the composer's picker. - let first = dialog - .fork_offered() - .first() - .map(|descriptor| descriptor.id); + let first = dialog.offered().first().map(|descriptor| descriptor.id); if dialog.harness.is_none() && let Some(harness) = first { @@ -354,9 +370,9 @@ impl Shell { ) -> Option<AnyElement> { let dialog = self.fork_dialog.as_ref()?; let theme = Theme::of(cx).for_popup(); - let source_harness = dialog.source.config.as_ref().map(|config| config.harness); + let source_harness = dialog.source_harness(); let catalog: Vec<HarnessDescriptor> = dialog.harnesses.ready().cloned().unwrap_or_default(); - let offered = fork_offered(&catalog, source_harness.or(dialog.harness)); + let offered = fork_offered_harnesses(&catalog, source_harness.or(dialog.harness)); let harness_loading = matches!(dialog.harnesses, Loadable::Loading); let harness_error = dialog.harnesses.error().map(str::to_owned); let selected_harness = dialog.harness; @@ -391,7 +407,7 @@ impl Shell { .child(popover::dialog_title(&theme, title)) .child(div().mt(px(6.0)).child(popover::dialog_body(&theme, body))); - // ── Agent: one chip per offered harness; the pick rides the border + // ── Harness: one chip per offered harness; the pick rides the border // and fill, like the composer's rail tabs. let label_style = |el: gpui::Div| { el.mt(px(14.0)) @@ -400,7 +416,7 @@ impl Shell { .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(theme.text_muted) }; - card = card.child(label_style(div()).child(SharedString::from("AGENT"))); + card = card.child(label_style(div()).child(SharedString::from("HARNESS"))); let mut chips = div().flex().flex_row().flex_wrap().gap(px(6.0)); for (ix, descriptor) in offered.iter().enumerate() { let harness = descriptor.id; @@ -448,11 +464,11 @@ impl Shell { } if offered.is_empty() { let text = if let Some(error) = harness_error { - SharedString::from(format!("Couldn't load the agents: {error}")) + SharedString::from(format!("Couldn't load the harnesses: {error}")) } else if harness_loading { - SharedString::from("Loading this device's agents…") + SharedString::from("Loading this device's harnesses…") } else { - SharedString::from("No agents are available on this device.") + SharedString::from("No harnesses are available on this device.") }; chips = chips.child( div() @@ -486,7 +502,7 @@ impl Shell { } else if models_loading { SharedString::from("Loading models…") } else { - SharedString::from("This agent lists no models.") + SharedString::from("This harness lists no models.") }; list = list.child( div() @@ -576,8 +592,8 @@ impl Shell { .line_height(px(17.0)) .text_color(theme.text_muted) .child(SharedString::from( - "A different agent starts a fresh session and receives a \ - size-limited transcript of this conversation, not the full history.", + "A different harness starts fresh and receives a \ + size-limited transcript of this chat, not its full history.", )), ); } @@ -609,16 +625,28 @@ impl Shell { } impl ForkDialog { - /// The harnesses this dialog offers — `fork_offered` over its catalog - /// with the source's harness as the guaranteed member. - fn fork_offered(&self) -> Vec<HarnessDescriptor> { - let source_harness = self.source.config.as_ref().map(|config| config.harness); + /// The harness the source chat runs on per its row config. + fn source_harness(&self) -> Option<HarnessId> { + self.source.config.as_ref().map(|config| config.harness) + } + + /// The source's model id, when its row carries one. + fn source_model(&self) -> Option<String> { + self.source + .config + .as_ref() + .and_then(|config| config.model.clone()) + } + + /// The harnesses this dialog offers — `fork_offered_harnesses` over its + /// catalog with the source's harness as the guaranteed member. + fn offered(&self) -> Vec<HarnessDescriptor> { let catalog = self .harnesses .ready() .map(|list| list.as_slice()) .unwrap_or_default(); - fork_offered(catalog, source_harness.or(self.harness)) + fork_offered_harnesses(catalog, self.source_harness().or(self.harness)) } } @@ -708,6 +736,54 @@ mod tests { assert!(blank.get("model").is_none()); } + /// The overrides a confirm sends: only the picks that differ from the + /// source's own, so an untouched dialog produces the classic fork + /// request — and a model rides along whenever the harness does. + #[test] + fn confirm_overrides_send_only_what_differs() { + let source = chat_row( + "src", + Some(serde_json::json!({ + "harness": "codex", + "model": "gpt-6-codex", + "sandbox": "workspace-write", + })), + ); + // Untouched defaults: byte-for-byte the classic fork. + assert_eq!( + confirm_overrides(&source, Some(HarnessId::Codex), Some("gpt-6-codex")), + (None, None) + ); + // Another harness: the harness rides, and a model equal to the + // source's still rides with it (the new harness needs it spelled + // out). + assert_eq!( + confirm_overrides(&source, Some(HarnessId::Pi), Some("gpt-6-codex")), + (Some(HarnessId::Pi), Some("gpt-6-codex".into())) + ); + // Another harness, no model picked. + assert_eq!( + confirm_overrides(&source, Some(HarnessId::Pi), None), + (Some(HarnessId::Pi), None) + ); + // Same harness, another model. + assert_eq!( + confirm_overrides(&source, Some(HarnessId::Codex), Some("gpt-6-mini")), + (None, Some("gpt-6-mini".into())) + ); + // A blank model pick counts as none. + assert_eq!( + confirm_overrides(&source, Some(HarnessId::Codex), Some(" ")), + (None, None) + ); + // A config-less source: any resolved pick is an override. + let bare = chat_row("bare", None); + assert_eq!( + confirm_overrides(&bare, Some(HarnessId::Codex), None), + (Some(HarnessId::Codex), None) + ); + } + /// The offered list: the composer's offered set, with the source's own /// harness appended when the catalog carries it but it is not offered — /// the default must stay pickable. @@ -729,25 +805,25 @@ mod tests { // Present but not installed: offered_harnesses drops it. descriptor(HarnessId::ClaudeCode, "Claude Code", false), ]; - let offered = fork_offered(&catalog, Some(HarnessId::Codex)); + let offered = fork_offered_harnesses(&catalog, Some(HarnessId::Codex)); assert_eq!( offered.iter().map(|d| d.id).collect::<Vec<_>>(), vec![HarnessId::Codex, HarnessId::Pi] ); // The source's harness is not offered (its CLI is gone): it still // rides the list, behind the offered ones. - let offered = fork_offered(&catalog, Some(HarnessId::ClaudeCode)); + let offered = fork_offered_harnesses(&catalog, Some(HarnessId::ClaudeCode)); assert_eq!( offered.iter().map(|d| d.id).collect::<Vec<_>>(), vec![HarnessId::Codex, HarnessId::Pi, HarnessId::ClaudeCode] ); // Not in the catalog at all: nothing to append. - let offered = fork_offered(&catalog, Some(HarnessId::Grok)); + let offered = fork_offered_harnesses(&catalog, Some(HarnessId::Grok)); assert_eq!( offered.iter().map(|d| d.id).collect::<Vec<_>>(), vec![HarnessId::Codex, HarnessId::Pi] ); - assert!(fork_offered(&[], Some(HarnessId::Pi)).is_empty()); + assert!(fork_offered_harnesses(&[], Some(HarnessId::Pi)).is_empty()); } /// The dialog opens on both entry points with the source's own harness @@ -817,7 +893,7 @@ mod tests { dialog.harnesses = Loadable::Ready(catalog); // The default-resolution the load completion applies — // exercised through the dialog's own offered list here. - let first = dialog.fork_offered().first().map(|d| d.id); + let first = dialog.offered().first().map(|d| d.id); if dialog.harness.is_none() && let Some(harness) = first { dialog.harness = Some(harness); } @@ -893,4 +969,71 @@ mod tests { }) .unwrap(); } + + /// The overlay renders while the dialog is open — harness chips, model + /// rows, the cross-harness note and the button pair all build against a + /// landed catalog and model list — and stops once it closes. + #[gpui::test] + fn the_fork_dialog_overlay_builds_from_a_landed_catalog(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let source = chat_row( + "src", + Some(serde_json::json!({ + "harness": "codex", + "model": "gpt-6-codex", + "sandbox": "workspace-write", + })), + ); + window + .update(cx, |shell, _, cx| { + shell.open_fork_dialog(source, "src".into(), Some("a1".into()), cx); + let viewport = gpui::size(px(800.0), px(600.0)); + // Nothing has loaded yet: the overlay still renders (its + // loading states). + assert!(shell.render_fork_dialog(viewport, cx).is_some()); + // A landed catalog and model list: chips, rows and the + // cross-harness note after picking another harness. + { + let dialog = shell.fork_dialog.as_mut().unwrap(); + dialog.harnesses = Loadable::Ready(vec![ + HarnessDescriptor { + id: HarnessId::Codex, + name: "Codex".into(), + supports_steering: true, + steering_mode: roboco_proto::SteeringMode::StepBoundary, + reasoning_levels: vec![], + installed: true, + can_install: false, + enabled: Some(true), + }, + HarnessDescriptor { + id: HarnessId::Pi, + name: "Pi".into(), + supports_steering: true, + steering_mode: roboco_proto::SteeringMode::StepBoundary, + reasoning_levels: vec![], + installed: true, + can_install: false, + enabled: Some(true), + }, + ]); + dialog.models.insert( + HarnessId::Pi, + Loadable::Ready(vec![Model { + id: "glm-5.3".into(), + label: "GLM 5.3".into(), + description: Some("the flagship".into()), + reasoning_levels: vec![], + options: vec![], + }]), + ); + } + shell.pick_fork_harness(HarnessId::Pi, cx); + assert!(shell.render_fork_dialog(viewport, cx).is_some()); + shell.close_fork_dialog(cx); + assert!(shell.render_fork_dialog(viewport, cx).is_none()); + }) + .unwrap(); + } } From 347d3cb5bba93279ed469ea27c8319b417610fa8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 19:25:11 +0700 Subject: [PATCH 28/96] fix(engine): reach the workspace through the engine inner on harness switch --- crates/engine/src/sessions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index d74e526b9b..c82175bd2b 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1204,7 +1204,7 @@ impl SessionsEngine { chat_id: &str, config: roboco_proto::ChatConfig, ) -> Result<(), EngineError> { - let Some(ws) = self.workspace() else { + let Some(ws) = self.inner.workspace() else { return Err(EngineError::Other("workspace unavailable".into())); }; let chat = ws From 02e4b5c0137b94e869a4a0d34c02467855c122fe Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 20:17:32 +0700 Subject: [PATCH 29/96] fix(ui): settle the desktop compile errors from the account, fork and switch merges --- crates/ui/src/account_usage.rs | 4 +++- crates/ui/src/pickers.rs | 2 +- crates/ui/src/transcript.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui/src/account_usage.rs b/crates/ui/src/account_usage.rs index 38076b060f..d08da48584 100644 --- a/crates/ui/src/account_usage.rs +++ b/crates/ui/src/account_usage.rs @@ -487,7 +487,7 @@ impl AccountUsage { fn cancel_sign_in(&mut self, cx: &mut Context<Self>) { let login_id = match &self.sign_in { Some(SignInFlow::Browser { start, .. }) - | Some(SignInFlow::PasteCode { start, .. }) => start.login_id.clone(), + | Some(SignInFlow::PasteCode { start, .. }) => Some(start.login_id.clone()), _ => None, }; self.sign_in = None; @@ -918,7 +918,9 @@ impl Render for AccountUsage { .children(context) .when_some(confirm, |el, confirm| el.child(confirm)) } +} +impl AccountUsage { /// The "switch to a signed-out account" confirmation, over the popover — /// the same warning Settings → Accounts asks with. fn render_switch_confirm( diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index fd04ab5c5a..98b1429def 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -6116,7 +6116,7 @@ mod tests { .read(cx) .selected_chat_row() .and_then(|chat| chat.config.clone()); - assert_eq!(config.map(|c| c.harness), Some(HarnessId::Codex)); + assert_eq!(config.as_ref().map(|c| c.harness), Some(HarnessId::Codex)); // Model options reset to the new harness's defaults (none // remembered): the old harness's model never lingers. assert_eq!(config.as_ref().and_then(|c| c.model.as_deref()), None); diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index fd4ec8397d..23e39ded49 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -7217,7 +7217,7 @@ impl Transcript { .on_click(cx.listener(move |_, _, _, cx| { cx.emit(TranscriptEvent::ForkFromEntry { chat_id: chat_id.clone(), - entry_id: entry_id.clone(), + entry_id: entry_id.to_string(), }); })) .tooltip(crate::settings::widgets::text_tooltip( From 0f09fe7d69dcab94bb2b1ad2db2033ad916dc2dd Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 19:42:21 +0700 Subject: [PATCH 30/96] feat(mcp): teach notice-driven orchestration in the instructions Orchestration v2 ticket 16 (.scratch/t3-orchestration-v2/issues/16): the Roboco MCP server's guidance now matches the completion-notice machinery landed in tickets 12/13 instead of the blocking waits. - The server instructions (initialize) rewrite the delegation flow: launch side chats with wait: false (create_chats / send_messages), end the turn, and receive a completion notice when a side chat you created finishes, fails, stops, is interrupted, or needs input on a turn you started or steered; collect results with read_chat, answer waiting side chats with respond_to_input, and keep wait_for_turn / wait: true for quick synchronous asks. notify: false is documented for fire-and-forget children. Side chats are told their final reply is delivered to their parent automatically and not to report or acknowledge with send_message. The conditional worktree rule rides along: a side chat that will edit files, commit, or switch branches in a git repo gets its own worktree (git worktree add, passed as cwd) or reuses one handed over via cwd, never switching branches in the main checkout; read-only side chats, non-git folders, and explicit work-in-place instructions are exempt. - Tool descriptions follow: create_chat/create_chats/send_messages/ wait_for_turn/read_chat point at the notice flow, and the cwd schema carries the worktree rule. send_message's auto mode no longer claims a live mid-turn mailbox for every harness: it starts idle or errored chats, and a message into a working chat is delivered without interrupting the run - mid-turn where the harness accepts prompts mid-run, otherwise held and delivered when the turn ends. - The attribution line on a parent's messages to its own notifying side chat (agentOrigin names the sender and did not opt out) now tells that side chat its final reply is delivered automatically instead of inviting a send_message reply; every other chat-to-chat message keeps today's line. The desktop transcript's attribution display parses both headers so the human bubble stays concise. - MCP tool tests assert the key instruction text, the description changes, and the attribution variants (notifying, opted-out, third-party creator, ordinary chat); a transcript test pins both display forms. docs/mcp.md follows the new flow. Review pass (two-axis code review): the outcome list gains "is interrupted" so the instructions name every outcome the engine's completion notices can carry (restart-lost runs included), in the instructions, their test, and docs/mcp.md. Not run locally (CI runs them). --- crates/roboco-mcp/src/jsonrpc.rs | 56 +++++--- crates/roboco-mcp/src/tools.rs | 212 +++++++++++++++++++++++++++++-- crates/ui/src/transcript.rs | 52 +++++++- docs/mcp.md | 28 ++-- 4 files changed, 305 insertions(+), 43 deletions(-) diff --git a/crates/roboco-mcp/src/jsonrpc.rs b/crates/roboco-mcp/src/jsonrpc.rs index cf81d10a58..a30d82a9d2 100644 --- a/crates/roboco-mcp/src/jsonrpc.rs +++ b/crates/roboco-mcp/src/jsonrpc.rs @@ -28,21 +28,47 @@ model, read transcripts, and send messages between chats.\n\ \n\ Chats are referenced by full id, a unique id prefix, or an exact title. Use \ `whoami` to learn which chat you are speaking from; messages you send are \ -attributed to it and a chat cannot message itself. For request/response \ -with a single chat, `send_message` with `wait: true` returns its reply. \ -For parallel delegation, use `create_chats` with a prompt for each chat or \ -`send_messages` for existing chats. These batch tools launch requests concurrently \ -even if your harness executes tool calls sequentially. Alternatively, launch ALL \ -chats/messages with `wait: false` first, then use `wait_for_turn` to collect replies. \ -Do not wait for one worker before launching the next independent worker. \ -Use `kind: chat` in `create_chat` or each `create_chats` request for a standalone \ -session visible in Sessions; it cannot have a parent. Use `kind: side` for a child \ -with an explicit parent or your origin chat. Omitted kind keeps legacy defaults: \ -side with an origin/parent, standalone otherwise. Side chats cannot create chats. \ -The engine is device-local: `list_devices` reports this engine's device, and a \ -`device` argument must resolve to it. A project fixes its device; with both \ -project and device it must belong to that device. Use listed ids when names or \ -paths repeat. Without project or device, creation uses the local engine. \ +attributed to it and a chat cannot message itself. The engine is device-local: \ +`list_devices` reports this engine's device, and a `device` argument must \ +resolve to it. A project fixes its device; with both project and device it must \ +belong to that device. Use listed ids when names or paths repeat. Without \ +project or device, creation uses the local engine.\n\ +\n\ +Delegating work: launch side chats with `wait: false` — `create_chats` with a \ +prompt per request for new ones, `send_messages` for existing chats — then end \ +your turn. Do not block on the launches, and do not wait for one worker before \ +launching the next independent worker (the batch tools run requests \ +concurrently even when your harness executes tool calls sequentially). When a \ +side chat you created finishes, fails, stops, is interrupted, or needs input \ +on a turn you started or steered, the engine sends you a completion notice \ +naming that chat and its outcome: immediately when you are idle, after your \ +current turn ends when you are busy, merged into one message when several \ +land together. Collect the result with `read_chat`; answer a waiting side \ +chat with `respond_to_input`. \ +Side chats you create notify you by default — pass `notify: false` at creation \ +for fire-and-forget children that should not wake you. `wait_for_turn`, and \ +`wait: true` on `send_message` for a single request/response, remain for quick \ +synchronous asks; they block your turn, so never use them to babysit \ +long-running work.\n\ +\n\ +If you are a side chat created by another chat: your final reply is delivered \ +to your parent automatically when your turn ends. Do not report progress or \ +acknowledge messages with `send_message`; answer the task in your reply and \ +end your turn.\n\ +\n\ +A side chat that will edit files, commit, or switch branches in a git repo \ +gets its own worktree — create one with `git worktree add` and pass its path \ +as the `cwd` argument — or reuses an existing worktree handed over via `cwd`; \ +it never switches branches in the main checkout. Read-only side chats \ +(research, review, audit, questions), non-git folders, and an explicit user \ +instruction to work in place are exempt.\n\ +\n\ +Use `kind: chat` in `create_chat` or each `create_chats` request for a \ +standalone session visible in Sessions; it cannot have a parent. Use `kind: \ +side` for a child with an explicit parent or your origin chat. Omitted kind \ +keeps legacy defaults: side with an origin/parent, standalone otherwise. Side \ +chats cannot create chats.\n\ +\n\ If a chat is `awaitingInput`, answer it with `respond_to_input`."; const PARSE_ERROR: i64 = -32700; diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index d25f05af58..b869ccc724 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -121,7 +121,7 @@ fn catalog() -> Vec<ToolDef> { }, ToolDef { name: "create_chat", - description: "Create a standalone session visible in Sessions with kind chat, or a child with kind side. Omitted kind preserves the default: side with an origin/parent, standalone otherwise. A side chat cannot create chats. Select a project and/or device; the harness and model catalogs are validated before writing. Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and wait only after all chats have been started.", + description: "Create a standalone session visible in Sessions with kind chat, or a child with kind side. Omitted kind preserves the default: side with an origin/parent, standalone otherwise. A side chat cannot create chats. Select a project and/or device; the harness and model catalogs are validated before writing. Optionally send a first prompt and wait for the reply. Returns the new chat id. For parallel delegation use create_chats, or leave wait=false on every launch and end your turn: side chats you create send you a completion notice when they finish, fail, stop, or need input (pass notify=false for fire-and-forget children).", input_schema: json!({ "type": "object", "properties": { @@ -135,7 +135,7 @@ fn catalog() -> Vec<ToolDef> { "sandbox": { "type": "string", "enum": ["read-only", "workspace-write", "danger-full-access"], "default": "workspace-write" }, "title": { "type": "string", "description": "Sidebar title. Otherwise the engine titles it from the first exchange." }, "branch": { "type": "string", "description": "Branch label to record on the chat." }, - "cwd": { "type": "string", "description": "Working directory override (an existing worktree path). Defaults to the project folder." }, + "cwd": { "type": "string", "description": "Working directory override: an existing worktree path to run in. Defaults to the project folder. Give a side chat that will edit files, commit, or switch branches in a git repo its own worktree (git worktree add) or an existing one here; it never switches branches in the main checkout." }, "prompt": { "type": "string", "description": "First message to send right away." }, "wait": { "type": "boolean", "default": false, "description": "With prompt: block until the first turn finishes and return the reply." }, "notify": { "type": "boolean", "default": true, "description": "For side chats you create: whether you want a completion notice when the chat finishes, fails, stops or needs input (default on). Pass false for fire-and-forget children." }, @@ -145,7 +145,7 @@ fn catalog() -> Vec<ToolDef> { }, ToolDef { name: "read_chat", - description: "Read a chat's transcript as plain messages (newest window by default). Tool calls are summarized one per line.", + description: "Read a chat's transcript as plain messages (newest window by default). Tool calls are summarized one per line. Use it to collect a side chat's result after a completion notice names it.", input_schema: chat_key_schema(json!({ "limit": { "type": "integer", "minimum": 1, "maximum": 500, "default": 40, "description": "How many messages, counted from the newest." }, "offset": { "type": "integer", "minimum": 0, "default": 0, "description": "Skip this many newest messages (paging backwards)." }, @@ -155,7 +155,7 @@ fn catalog() -> Vec<ToolDef> { }, ToolDef { name: "send_message", - description: "Send a message to a chat. Messages are attributed to your chat. mode 'auto' starts a turn when the chat is idle, steers a running turn through its live mailbox (at the next supported input boundary without interrupting the agent). Use mode 'queue' only to explicitly hold a message for later. With wait=true, blocks until the turn finishes and returns the assistant's reply. For parallel work use send_messages, or send to all chats with wait=false before waiting.", + description: "Send a message to a chat. Messages are attributed to your chat. mode 'auto' starts a turn when the chat is idle or errored; when the chat is working, the message is delivered without interrupting the run — mid-turn where the harness accepts prompts mid-run, otherwise held and delivered when the turn ends. Use mode 'queue' only to explicitly hold a message for later. With wait=true, blocks until the turn finishes and returns the assistant's reply. For parallel work use send_messages, or send to all chats with wait=false and end your turn.", input_schema: chat_key_schema(json!({ "text": { "type": "string" }, "mode": { "type": "string", "enum": ["auto", "run", "steer", "queue"], "default": "auto" }, @@ -165,7 +165,7 @@ fn catalog() -> Vec<ToolDef> { }, ToolDef { name: "wait_for_turn", - description: "Block until a chat is no longer working: returns completed, awaitingInput (answer with respond_to_input), errored, or timedOut, with the newest assistant message.", + description: "Block until a chat is no longer working: returns completed, awaitingInput (answer with respond_to_input), errored, or timedOut, with the newest assistant message. It blocks your turn — keep it for quick synchronous asks; side chats you create notify you when they settle, so do not babysit them with waits.", input_schema: chat_key_schema(json!({ "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 600 } })), @@ -205,12 +205,12 @@ fn catalog() -> Vec<ToolDef> { ( "create_chats", "create_chat", - "Create multiple standalone or side chats concurrently (kind per request). Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false. Results preserve request order and include per-request errors; successful requests are not rolled back.", + "Create multiple standalone or side chats concurrently (kind per request). Put each chat's prompt in its request to start all work together. Prefer this for parallel delegation, including harnesses that execute tool calls sequentially. Each request has create_chat arguments; wait defaults to false, so launch every chat and end your turn — your side chats send you completion notices. If wait=true, requests still run concurrently. Results preserve request order and include per-request errors; successful requests are not rolled back.", ), ( "send_messages", "send_message", - "Send messages to multiple independent chats concurrently. Prefer this to delegate parallel work to existing chats. Each request has send_message arguments; wait defaults to false. If wait=true, requests still run concurrently. Results preserve request order and include per-request errors; successful sends are not rolled back. Do not include dependent messages to the same chat.", + "Send messages to multiple independent chats concurrently. Prefer this to delegate parallel work to existing chats. Each request has send_message arguments; wait defaults to false, so launch every send and end your turn — the side chats you created notify you when they settle. If wait=true, requests still run concurrently. Results preserve request order and include per-request errors; successful sends are not rolled back. Do not include dependent messages to the same chat.", ), ] { let item_schema = tools @@ -1067,7 +1067,12 @@ impl Tools { /// Prefix the sender's identity when this server speaks for a chat, so /// the receiving agent (and the human reading that transcript) can tell - /// an agent-to-agent message from a typed one. + /// an agent-to-agent message from a typed one. A message to this chat's + /// own notifying side chat teaches the notice flow instead of inviting a + /// reply: that side chat's final reply is already delivered to its + /// creator, and hand-sent reports and acknowledgements only start empty + /// turns (orchestration v2). Every other chat-to-chat message keeps the + /// reply line, so third-party and opted-out targets can still answer. async fn attribute(&self, target: &Chat, text: &str) -> String { let Some(origin_id) = self.roboco.origin().chat_id.as_deref() else { return text.to_owned(); @@ -1085,10 +1090,21 @@ impl Tools { } _ => short(origin_id).to_owned(), }; - format!( - "[Message from Roboco chat {label}. Reply to it with the Roboco `send_message` tool, chat {}.]\n\n{text}", - short(origin_id) - ) + // The same rule the engine's completion notices key on: only the + // creator's messages to a side chat that did not opt out. + let notifying = target.agent_origin.as_ref().is_some_and(|origin| { + origin.chat_id == origin_id && origin.notify + }); + if notifying { + format!( + "[Message from Roboco chat {label}. You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.]\n\n{text}" + ) + } else { + format!( + "[Message from Roboco chat {label}. Reply to it with the Roboco `send_message` tool, chat {}.]\n\n{text}", + short(origin_id) + ) + } } /// Pick and perform the delivery the composer would. Run and steer @@ -1324,6 +1340,9 @@ mod tests { writes: Mutex<Vec<(String, Value)>>, dispatch_barrier: Option<tokio::sync::Barrier>, beta_parent: Option<String>, + /// `Chat.agentOrigin` on the Beta row: which chat's agent created it + /// and whether it wants completion notices. + beta_agent_origin: Option<roboco_proto::AgentOrigin>, catalog_error: Option<&'static str>, } @@ -1365,6 +1384,7 @@ mod tests { { "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", "parentChatId": self.beta_parent, + "agentOrigin": self.beta_agent_origin, "archived": false, "spaceId": "space-1", "createdAt": "2026-09-02T00:00:00Z" } @@ -1490,6 +1510,107 @@ mod tests { assert_eq!(params["command"]["request"]["model"], "opus"); } + #[tokio::test] + async fn attribution_line_varies_with_the_target_chat() { + // Beta as an agent-created side chat: who created it and whether it + // notifies are the two halves of the new attribution rule. + let world = |creator: &str, notify: bool| { + Arc::new(World { + beta_parent: Some(creator.into()), + beta_agent_origin: Some(roboco_proto::AgentOrigin { + chat_id: creator.into(), + notify, + }), + ..Default::default() + }) + }; + let prompt_of = |world: &Arc<World>| { + let writes = world.writes.lock().unwrap(); + writes[0].1["command"]["request"]["prompt"] + .as_str() + .unwrap() + .to_owned() + }; + let origin = Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }; + + // The parent's own notifying side chat: the line teaches the notice + // flow and no longer invites a reply. + let notifying = world("chat-alpha-1", true); + let tools = tools(notifying.clone(), origin.clone()); + tools + .call("send_message", json!({ "chat": "Beta", "text": "the task" })) + .await + .unwrap(); + let prompt = prompt_of(¬ifying); + assert!( + prompt.starts_with("[Message from Roboco chat Alpha (chat-alp)."), + "{prompt}" + ); + assert!( + prompt.contains("your final reply is delivered to it automatically"), + "{prompt}" + ); + assert!(prompt.contains("Do not report or acknowledge"), "{prompt}"); + assert!(!prompt.contains("Reply to it with"), "{prompt}"); + assert!(prompt.ends_with("the task"), "{prompt}"); + + // A side chat that opted out of notices keeps today's reply line. + let opted_out = world("chat-alpha-1", false); + let tools = tools(opted_out.clone(), origin.clone()); + tools + .call("send_message", json!({ "chat": "Beta", "text": "the task" })) + .await + .unwrap(); + let prompt = prompt_of(&opted_out); + assert!( + prompt.contains( + "Reply to it with the Roboco `send_message` tool, chat chat-alp." + ), + "{prompt}" + ); + assert!(!prompt.contains("delivered to it automatically"), "{prompt}"); + + // A side chat created by a third chat keeps the reply line too: only + // the creator's messages teach the notice flow. + let foreign = world("chat-gamma-9", true); + let tools = tools(foreign.clone(), origin); + tools + .call("send_message", json!({ "chat": "Beta", "text": "the task" })) + .await + .unwrap(); + let prompt = prompt_of(&foreign); + assert!( + prompt.contains( + "Reply to it with the Roboco `send_message` tool, chat chat-alp." + ), + "{prompt}" + ); + + // An ordinary chat (no agent origin) keeps today's line. + let plain = Arc::new(World::default()); + let tools = tools( + plain.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + tools + .call("send_message", json!({ "chat": "Alpha", "text": "the task" })) + .await + .unwrap(); + let prompt = prompt_of(&plain); + assert!( + prompt.contains( + "Reply to it with the Roboco `send_message` tool, chat chat-bet." + ), + "{prompt}" + ); + } + #[tokio::test] async fn auto_steers_busy_chats_even_at_turn_boundaries_or_after_long_quiet_tools() { let world = Arc::new(World::default()); @@ -2378,4 +2499,71 @@ mod tests { "dev-local" ); } + + #[tokio::test] + async fn instructions_teach_notice_driven_orchestration() { + let world = Arc::new(World::default()); + let tools = tools(world, Origin::default()); + let init = crate::jsonrpc::handle_request( + &tools, + json!(1), + "initialize", + json!({ "protocolVersion": "2025-06-18" }), + ) + .await; + let instructions = init["result"]["instructions"].as_str().unwrap(); + + // The delegation flow: launch without waiting, end the turn, and let + // the engine's completion notices carry the outcomes back. + assert!(instructions.contains("launch side chats with `wait: false`")); + assert!(instructions.contains("end your turn")); + assert!(instructions.contains("completion notice")); + assert!(instructions.contains("finishes, fails, stops, is interrupted, or needs input")); + assert!(instructions.contains("read_chat")); + assert!(instructions.contains("respond_to_input")); + assert!(instructions.contains("quick synchronous")); + // The old blocking-collect guidance is gone. + assert!(!instructions.contains("to collect replies")); + + // The notify opt-out and the side-chat no-report rule. + assert!(instructions.contains("notify: false")); + assert!( + instructions.contains("your final reply is delivered to your parent"), + "side chats must be told their reply is delivered automatically" + ); + assert!(instructions.contains("Do not report progress or acknowledge")); + + // The conditional worktree rule. + assert!(instructions.contains("git worktree add")); + assert!(instructions.contains("never switches branches in the main checkout")); + assert!(instructions.contains("Read-only side chats")); + } + + #[tokio::test] + async fn tool_descriptions_match_the_notice_flow() { + let defs = catalog(); + let find = |name: &str| defs.iter().find(|d| d.name == name).unwrap(); + + let create = find("create_chat"); + assert!(create.description.contains("end your turn")); + assert!(create.description.contains("completion notice")); + assert!(create.description.contains("notify=false")); + let batch = find("create_chats"); + assert!(batch.description.contains("completion notices")); + let send_batch = find("send_messages"); + assert!(send_batch.description.contains("notify you")); + let read = find("read_chat"); + assert!(read.description.contains("completion notice")); + + // `send_message` claims only behaviour every harness has: mid-turn + // delivery is conditional, and the held case is spelled out. + let send = find("send_message"); + assert!(!send.description.contains("live mailbox")); + assert!(send.description.contains("idle or errored")); + assert!(send.description.contains("held and delivered when the turn ends")); + + let wait = find("wait_for_turn"); + assert!(wait.description.contains("quick synchronous")); + assert!(wait.description.contains("notify you")); + } } diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 5773bcacba..8ad9ed8590 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -8329,16 +8329,24 @@ fn agent_message_display(text: &str) -> String { let Some((header, body)) = rest.split_once("]\n\n") else { return text.to_owned(); }; - let Some((label, id)) = + // Chat-to-chat message: the header invites a reply and repeats the + // short id in the instruction, so the label is stripped with it. + let name = if let Some((label, id)) = header.rsplit_once(". Reply to it with the Roboco `send_message` tool, chat ") - else { - return text.to_owned(); - }; - let Some(id) = id.strip_suffix('.') else { + && let Some(id) = id.strip_suffix('.') + { + let suffix = format!(" ({id})"); + label.strip_suffix(&suffix).unwrap_or(label) + } else if let Some(label) = header.strip_suffix( + ". You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.", + ) { + // A parent's message to its notifying side chat (orchestration + // v2): the header teaches the notice flow instead of inviting a + // reply, and carries no separate id — the label holds it. + label.rsplit_once(" (").map_or(label, |(name, _)| name) + } else { return text.to_owned(); }; - let suffix = format!(" ({id})"); - let name = label.strip_suffix(&suffix).unwrap_or(label); format!("Message from {name}\n\n{body}") } @@ -16037,6 +16045,36 @@ mod tests { ); assert!(rows_for_entry(&entry, false, false, &mut parse).is_empty()); } + + #[test] + fn agent_attribution_headers_display_concisely() { + // Ordinary chat-to-chat message: the reply invitation is dropped and + // the label's short id with it. + let reply_line = "[Message from Roboco chat Alpha (chat-alp). Reply to it with the Roboco `send_message` tool, chat chat-alp.]\n\nplease review"; + assert_eq!( + agent_message_display(reply_line), + "Message from Alpha\n\nplease review" + ); + // A parent's message to its notifying side chat (orchestration v2): + // the header teaches the notice flow, and still collapses. + let notice_line = "[Message from Roboco chat Alpha (chat-alp). You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.]\n\nthe task"; + assert_eq!( + agent_message_display(notice_line), + "Message from Alpha\n\nthe task" + ); + // An untitled sender keeps its short id as the name. + let untitled = "[Message from Roboco chat chat-alp. You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.]\n\nthe task"; + assert_eq!( + agent_message_display(untitled), + "Message from chat-alp\n\nthe task" + ); + // Anything else stays verbatim. + assert_eq!(agent_message_display("plain text"), "plain text"); + assert_eq!( + agent_message_display("[Message from Roboco chat Alpha. unknown suffix.]\n\nbody"), + "[Message from Roboco chat Alpha. unknown suffix.]\n\nbody" + ); + } } #[cfg(feature = "appshots-fixture")] diff --git a/docs/mcp.md b/docs/mcp.md index 377c6f9ef6..c0ad2fed7d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -25,8 +25,12 @@ originating chat in the environment: When `ROBOCO_CHAT_ID` is set, every `send_message` is prefixed with a `[Message from Roboco chat <title> (<id8>) …]` line so the receiving agent and the human reading that transcript can tell an agent-to-agent message from a typed -one, and the server refuses to message its own chat. The transcript renders -this routing header as "Message from **chat name**", keeping the full routing +one, and the server refuses to message its own chat. The line comes in two +variants: an ordinary chat-to-chat message invites a reply with `send_message`; +a message from a side chat's creator (when that side chat notifies — the +default) instead tells the side chat its final reply is delivered to the parent +automatically and not to report or acknowledge by hand. The transcript renders +both headers as "Message from **chat name**", keeping the full routing instructions in the stored prompt for agents. ### Parent links @@ -153,10 +157,11 @@ Watch streams are the engine's only read surface (there is no one-shot "get transcript" RPC); a snapshot is "subscribe, take the first item, drop" — drop cancels server-side, exactly what the sidebar does on attach. -`send_message` mode `auto` starts idle chats and steers working chats through -their live mailbox without interrupting tools or child processes. Providers -consume it at their next supported input boundary. Only explicit `queue` mode -creates a held queue row. `awaitingInput` refuses and points at +`send_message` mode `auto` starts idle (or errored) chats and delivers into +working chats without interrupting the run: mid-turn where the harness accepts +prompts mid-run (Claude Code, Codex, Cursor, Pi, OpenCode), otherwise held in +the queue and delivered when the turn ends. Only explicit `queue` mode creates a +held queue row. `awaitingInput` refuses and points at `respond_to_input`. Quiet, long-running turns still receive steering; the host falls back to starting a turn if the live runtime has already exited. @@ -258,9 +263,14 @@ Only batch independent work, not ordered messages to the same chat. Requests may mix `kind: "chat"` and `kind: "side"`; guards run per request. When using individual tools, launch **all** chats/messages with `wait: false` -first, then collect replies with `wait_for_turn`. Waiting on each individual -launch before issuing the next serializes work at the caller, even though -both the MCP transport and engine support concurrent chat runs. +and end the turn. Side chats an agent created send it a completion notice when +they finish, fail, stop, are interrupted, or need input on a turn it started +or steered (opt out per chat with `notify: false`); `read_chat` collects the +result and `respond_to_input` answers a waiting side chat. Waiting on each +individual launch before issuing the next serializes work at the caller, even though +both the MCP transport and engine support concurrent chat runs, and +`wait_for_turn` blocks the caller for the whole wait — keep it for quick +synchronous asks. ## Smoke recipe From f4dc8447e65415ce93aea4e869b7b342a25a97cc Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 20:08:51 +0700 Subject: [PATCH 31/96] =?UTF-8?q?feat(engine):=20resume=20at=20reset=20?= =?UTF-8?q?=E2=80=94=20durable=20limited-chat=20continuations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 07 (orchestration v2). A Limited chat's **Resume at reset** arms a durable continuation owned by the engine: at the limit's reset time it sends the continue prompt exactly like the manual Resume — a normal run with the queued messages behind it. - New engine module scheduled_resumes: one pending continuation per chat, persisted under the engine data dir (scheduled-resumes.json), with the consume-before-fire discipline (a crash mid-fire can never double-schedule) and the loop guard (a new limit record re-arms only when its reset advances past the last fired continuation). - Firing goes through the chat's own run configuration like the completion-notice turn, with a deterministic message id so a re-fire after a crash lands as the same transcript entry; the turn thaws the queue the limited chat holds so queued rows follow it. - Restarts: pending continuations survive engine restarts; overdue ones fire after boot, staggered like the crashed-run auto-resume. A shutdown never consumes one. - Cancellation: a new message (Run/Steer command or queued row), archive, or delete cancels the pending continuation — the newer intent wins. Deleting also guards the fire path against a chat row that vanished through the synced doc. - New engine setting "Auto-resume limited chats" (default off), persisted in engine-settings.json, read through GetEngineSettings/SetEngineSettings and armed automatically when a chat becomes Limited. - Wire (additive, ADR 0005): ScheduledResume on the Session row, mirrored through the workspace doc; new Mutate ops scheduleResumeAtReset / cancelScheduledResume; EngineSettings; wiregen + generated TS updated by hand. - Desktop: the Limited banner offers Resume at reset, shows "resumes in …" with a Cancel action once armed; Settings → General gains the Auto-resume limited chats card. - Engine integration tests (resume_at_reset.rs) cover firing at the reset time, restart + overdue-after-boot, every cancellation rule, the setting on/off, and the re-arm/loop-guard overlap; store + settings unit tests; state/view tests for the scheduled-resume row and banner text. Tests not run locally (verification policy) — CI on the PR is the signal. --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 4 + crates/doc/src/registry/tests.rs | 1 + crates/doc/src/workspace.rs | 11 + crates/engine/src/doc_host.rs | 27 + crates/engine/src/engine_settings.rs | 145 +++ crates/engine/src/lib.rs | 14 + crates/engine/src/rpc.rs | 46 +- crates/engine/src/scheduled_resumes.rs | 348 +++++++ crates/engine/src/sessions.rs | 366 ++++++- crates/engine/src/workspace_host.rs | 1 + crates/engine/tests/resume_at_reset.rs | 917 ++++++++++++++++++ crates/mobile-client/src/clock.rs | 1 + crates/mobile-client/src/notify.rs | 1 + crates/mobile-client/src/session/composer.rs | 1 + crates/mobile-client/src/workspace/view.rs | 1 + crates/proto/src/entities.rs | 54 ++ crates/proto/src/view.rs | 43 +- crates/roboco-mcp/src/tools.rs | 1 + crates/rpc/src/lib.rs | 6 + crates/ui/examples/macos-resource-profile.rs | 1 + crates/ui/src/composer.rs | 1 + crates/ui/src/settings.rs | 1 + crates/ui/src/settings/auto_resume.rs | 184 ++++ crates/ui/src/settings/shortcuts.rs | 7 +- crates/ui/src/shell.rs | 109 ++- crates/ui/src/state.rs | 66 +- crates/ui/src/transcript.rs | 2 + crates/wiregen/src/main.rs | 2 + .../proto/src/generated/EngineSettings.ts | 13 + .../proto/src/generated/MutateParams.ts | 2 +- .../proto/src/generated/ScheduledResume.ts | 15 + web/packages/proto/src/generated/Session.ts | 9 +- web/packages/proto/src/generated/index.ts | 2 + 34 files changed, 2379 insertions(+), 25 deletions(-) create mode 100644 crates/engine/src/engine_settings.rs create mode 100644 crates/engine/src/scheduled_resumes.rs create mode 100644 crates/engine/tests/resume_at_reset.rs create mode 100644 crates/ui/src/settings/auto_resume.rs create mode 100644 web/packages/proto/src/generated/EngineSettings.ts create mode 100644 web/packages/proto/src/generated/ScheduledResume.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index de879e54b0..2db7def6a1 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -66,7 +66,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats --test resume_at_reset # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index 5a4dd5c3f2..942c9402f7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,6 +56,10 @@ _Avoid_: Session, state (when it means chat status) An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume; queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. _Avoid_: Rate-limited (that is the harness event), throttled, capped +**Resume at reset**: +A pending continuation the engine owns for a Limited chat: at the limit's reset time it sends the continue prompt like a manual Resume. Durable across engine restarts (an overdue one fires after boot); cancelled by a new message, archive, or delete. Armed by the banner action or the "Auto-resume limited chats" engine setting. +_Avoid_: Scheduled task, retry, snooze + **Chat doc**: The chat's durable document — transcript, command ledger, and message queue in one doc, owned by the engine. _Avoid_: Session doc diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index 353f0ad65f..e0496d7c7d 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -303,6 +303,7 @@ fn session(chat_id: &str, device_id: &str, status: SessionStatus) -> Session { started_at: Some(ts(3_000)), updated_at: ts(3_500), limit: None, + scheduled_resume: None, } } diff --git a/crates/doc/src/workspace.rs b/crates/doc/src/workspace.rs index 9dcab256f5..f24333f5c7 100644 --- a/crates/doc/src/workspace.rs +++ b/crates/doc/src/workspace.rs @@ -499,6 +499,13 @@ impl WorkspaceDoc { )?, None => row.delete("limit")?, } + match &session.scheduled_resume { + Some(scheduled) => row.insert( + "scheduledResume", + LoroValue::from(serde_json::to_value(scheduled)?), + )?, + None => row.delete("scheduledResume")?, + } self.doc.commit(); Ok(()) } @@ -796,6 +803,8 @@ pub(crate) struct RawSession { updated_at: i64, #[serde(default)] limit: Option<roboco_proto::SessionLimit>, + #[serde(default)] + scheduled_resume: Option<roboco_proto::ScheduledResume>, } impl From<RawSession> for Session { @@ -808,6 +817,7 @@ impl From<RawSession> for Session { started_at: raw.started_at.map(dt), updated_at: dt(raw.updated_at), limit: raw.limit, + scheduled_resume: raw.scheduled_resume, } } } @@ -917,6 +927,7 @@ mod tests { started_at: Some(ts(3_000)), updated_at: ts(3_500), limit: None, + scheduled_resume: None, } } diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index e17abb7819..9d696c7564 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -402,6 +402,14 @@ impl ChatDocHandle { self.queue_paused.store(true, Ordering::Release); } + /// Release the hold [`Self::pause_queue`] armed: the explicit prompt — + /// the manual Resume's run, the user's own message, or the engine's + /// Resume-at-reset continuation — dispatched successfully, so the queued + /// rows may follow it (the drain still waits out the turn in flight). + pub fn thaw_queue(&self) { + self.queue_paused.store(false, Ordering::Release); + } + /// Where a newly steered row goes: after the rows already steered, ahead /// of every ordinary row. Records `id` as steered. fn steer_slot(&self, id: &str) -> Result<usize, DocError> { @@ -1046,7 +1054,13 @@ impl DocHost { /// Drop a chat's doc unconditionally and delete its local snapshot — the /// chat is gone (DeleteChat / DeleteSpace cascade). Watchers see the /// stream end; a racing writer keeps its orphaned doc until the run ends. + /// Cancels the chat's pending Resume-at-reset continuation (ticket 07): + /// deleting the chat is the last word — locally, without re-mirroring a + /// sessions row that is going away with the chat. pub fn purge_chat(&self, chat_id: &str) { + if let Some(sessions) = self.sessions() { + sessions.cancel_scheduled_resume_with_chat(chat_id); + } let removed = lock(&self.inner.handles).remove(chat_id); drop(removed); if let Err(err) = self.inner.store.delete_snapshot(chat_id) { @@ -1109,6 +1123,14 @@ impl DocHost { // effort — the command itself is durable regardless. if is_message { self.unarchive_on_send(chat_id); + // And it cancels any pending Resume-at-reset continuation + // (orchestration v2, ticket 07): the newer intent wins. The + // engine's own continuations never queue commands — they + // dispatch directly — so this hook only ever sees user and + // agent messages. + if let Some(sessions) = self.sessions() { + sessions.cancel_scheduled_resume(chat_id); + } } Ok(id) } @@ -1170,6 +1192,11 @@ impl DocHost { handle.publish_queue(); // Same reasoning as a command: the user is acting in this chat again. self.unarchive_on_send(chat_id); + // And their newer intent cancels any pending Resume-at-reset + // continuation (orchestration v2, ticket 07). + if let Some(sessions) = self.sessions() { + sessions.cancel_scheduled_resume(chat_id); + } Ok(id) } diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs new file mode 100644 index 0000000000..a3a4fffcaa --- /dev/null +++ b/crates/engine/src/engine_settings.rs @@ -0,0 +1,145 @@ +//! Engine settings (orchestration v2): preferences the ENGINE itself acts +//! on — set from any client (Settings → General on desktop), persisted under +//! the engine data dir so they survive restarts and apply with the UI +//! closed. Per-device like the harness prefs, not synced. +//! +//! First setting: **Auto-resume limited chats** (ticket 07, default off) — +//! arm the Resume-at-reset continuation automatically whenever a chat +//! becomes Limited, so long work continues overnight without a click per +//! chat. Off by default: an engine that starts spending turns on its own is +//! opt-in. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use serde::{Deserialize, Serialize}; + +/// The engine's user-facing settings. Additive only (ADR 0005): every field +/// carries `#[serde(default)]` so settings files from older engines — and +/// older engines reading newer files — keep loading. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ts_rs::TS)] +#[serde(default, rename_all = "camelCase")] +pub struct EngineSettings { + /// Arm **Resume at reset** automatically whenever a chat becomes + /// Limited (orchestration v2, ticket 07). Off by default. + pub auto_resume_limited: bool, +} + +/// The settings plus where they persist. `path = None` (bare engines, +/// tests) keeps everything in memory. One instance lives on the sessions +/// engine's `Inner`. +#[derive(Debug, Default)] +pub(crate) struct EngineSettingsStore { + settings: Mutex<EngineSettings>, + path: Mutex<Option<PathBuf>>, +} + +impl EngineSettingsStore { + /// The persisted store at `path`, loaded. Engine assembly calls this; + /// the default (no path) serves bare engines. + pub(crate) fn open(path: Option<PathBuf>) -> Self { + let settings = path.as_deref().and_then(load_file).unwrap_or_default(); + Self { + settings: Mutex::new(settings), + path: Mutex::new(path), + } + } + + pub(crate) fn get(&self) -> EngineSettings { + *lock(&self.settings) + } + + /// Update in memory and on disk. Returns the persistence error (the + /// in-memory value still updated — a failed write never wedges the + /// toggle, and the next successful write heals the file). + pub(crate) fn set(&self, settings: EngineSettings) -> std::io::Result<()> { + *lock(&self.settings) = settings; + let path = lock(&self.path).clone(); + match path { + Some(path) => write_file(&path, &settings), + None => Ok(()), + } + } +} + +fn load_file(path: &Path) -> Option<EngineSettings> { + let bytes = std::fs::read(path).ok()?; + match serde_json::from_slice::<EngineSettings>(&bytes) { + Ok(settings) => Some(settings), + Err(err) => { + // A broken file must never refuse the engine over one toggle. + tracing::warn!(path = %path.display(), error = %err, "engine settings unreadable"); + None + } + } +} + +fn write_file(path: &Path, settings: &EngineSettings) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(settings) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + std::fs::write(path, bytes) +} + +fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path() -> PathBuf { + tempfile::tempdir().unwrap().keep().join("engine-settings.json") + } + + #[test] + fn defaults_are_off_and_round_trip_through_disk() { + let path = temp_path(); + let store = EngineSettingsStore::open(Some(path.clone())); + assert_eq!(store.get(), EngineSettings::default()); + assert!(!store.get().auto_resume_limited); + + store + .set(EngineSettings { + auto_resume_limited: true, + }) + .unwrap(); + let reloaded = EngineSettingsStore::open(Some(path)); + assert!(reloaded.get().auto_resume_limited); + + // Back off again. + reloaded.set(EngineSettings::default()).unwrap(); + assert!(!EngineSettingsStore::open(Some(path)).get().auto_resume_limited); + } + + #[test] + fn missing_and_broken_files_default_off_and_old_settings_gain_defaults() { + // Missing. + let missing = EngineSettingsStore::open(Some(temp_path())); + assert_eq!(missing.get(), EngineSettings::default()); + // Broken. + let broken = temp_path(); + std::fs::write(&broken, b"{not json").unwrap(); + assert_eq!(EngineSettingsStore::open(Some(broken)).get(), EngineSettings::default()); + // A file from an engine with fewer settings still loads. + let sparse = temp_path(); + std::fs::write(&sparse, b"{}").unwrap(); + assert_eq!(EngineSettingsStore::open(Some(sparse)).get(), EngineSettings::default()); + // A file from a newer engine keeps its known fields. + let future = temp_path(); + std::fs::write(&future, br#"{"autoResumeLimited":true,"someFutureField":9}"#).unwrap(); + assert!(EngineSettingsStore::open(Some(future)).get().auto_resume_limited); + } + + #[test] + fn a_failed_write_never_wedges_the_in_memory_toggle() { + // A path whose parent does not exist cannot be written. + let store = EngineSettingsStore::open(Some(PathBuf::from("/nonexistent/engine-settings.json"))); + assert!(store + .set(EngineSettings { + auto_resume_limited: true, + }) + .is_err()); + assert!(store.get().auto_resume_limited); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca8c4a9166..c4b0149803 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ mod chat_persistence; pub mod completion_notices; pub mod diff_sync; pub mod doc_host; +pub mod engine_settings; mod fs_watch; pub mod harness_updates; mod http_error; @@ -35,6 +36,7 @@ pub mod registry; pub mod repos; pub mod rpc; pub mod run_journal; +pub mod scheduled_resumes; pub mod sessions; pub mod sidebar_state; pub mod source_control; @@ -59,6 +61,7 @@ pub use diff_sync::{ discard_working_tree, merge_base, read_diff_file_text, snapshot_tree, working_diff_base, }; pub use doc_host::{ChatDocHandle, DocHost, DocHostConfig}; +pub use engine_settings::EngineSettings; pub use instance_lock::InstanceLock; pub use profile::EngineProfile; pub use project_actions::ProjectActionsStore; @@ -231,11 +234,22 @@ impl EngineCore { doc_host.set_workspace(workspace.clone()); doc_host.set_sessions(sessions.clone()); sessions.set_doc_host(doc_host.clone()); + // Engine settings (auto-resume limited chats, …) and the durable + // Resume-at-reset store both live under the engine data dir; the + // continuations' timers arm after `recover_stale` (firing needs the + // wired doc host) — orchestration v2, ticket 07. + sessions.set_engine_settings_store(crate::engine_settings::EngineSettingsStore::open( + Some(data_dir.join("engine-settings.json")), + )); + sessions.set_scheduled_resumes_path(data_dir.join("scheduled-resumes.json")); match sessions.recover_stale() { Ok(0) => {} Ok(recovered) => tracing::info!(recovered, "stale sessions recovered on boot"), Err(err) => tracing::error!(error = %err, "stale-session recovery failed"), } + // Overdue continuations fire after boot; the rest sleep until their + // reset time. Staggered with the crashed-run resumes above. + sessions.recover_scheduled_resumes(); doc_host.spawn_transcript_salvage(profile.store_root().join("journals")); let repos = Repos::new(data_dir, &device_id); doc_host.set_repos(repos.clone()); diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 21d4b67c75..4d076606f2 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -736,6 +736,16 @@ pub enum MutateParams { #[ts(optional)] at: Option<i64>, }, + /// **Resume at reset** (orchestration v2, ticket 07): arm the durable + /// continuation for a Limited chat — at the limit's reset time the + /// engine sends the continue prompt like the manual Resume. Requires + /// the chat to be Limited with a reset time still ahead. + #[serde(rename_all = "camelCase")] + ScheduleResumeAtReset { chat_id: String }, + /// Cancel the chat's pending Resume-at-reset continuation (the banner's + /// Cancel action). No-op when nothing is armed. + #[serde(rename_all = "camelCase")] + CancelScheduledResume { chat_id: String }, } pub struct EngineRpc { @@ -1095,11 +1105,19 @@ impl EngineRpc { .set_chat_host(&chat_id, &device_id) .map_err(failed) .map(drop), - MutateParams::SetChatArchived { chat_id, archived } => self - .workspace - .set_chat_archived(&chat_id, archived) - .map_err(failed) - .map(drop), + MutateParams::SetChatArchived { chat_id, archived } => { + let result = self + .workspace + .set_chat_archived(&chat_id, archived) + .map_err(failed); + // Archiving a Limited chat cancels its pending Resume-at-reset + // continuation (orchestration v2, ticket 07): the user put the + // work away — it must not resume on its own overnight. + if archived { + self.sessions.cancel_scheduled_resume(&chat_id); + } + result.map(drop) + } MutateParams::SetChatConfig { chat_id, config } => self .workspace .set_chat_config(&chat_id, &config) @@ -1124,6 +1142,16 @@ impl EngineRpc { .map_err(failed) .map(drop) } + MutateParams::ScheduleResumeAtReset { chat_id } => { + self.sessions + .schedule_resume_at_reset(&chat_id) + .map_err(failed) + .map(drop) + } + MutateParams::CancelScheduledResume { chat_id } => { + self.sessions.cancel_scheduled_resume(&chat_id); + Ok(()) + } } } } @@ -1389,6 +1417,14 @@ impl RpcService for EngineRpc { .map_err(RpcError::Failed)?; RpcReply::value(&self.registry.title_settings()) } + methods::GET_ENGINE_SETTINGS => RpcReply::value(&self.sessions.engine_settings()), + methods::SET_ENGINE_SETTINGS => { + let p: crate::engine_settings::EngineSettings = parse_params(params)?; + self.sessions + .set_engine_settings(p) + .map_err(|e| RpcError::Failed(e.to_string()))?; + RpcReply::value(&self.sessions.engine_settings()) + } methods::SET_HARNESS_ENABLED => { let p: SetHarnessEnabledParams = parse_params(params)?; update_harness_enabled(&self.registry, p.harness, p.enabled).await?; diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs new file mode 100644 index 0000000000..3fe197d6da --- /dev/null +++ b/crates/engine/src/scheduled_resumes.rs @@ -0,0 +1,348 @@ +//! Resume at reset (orchestration v2, ticket 07): the durable continuation a +//! Limited chat's **Resume at reset** action arms. The engine — not the +//! client — owns it: at the limit's reset time it sends the continue prompt +//! exactly like the manual Resume (a normal run, queued messages behind it). +//! +//! One pending continuation per chat. It is cancelled by a newer message to +//! the chat, by archiving it, or by deleting it — the user's newer intent +//! always wins. It survives engine restarts (this file), and one whose time +//! passed while the engine was down fires after boot. +//! +//! This module is the store: the entries, their JSON persistence under the +//! engine data dir, and the disarm flags the timers check. The sessions +//! engine owns arming, firing and the session-row publishing +//! (`sessions.rs`). +//! +//! The loop guard: a fired continuation is consumed, never re-armed from its +//! own failure. A *new* limit record re-arms only when its reset time is +//! strictly later than the last continuation the chat fired (see +//! [`ScheduledResumes::advances`]) — a record whose reset does not advance +//! past the fired one is the same wall, and re-arming it would fire straight +//! back into the limit in a loop. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use roboco_proto::ScheduledResume; + +use crate::now_ms; + +/// The durable shape of `scheduled-resumes.json`. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct ScheduledResumeFile { + /// The pending continuations, one per chat. + resumes: Vec<ScheduledResumeRecord>, + /// When each chat's last continuation FIRED (epoch ms) — the loop guard's + /// progress floor. Survives restarts so a lying reset time cannot loop + /// across them either. + fired: HashMap<String, i64>, +} + +/// One pending continuation, as persisted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScheduledResumeRecord { + chat_id: String, + /// Epoch ms when the continuation fires. + fire_at: i64, +} + +/// One in-memory entry: the record plus the flag its timer checks. +#[derive(Debug)] +struct Entry { + fire_at: i64, + /// Set when the entry is replaced or cancelled: its timer wakes, sees + /// the flag, and goes away without firing. + disarmed: Arc<AtomicBool>, +} + +/// The engine's pending Resume-at-reset continuations. In-memory state +/// mirrors the JSON store; `path = None` (bare engines, tests) keeps +/// everything in memory. One instance lives on the sessions engine's +/// `Inner`. +#[derive(Debug, Default)] +pub(crate) struct ScheduledResumes { + entries: Mutex<HashMap<String, Entry>>, + fired: Mutex<HashMap<String, i64>>, + path: Mutex<Option<PathBuf>>, +} + +impl ScheduledResumes { + /// Persist to `path` (loading anything already there) — engine assembly. + pub(crate) fn set_path(&self, path: PathBuf) { + let loaded = load_file(&path); + { + let mut entries = lock(&self.entries); + let mut fired = lock(&self.fired); + // Data only: boot recovery spawns the timers separately, after + // the doc host is wired (firing needs it). + for record in loaded.resumes { + entries.insert( + record.chat_id.clone(), + Entry { + fire_at: record.fire_at, + // No timer owns this entry yet — the disarm flag is + // for the timer boot recovery is about to spawn. + disarmed: Arc::new(AtomicBool::new(false)), + }, + ); + } + fired.extend(loaded.fired); + } + *lock(&self.path) = Some(path); + self.persist(); + } + + /// The pending continuation for `chat`, when one is armed. + pub(crate) fn scheduled(&self, chat_id: &str) -> Option<ScheduledResume> { + lock(&self.entries) + .get(chat_id) + .map(|entry| ScheduledResume { + fire_at: entry.fire_at, + }) + } + + /// Arm (or re-arm) `chat`'s continuation at `fire_at`, disarming whatever + /// timer the replaced entry owned. Returns the new entry's disarm flag — + /// the timer the caller spawns for it. + pub(crate) fn arm(&self, chat_id: &str, fire_at: i64) -> Arc<AtomicBool> { + let disarmed = Arc::new(AtomicBool::new(false)); + { + let mut entries = lock(&self.entries); + if let Some(old) = entries.insert( + chat_id.to_string(), + Entry { + fire_at, + disarmed: disarmed.clone(), + }, + ) { + old.disarmed.store(true, Ordering::Release); + } + } + self.persist(); + disarmed + } + + /// Cancel `chat`'s continuation: remove, persist, disarm its timer. + /// `true` when there was one to cancel. + pub(crate) fn cancel(&self, chat_id: &str) -> bool { + let removed = lock(&self.entries).remove(chat_id); + if let Some(old) = &removed { + old.disarmed.store(true, Ordering::Release); + } + if removed.is_some() { + self.persist(); + } + removed.is_some() + } + + /// Consume the continuation this timer owns: remove and persist it — + /// BEFORE anything async happens, so a crash mid-fire can never + /// double-schedule. `false` when the entry is gone or was replaced by a + /// different one (this timer is obsolete). + pub(crate) fn consume(&self, chat_id: &str, fire_at: i64) -> bool { + let consumed = { + let mut entries = lock(&self.entries); + match entries.get(chat_id) { + Some(entry) if entry.fire_at == fire_at => { + entries.remove(chat_id); + true + } + _ => false, + } + }; + if consumed { + self.persist(); + } + consumed + } + + /// Put a consumed continuation back after a shutdown raced its dispatch: + /// the next boot re-arms it (overdue) instead of losing the user's + /// armed continuation to a graceful shutdown. + pub(crate) fn requeue(&self, chat_id: &str, fire_at: i64) { + let disarmed = Arc::new(AtomicBool::new(false)); + if let Some(old) = lock(&self.entries).insert( + chat_id.to_string(), + Entry { + fire_at, + disarmed, + }, + ) { + old.disarmed.store(true, Ordering::Release); + } + self.persist(); + } + + /// Every pending continuation with its disarm flag, for boot recovery's + /// timers. + pub(crate) fn pending(&self) -> Vec<(String, i64, Arc<AtomicBool>)> { + lock(&self.entries) + .iter() + .map(|(chat, entry)| (chat.clone(), entry.fire_at, entry.disarmed.clone())) + .collect() + } + + /// Stamp that `chat`'s continuation fired at `fire_at` — the progress + /// floor [`Self::advances`] re-arms must beat. + pub(crate) fn note_fired(&self, chat_id: &str, fire_at: i64) { + lock(&self.fired) + .entry(chat_id.to_string()) + .and_modify(|at| *at = (*at).max(fire_at)) + .or_insert(fire_at); + self.persist(); + } + + /// The loop guard: does `resets_at` advance past everything the chat + /// already fired (and the present)? A limit record whose reset does not + /// beat the last fired continuation is the same wall — re-arming it + /// would loop. See the module docs. + pub(crate) fn advances(&self, chat_id: &str, resets_at: i64) -> bool { + let floor = lock(&self.fired) + .get(chat_id) + .copied() + .unwrap_or(i64::MIN); + resets_at > floor.max(now_ms()) + } + + fn persist(&self) { + let Some(path) = lock(&self.path).clone() else { + return; + }; + let file = ScheduledResumeFile { + resumes: lock(&self.entries) + .iter() + .map(|(chat, entry)| ScheduledResumeRecord { + chat_id: chat.clone(), + fire_at: entry.fire_at, + }) + .collect(), + fired: lock(&self.fired).clone(), + }; + if let Err(err) = write_file(&path, &file) { + tracing::warn!(path = %path.display(), error = %err, "scheduled-resume persist failed"); + } + } +} + +fn load_file(path: &Path) -> ScheduledResumeFile { + std::fs::read(path) + .map_err(Into::into) + .and_then(|bytes| serde_json::from_slice(&bytes)) + .unwrap_or_else(|err| { + // Missing file = fresh engine; broken file = keep going with + // nothing armed (never refuse the engine over one feature). + if path.exists() { + tracing::warn!(path = %path.display(), error = %err, "scheduled-resume store unreadable"); + } + ScheduledResumeFile::default() + }) +} + +fn write_file(path: &Path, file: &ScheduledResumeFile) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(file) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + std::fs::write(path, bytes) +} + +fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path() -> PathBuf { + let dir = tempfile::tempdir().unwrap(); + let path = dir.keep().join("scheduled-resumes.json"); + path + } + + #[test] + fn arm_cancel_and_consume_track_one_entry_per_chat() { + let store = ScheduledResumes::default(); + assert!(store.scheduled("chat").is_none()); + assert!(!store.cancel("chat")); + + let first = store.arm("chat", 1_000); + assert_eq!(store.scheduled("chat").unwrap().fire_at, 1_000); + // Re-arming replaces: the old timer's flag flips. + let second = store.arm("chat", 2_000); + assert!(first.load(Ordering::Acquire)); + assert!(!second.load(Ordering::Acquire)); + assert_eq!(store.scheduled("chat").unwrap().fire_at, 2_000); + + // A timer for the replaced entry cannot consume the new one. + assert!(!store.consume("chat", 1_000)); + assert_eq!(store.scheduled("chat").unwrap().fire_at, 2_000); + // The owner consumes. + assert!(store.consume("chat", 2_000)); + assert!(store.scheduled("chat").is_none()); + // Taken is taken. + assert!(!store.consume("chat", 2_000)); + } + + #[test] + fn cancel_disarms_the_live_timer() { + let store = ScheduledResumes::default(); + let disarmed = store.arm("chat", 1_000); + assert!(store.cancel("chat")); + assert!(disarmed.load(Ordering::Acquire)); + assert!(store.scheduled("chat").is_none()); + // Nothing left to cancel. + assert!(!store.cancel("chat")); + } + + #[test] + fn entries_and_fired_floors_survive_a_store_reload() { + let path = temp_path(); + let first = ScheduledResumes::default(); + first.set_path(path.clone()); + first.arm("chat-live", 5_000); + first.arm("chat-done", 6_000); + first.consume("chat-done", 6_000); + first.note_fired("chat-done", 6_000); + + let reloaded = ScheduledResumes::default(); + reloaded.set_path(path); + assert_eq!(reloaded.scheduled("chat-live").unwrap().fire_at, 5_000); + assert!(reloaded.scheduled("chat-done").is_none()); + assert_eq!(reloaded.pending().len(), 1); + // The fired floor survives the restart. + assert!(reloaded.advances("chat-done", 7_000)); + assert!(!reloaded.advances("chat-done", 6_000)); + // A reset in the past never advances. + assert!(!reloaded.advances("chat-live", 1)); + } + + #[test] + fn a_broken_store_file_loads_as_nothing_armed() { + let path = temp_path(); + std::fs::write(&path, b"{not json").unwrap(); + let store = ScheduledResumes::default(); + store.set_path(path.clone()); + assert!(store.pending().is_empty()); + // And it heals: the next persist writes a valid file. + store.arm("chat", 9_000); + let healed = ScheduledResumes::default(); + healed.set_path(path); + assert_eq!(healed.scheduled("chat").unwrap().fire_at, 9_000); + } + + #[test] + fn requeue_restores_a_consumed_entry_for_the_next_boot() { + let path = temp_path(); + let store = ScheduledResumes::default(); + store.set_path(path); + store.arm("chat", 5_000); + assert!(store.consume("chat", 5_000)); + store.requeue("chat", 5_000); + let reloaded = ScheduledResumes::default(); + reloaded.set_path(path); + assert_eq!(reloaded.scheduled("chat").unwrap().fire_at, 5_000); + } +} diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 89bbfecdb7..55a04b3cbb 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -18,6 +18,7 @@ //! spawn failure, stream error, engine-restart recovery). use std::collections::HashMap; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; use chrono::Utc; @@ -30,8 +31,8 @@ use roboco_doc::{ }; use roboco_harness::{CancellationToken, Harness, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionLimit, - SessionStatus, UserInputAnswer, UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, ScheduledResume, Session, + SessionLimit, SessionStatus, UserInputAnswer, UserInputQuestion, }; use crate::doc_host::{ChatDocHandle, DocHost}; @@ -213,6 +214,13 @@ struct Inner { /// side-chat turns owe their agent parents. Durability lands with /// ticket 13; this is the in-memory seam. notices: crate::completion_notices::CompletionNotices, + /// Pending Resume-at-reset continuations (orchestration v2, ticket 07): + /// the durable entries + disarm flags, persisted under the engine data + /// dir. Arming, firing and cancellation live on [`SessionsEngine`]. + scheduled_resumes: crate::scheduled_resumes::ScheduledResumes, + /// Engine settings the engine itself acts on (auto-resume limited + /// chats, …) — loaded at assembly, persisted under the data dir. + engine_settings: crate::engine_settings::EngineSettingsStore, /// Auto-titler for untitled chats (wired at engine assembly; absent in bare tests). titles: OnceLock<crate::titles::TitleGenerator>, generated_images: OnceLock<(crate::uploads::Uploads, std::path::PathBuf)>, @@ -264,6 +272,8 @@ impl SessionsEngine { last_requests: Mutex::new(HashMap::new()), harness_sessions: Mutex::new(HashMap::new()), notices: crate::completion_notices::CompletionNotices::default(), + scheduled_resumes: crate::scheduled_resumes::ScheduledResumes::default(), + engine_settings: crate::engine_settings::EngineSettingsStore::default(), titles: OnceLock::new(), generated_images: OnceLock::new(), turn_listener: OnceLock::new(), @@ -326,6 +336,37 @@ impl SessionsEngine { *lock(&self.inner.agent_accounts) = Some(accounts); } + /// Persist the Resume-at-reset store under `path`, loading anything + /// already there (engine assembly — `EngineCore::assemble`). The + /// entries carry no timers until [`Self::recover_scheduled_resumes`] + /// arms them, because firing needs the doc host this runs before. + pub fn set_scheduled_resumes_path(&self, path: std::path::PathBuf) { + self.inner.scheduled_resumes.set_path(path); + } + + /// Wire the engine settings store (engine assembly; a bare engine keeps + /// the in-memory default — auto-resume off). + pub fn set_engine_settings_store(&self, store: crate::engine_settings::EngineSettingsStore) { + self.inner.engine_settings = store; + } + + /// The engine's settings (see [`crate::engine_settings::EngineSettings`]). + pub fn engine_settings(&self) -> crate::engine_settings::EngineSettings { + self.inner.engine_settings.get() + } + + /// Update the engine's settings, persisting them when a store path is + /// wired. A failed disk write still applies in memory (and is reported). + pub fn set_engine_settings( + &self, + settings: crate::engine_settings::EngineSettings, + ) -> Result<(), EngineError> { + self.inner + .engine_settings + .set(settings) + .map_err(|err| EngineError::Other(format!("engine settings write failed: {err}"))) + } + fn note_turn_start(&self, chat_id: &str, cwd: &str) { if let Some(listener) = self.inner.turn_listener.get() { listener(chat_id, cwd); @@ -1342,6 +1383,280 @@ impl SessionsEngine { Ok(recovered) } + // ── Resume at reset (orchestration v2, ticket 07) ───────────────────── + + /// The chat's pending Resume-at-reset continuation, when one is armed. + /// (The session row carries the same record for clients; this is the + /// engine-side read.) + pub fn scheduled_resume(&self, chat_id: &str) -> Option<ScheduledResume> { + self.inner.scheduled_resumes.scheduled(chat_id) + } + + /// **Resume at reset**: arm the durable continuation for a Limited chat, + /// firing the continue prompt at its limit's reset time. Requires the + /// chat to be Limited with a reset time still ahead — otherwise the + /// honest offer is the manual Resume, and the caller is told so. + pub fn schedule_resume_at_reset(&self, chat_id: &str) -> Result<(), EngineError> { + let limit = lock(&self.inner.statuses) + .get(chat_id) + .and_then(|row| row.limit.clone()) + .ok_or_else(|| { + EngineError::Other(format!("chat {chat_id} is not Limited: no limit record")) + })?; + let fire_at = limit + .resets_at + .ok_or_else(|| EngineError::Other( + "the limit's reset time is unknown — use Resume instead".into(), + ))?; + if fire_at <= now_ms() { + return Err(EngineError::Other( + "the limit has already reset — use Resume instead".into(), + )); + } + self.arm_scheduled_resume(chat_id, fire_at); + Ok(()) + } + + /// Cancel the chat's pending Resume-at-reset continuation. A new message + /// to the chat, archiving it or deleting it all land here — the user's + /// newer intent wins. `true` when there was one to cancel. + pub fn cancel_scheduled_resume(&self, chat_id: &str) -> bool { + let cancelled = self.inner.scheduled_resumes.cancel(chat_id); + if cancelled { + self.inner.set_scheduled_resume_row(chat_id, None); + tracing::info!(chat = %chat_id, "scheduled resume cancelled"); + } + cancelled + } + + /// Cancel the continuation as the chat is DELETED: the store entry and + /// timer go, the in-memory row clears, but nothing mirrors into the + /// workspace doc — its sessions row is being deleted with the chat, and + /// a mirror would resurrect it. + pub(crate) fn cancel_scheduled_resume_with_chat(&self, chat_id: &str) { + if self.inner.scheduled_resumes.cancel(chat_id) { + self.inner.set_scheduled_resume_row_local(chat_id, None); + tracing::info!(chat = %chat_id, "scheduled resume cancelled with the deleted chat"); + } + } + + /// Boot recovery: arm timers for every continuation that survived the + /// restart — an overdue one fires now, after the engine is up. Called + /// from assembly once the doc host is wired (firing needs it). + pub fn recover_scheduled_resumes(&self) { + let pending = self.inner.scheduled_resumes.pending(); + for (ix, (chat_id, fire_at, disarmed)) in pending.into_iter().enumerate() { + // Same stagger discipline as the crashed-run auto-resume: a + // fleet of overdue continuations re-dispatches spaced out, not + // as one boot burst of CLI spawns. + let delay = std::time::Duration::from_millis(ix as u64 * RESUME_STAGGER_MS); + self.spawn_resume_timer(chat_id, fire_at, disarmed, delay); + tracing::info!( + chat = %chat_id, + fire_at, + overdue = fire_at <= now_ms(), + "scheduled resume armed at boot" + ); + } + } + + /// Arm (or re-arm) the chat's Resume-at-reset continuation at `fire_at`: + /// store it durably, publish it on the status row, spawn its timer. + fn arm_scheduled_resume(&self, chat_id: &str, fire_at: i64) { + let disarmed = self.inner.scheduled_resumes.arm(chat_id, fire_at); + self.inner + .set_scheduled_resume_row(chat_id, Some(ScheduledResume { fire_at })); + self.spawn_resume_timer( + chat_id.to_string(), + fire_at, + disarmed, + std::time::Duration::ZERO, + ); + } + + /// The **Auto-resume limited chats** setting's arm: a fresh limit record + /// re-arms the continuation only when its reset time is still ahead AND + /// advances past the last continuation this chat fired — the loop guard. + /// A record whose reset does not beat it is the same wall, and re-arming + /// would fire straight back into the limit in a loop (T3's model: the + /// scheduler only ever works from a fresh persisted limit record). + fn maybe_auto_arm_resume(&self, chat_id: &str, limit: &SessionLimit) { + if !self.inner.engine_settings.get().auto_resume_limited { + return; + } + let Some(resets_at) = limit.resets_at else { + return; + }; + if !self.inner.scheduled_resumes.advances(chat_id, resets_at) { + return; + } + tracing::info!( + chat = %chat_id, + resets_at, + "auto-resume limited chats: arming the continuation" + ); + self.arm_scheduled_resume(chat_id, resets_at); + } + + /// One continuation's timer: wait out the delay plus the remaining time + /// to `fire_at`, then hand the firing to the engine — unless the entry + /// was replaced or cancelled while it slept (the flag). `consume` + /// re-checks under the store's lock either way, so an obsolete timer can + /// never fire. + fn spawn_resume_timer( + &self, + chat_id: String, + fire_at: i64, + disarmed: Arc<AtomicBool>, + delay: std::time::Duration, + ) { + let engine = self.clone(); + tokio::spawn(async move { + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + // Clock skew and a reset time minted on a lagging client clamp + // to "due now": never a panic, never a negative sleep. + let target = std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_millis(fire_at.max(0) as u64); + tokio::time::sleep_until(tokio::time::Instant::from_std(target)).await; + if disarmed.load(std::sync::atomic::Ordering::Acquire) { + return; + } + engine.fire_scheduled_resume(&chat_id, fire_at).await; + }); + } + + /// Fire one armed continuation: send the continue prompt exactly like + /// the manual Resume — a normal run, the queued messages behind it. + /// Owned by the timer task spawned in [`Self::spawn_resume_timer`]. + async fn fire_scheduled_resume(&self, chat_id: &str, fire_at: i64) { + // A shutdown that began before the timer woke leaves the record + // armed for the next boot — shutting down never consumes one. + if self + .inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + return; + } + // Consume BEFORE anything async: the record is gone before the run + // starts, so a crash mid-fire can never double-schedule it. + if !self.inner.scheduled_resumes.consume(chat_id, fire_at) { + return; // cancelled or replaced while this timer slept + } + // The chat fired this continuation — the loop guard's progress + // floor (a new limit record must beat it to re-arm). + self.inner.scheduled_resumes.note_fired(chat_id, fire_at); + self.inner.set_scheduled_resume_row(chat_id, None); + // A shutdown that raced the consume: put the record back — the next + // boot fires it overdue instead of losing the armed continuation. + if self + .inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + self.inner.scheduled_resumes.requeue(chat_id, fire_at); + return; + } + // A turn already in flight is the user's newer intent — its send + // cancelled this continuation on the way in and the timer raced + // the cancellation. Ours loses. + if self.turn_in_flight(chat_id) { + tracing::info!( + chat = %chat_id, + "scheduled resume dropped: a turn is already in flight" + ); + return; + } + let Some(host) = self.inner.doc_host() else { + tracing::warn!(chat = %chat_id, "scheduled resume dropped: no doc host"); + return; + }; + // The chat row is gone — deleted here or on another device (the + // row syncs through the workspace doc), or swept with its space — + // or the chat was archived, possibly from another device whose + // sync never ran the archive RPC here: the continuation dies with + // the chat either way, and archived work must not resume on its + // own overnight. A bare host with no workspace to ask falls + // through to the last-request configuration below. + let chat = host.workspace().and_then(|ws| ws.chat(chat_id).ok()); + match chat { + Some(None) => { + tracing::info!(chat = %chat_id, "scheduled resume dropped: the chat is gone"); + return; + } + Some(Some(chat)) if chat.archived => { + tracing::info!(chat = %chat_id, "scheduled resume dropped: the chat is archived"); + return; + } + _ => {} + } + // The continuation runs on the chat's own configuration, like the + // completion-notice turn: the chat row when there is one, else the + // last live request. `dispatch` re-derives the harness session, so + // the continuation continues the same harness conversation. + let prompt = roboco_proto::RESUME_CONTINUE_PROMPT; + let request = host.request_from_chat_row(chat_id, prompt).or_else(|| { + self.last_request(chat_id).map(|mut request| { + request.prompt = prompt.to_string(); + // Dispatch re-injects the chat's remembered harness session — + // never carry a stale resume ref into the continuation. + request.resume = None; + request.attachments = Vec::new(); + request + }) + }); + let Some(request) = request else { + tracing::warn!( + chat = %chat_id, + "scheduled resume dropped: no run configuration for the chat" + ); + return; + }; + let harness = host.harness_for_request(chat_id, &request); + // Deterministic id: a re-fire after a crash between consume and + // persistence lands as the same transcript entry (dedupe by id). + let message_id = format!("resume-at-reset:{fire_at}:{chat_id}"); + tracing::info!(chat = %chat_id, fire_at, "scheduled resume firing"); + match host + .dispatch_with_source_context( + self, + chat_id, + harness, + request, + Some(message_id), + None, + ) + .await + { + Ok(_) => { + // The continuation is the explicit prompt that thaws the + // queue the limited chat holds — the same deliberate action + // the manual Resume's run is (queued messages follow it). + if let Ok(handle) = host.open(chat_id) { + handle.thaw_queue(); + } + } + Err(err) => { + if self + .inner + .shutting_down + .load(std::sync::atomic::Ordering::Acquire) + { + // The engine went down under the dispatch: requeue for + // the next boot rather than drop the armed continuation. + self.inner.scheduled_resumes.requeue(chat_id, fire_at); + } + tracing::warn!( + chat = %chat_id, + error = %err, + "scheduled resume turn failed to start" + ); + } + } + } + /// Graceful shutdown: interrupt every live run so streaming entries settle. pub async fn shutdown(&self) { self.inner @@ -1613,6 +1928,7 @@ impl Inner { started_at: None, updated_at: now, limit: None, + scheduled_resume: None, }); // `started_at` is the elapsed-timer base and must only ever mean // "this turn". Entering Working from a settled state always @@ -1654,6 +1970,40 @@ impl Inner { } } + /// Publish the chat's pending Resume-at-reset continuation onto its + /// status row (arming and cancellation both land here), leaving the + /// status itself untouched, and mirror it into the workspace doc so + /// remote devices' banners see it. No row yet — nothing armed, nothing + /// to clear; the arm paths only ever run for a chat with a row. + fn set_scheduled_resume_row(&self, chat_id: &str, scheduled: Option<ScheduledResume>) { + if let Some(session) = self.set_scheduled_resume_row_local(chat_id, scheduled) { + if let Some(ws) = self.workspace() { + ws.record_session(&session); + } + } + } + + /// [`set_scheduled_resume_row`] without the workspace mirror: the + /// in-memory row and the watch list only. The delete paths use this — + /// they are deleting the chat's sessions row, which a mirror would + /// resurrect. Returns the updated row when there was one. + fn set_scheduled_resume_row_local( + &self, + chat_id: &str, + scheduled: Option<ScheduledResume>, + ) -> Option<Session> { + let now = Utc::now(); + let mut statuses = lock(&self.statuses); + let entry = statuses.get_mut(chat_id)?; + entry.scheduled_resume = scheduled; + entry.updated_at = now; + let session = entry.clone(); + let mut list: Vec<Session> = statuses.values().cloned().collect(); + list.sort_by(|a, b| a.chat_id.cmp(&b.chat_id)); + self.sessions_tx.send_replace(list); + Some(session) + } + /// The doc host, once wired. `None` before assembly or after retirement. fn doc_host(&self) -> Option<DocHost> { lock(&self.doc_host).clone() @@ -3736,7 +4086,19 @@ async fn drive_run( let notifiable = run_notifiable_parent(&inner, &chat_id, &run_id); inner.remove_run(&chat_id, &run_id); let final_turn_completed = final_completed_turn.is_some(); + let auto_arm_limit = limit.clone(); inner.set_status_with_limit(&chat_id, final_status, false, final_completed_turn, limit); + if let Some(limit) = auto_arm_limit + .as_ref() + .filter(|_| final_status == SessionStatus::Errored) + { + // Auto-resume limited chats (orchestration v2, ticket 07): the fresh + // limit record arms the continuation when the setting is on — only + // ever via a NEW record whose reset advances past the last fired + // continuation (the loop guard), never from the failure of the + // continuation itself. + SessionsEngine { inner: inner.clone() }.maybe_auto_arm_resume(&chat_id, limit); + } if let (Some(parent), Some((status, error))) = (notifiable, final_done) { let completed_turn = final_turn_completed; if let Some(outcome) = final_outcome(status, error.as_deref(), completed_turn) { diff --git a/crates/engine/src/workspace_host.rs b/crates/engine/src/workspace_host.rs index 6c7de97cb1..30aee1f379 100644 --- a/crates/engine/src/workspace_host.rs +++ b/crates/engine/src/workspace_host.rs @@ -1083,6 +1083,7 @@ mod tests { started_at: None, updated_at: at, limit: None, + scheduled_resume: None, } } diff --git a/crates/engine/tests/resume_at_reset.rs b/crates/engine/tests/resume_at_reset.rs new file mode 100644 index 0000000000..b0e0199b0c --- /dev/null +++ b/crates/engine/tests/resume_at_reset.rs @@ -0,0 +1,917 @@ +//! Resume at reset (orchestration v2, ticket 07): a Limited chat's durable +//! continuation, owned by the engine. It fires the continue prompt at the +//! limit's reset time exactly like the manual Resume (ticket 06) — queued +//! messages behind it; it survives engine restarts and an overdue one fires +//! after boot; a new message, an archive or a delete cancels it; the +//! **Auto-resume limited chats** setting (off by default) arms it +//! automatically when a chat becomes Limited; and a continuation that hits +//! the limit again re-arms only via a NEW limit record whose reset advances +//! past the fired one — never in a loop. +//! +//! The harness seam is the same scripted stand-in as `limited_chats.rs` +//! (`ResetHarness`): each run's script says whether it ends on a classified +//! usage-limit error (with the reset time to report) or completes, every run +//! waits for a `finish` signal before its terminal event, and the script is +//! editable between runs so a continuation can hit the limit AGAIN. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{MessagePart, MessageRole, SessionCommandPayload}; +use roboco_engine::{AgentAccounts, AgentAccountsConfig, EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, + SandboxLevel, SessionStatus, SteeringMode, +}; +use roboco_rpc::methods; + +const CHAT: &str = "chat-resume-reset"; +const CONTINUE: &str = roboco_proto::RESUME_CONTINUE_PROMPT; + +/// The per-run script: `Some(resets_at)` ends the run on a classified +/// usage-limit error reporting that reset time; `None` completes normally. +type Script = Arc<Mutex<Vec<Option<i64>>>>; + +/// A scripted harness: run `n` ends on a usage-limit error with +/// `script[n]`'s reset time when `Some`, else completes. Every run holds +/// until the test releases it, so turn-end order is deterministic. +struct ResetHarness { + finish: tokio::sync::broadcast::Sender<()>, + prompts: Arc<Mutex<Vec<String>>>, + script: Script, + runs: std::sync::atomic::AtomicUsize, +} + +impl ResetHarness { + /// `script` is the initial per-run plan (extendable between runs with + /// [`Self::set_script`]). Returns the prompts log with the harness. + fn new(script: Vec<Option<i64>>) -> (Arc<Self>, Arc<Mutex<Vec<String>>>) { + let (finish, _) = tokio::sync::broadcast::channel(16); + let prompts = Arc::new(Mutex::new(Vec::new())); + ( + Arc::new(Self { + finish, + prompts: prompts.clone(), + script: Arc::new(Mutex::new(script)), + runs: std::sync::atomic::AtomicUsize::new(0), + }), + prompts, + ) + } + + /// Rewrite one run's outcome — the "hits the limit again" scripts. + fn set_script(&self, run: usize, resets_at: Option<i64>) { + let mut script = self.script.lock().unwrap(); + while script.len() <= run { + script.push(None); + } + script[run] = resets_at; + } +} + +#[async_trait] +impl Harness for ResetHarness { + fn id(&self) -> HarnessId { + HarnessId::ClaudeCode + } + fn display_name(&self) -> &str { + "Reset" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.prompts.lock().unwrap().push(request.prompt.clone()); + let run = self.runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let resets_at = self.script.lock().unwrap().get(run).copied().flatten(); + let session = format!("sess-{run}"); + let mut finish = self.finish.subscribe(); + let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { + harness: HarnessId::ClaudeCode, + model: "reset-1".into(), + tools: vec![], + cwd: request.cwd.clone(), + session_id: session.clone(), + assistant_message_id: format!("a-{run}"), + })]); + let tail = futures::stream::once(async move { + // The turn runs until the test says it ends. + let _ = finish.recv().await; + match resets_at { + Some(resets_at) => vec![ + Ok(AgentEvent::Error { + message: "Claude usage limit reached — try again after the limit resets." + .into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(resets_at), + }), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some(session), + }), + ], + None => vec![ + Ok(AgentEvent::TextDelta { + text: "Picked the work back up.".into(), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + }), + ], + } + }) + .flat_map(futures::stream::iter); + Ok(started.chain(tail).boxed()) + } +} + +async fn wait_for<F>(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +/// Epoch ms `ms` from now — the tests' short reset times. +fn in_ms(ms: i64) -> i64 { + chrono::Utc::now().timestamp_millis() + ms +} + +fn run_request(prompt: &str) -> RunRequest { + RunRequest { + prompt: prompt.into(), + harness: Some(HarnessId::ClaudeCode), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: ".".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + mcp: None, + resume: None, + provider_fork: None, + } +} + +/// Queue a Run command the way the composer's send does. +fn queue_run(core: &EngineCore, prompt: &str, message_id: &str) { + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request: run_request(prompt), + message_id: message_id.into(), + }, + ) + .expect("queue run command"); +} + +fn prompts_contain(prompts: &Arc<Mutex<Vec<String>>>, needle: &str) -> bool { + prompts.lock().unwrap().iter().any(|p| p == needle) +} + +fn sent_prompts(prompts: &Arc<Mutex<Vec<String>>>) -> Vec<String> { + prompts.lock().unwrap().clone() +} + +fn user_texts(core: &EngineCore) -> Vec<String> { + core.doc_host + .open(CHAT) + .ok() + .and_then(|h| h.doc().read_entries().ok()) + .unwrap_or_default() + .iter() + .filter(|e| e.role == MessageRole::User) + .map(|e| { + e.parts + .iter() + .filter_map(|p| match p { + MessagePart::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::<Vec<_>>() + .join("") + }) + .collect() +} + +/// Assemble a core around the scripted harness, pre-titled, with an +/// ISOLATED accounts store, and a chat carrying a Claude config row — the +/// continuation's run configuration (the same one the completion-notice +/// turn uses: the chat row first, the last request as fallback). +async fn setup( + script: Vec<Option<i64>>, +) -> ( + EngineCore, + Arc<ResetHarness>, + Arc<Mutex<Vec<String>>>, + roboco_rpc::RpcClient, +) { + let tmp = tempfile::tempdir().unwrap(); + // Leak the guard: the engine outlives this helper; uniqueness is all the + // test needs. + let path = tmp.keep(); + setup_at(&path.join("data"), script).await +} + +/// [`setup`] over an explicit data dir — the restart test reuses one dir for +/// both engines. +async fn setup_at( + data_dir: &std::path::Path, + script: Vec<Option<i64>>, +) -> ( + EngineCore, + Arc<ResetHarness>, + Arc<Mutex<Vec<String>>>, + roboco_rpc::RpcClient, +) { + let (harness, prompts) = ResetHarness::new(script); + let registry = HarnessRegistry::new(); + registry.register(harness.clone()); + let core = EngineCore::assemble(data_dir, Arc::new(registry), HarnessId::Mock) + .expect("engine core assembles"); + core.sessions.set_agent_accounts(AgentAccounts::new( + AgentAccountsConfig::isolated(&data_dir.parent().unwrap().join("accounts")), + )); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": CHAT, + "deviceId": core.device_id, + "config": { + "harness": "claude-code", + "sandbox": "workspace-write", + }, + }), + ) + .await + .expect("createChat"); + // Pre-title so the auto-titler never dispatches a harness request of its own. + core.workspace + .rename_chat(CHAT, "Pre-titled") + .expect("rename chat"); + (core, harness, prompts, client) +} + +/// Run the opening turn into the scripted limit and wait for the chat to +/// settle Limited. +async fn run_into_limit( + core: &EngineCore, + harness: &Arc<ResetHarness>, + prompts: &Arc<Mutex<Vec<String>>>, +) { + queue_run(core, "do the work", "m-open"); + wait_for( + || prompts_contain(prompts, "do the work"), + "the limited run to start", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored && s.limit.is_some()) + }, + "the chat to settle Limited", + ) + .await; +} + +fn scheduled_fire_at(core: &EngineCore) -> Option<i64> { + core.sessions + .session_status(CHAT) + .and_then(|s| s.scheduled_resume.map(|r| r.fire_at)) +} + +/// Arm the continuation through the client the way the banner's action does. +async fn schedule_resume(client: &roboco_rpc::RpcClient) { + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "scheduleResumeAtReset", "chatId": CHAT }), + ) + .await + .expect("scheduleResumeAtReset"); +} + +/// Flip the Auto-resume limited chats engine setting through the settings RPC. +async fn set_auto_resume(client: &roboco_rpc::RpcClient, on: bool) { + let saved = client + .call( + methods::SET_ENGINE_SETTINGS, + serde_json::json!({ "autoResumeLimited": on }), + ) + .await + .expect("SetEngineSettings"); + assert_eq!(saved["autoResumeLimited"], on, "the reply is the stored value"); +} + +/// **Resume at reset fires the continuation at the reset time** — the same +/// run the manual Resume is: the continue prompt as a normal run, the queued +/// messages behind it, the limit record cleared by the new turn, the armed +/// record cleared by the firing, and the row mirrored to the workspace doc +/// (remote sidebars' banners) both ways. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resume_at_reset_fires_the_continue_prompt_at_the_reset_time() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), None]).await; + + run_into_limit(&core, &harness, &prompts).await; + // Messages the user sent while the doomed run was working — they wait + // behind the continuation, exactly like the manual Resume. + core.doc_host + .queue_message(CHAT, "queued one", Vec::new()) + .expect("queue message"); + + schedule_resume(&client).await; + // The engine arms at the limit record's own reset time — the exact time + // the harness's error reported, read back from the settled row. + let fire_at = core + .sessions + .session_status(CHAT) + .and_then(|s| s.limit.and_then(|limit| limit.resets_at)) + .expect("the limit record carries the reset time"); + wait_for( + || scheduled_fire_at(&core) == Some(fire_at), + "the armed continuation to reach the session row", + ) + .await; + // The row rides the workspace doc too — remote devices' banners. + wait_for( + || { + core.workspace + .read_sessions() + .map(|rows| { + rows.iter().any(|row| { + row.chat_id == CHAT + && row + .scheduled_resume + .is_some_and(|r| r.fire_at == fire_at) + }) + }) + .unwrap_or(false) + }, + "the armed continuation to mirror into the workspace doc", + ) + .await; + + // The reset time arrives; the engine fires the continuation. + wait_for( + || prompts_contain(&prompts, CONTINUE), + "the continuation to fire at the reset time", + ) + .await; + // The new turn owns the chat: the limit record and the armed record are + // both gone. + wait_for( + || { + core.sessions.session_status(CHAT).is_some_and(|s| { + s.status == SessionStatus::Working + && s.limit.is_none() + && s.scheduled_resume.is_none() + }) + }, + "the continuation's turn to clear the limit and armed records", + ) + .await; + // The queued rows still wait behind the continuation's turn. + assert!(!prompts_contain(&prompts, "queued one")); + + let _ = harness.finish.send(()); + wait_for( + || prompts_contain(&prompts, "queued one"), + "the queued message to follow the continuation", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the followed-up turn to settle", + ) + .await; + assert_eq!( + sent_prompts(&prompts), + vec!["do the work", CONTINUE, "queued one"], + "the continuation fired the continue prompt, the queue followed" + ); + // The transcript carries the continuation as its user entry. + assert!(user_texts(&core).contains(&CONTINUE.to_string())); + core.shutdown().await; +} + +/// **Restarts:** a pending continuation survives an engine restart, and one +/// whose time passed while the engine was down fires after boot. The +/// transcript entry is idempotent by its deterministic message id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pending_continuation_survives_a_restart_and_an_overdue_one_fires_after_boot() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("data"); + let (core, harness, prompts, client) = setup_at(&data_dir, vec![Some(in_ms(2_000))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + + // A graceful shutdown while the continuation is still pending: it must + // NOT be consumed (shutting down never fires one) — the record survives. + core.shutdown().await; + drop(core); + // The reset time passes while no engine is running. + tokio::time::sleep(Duration::from_millis(2_200)).await; + // The first engine's harness never saw a continuation. + assert!(!prompts_contain(&prompts, CONTINUE)); + + // Boot a fresh engine over the same data dir: the overdue continuation + // fires after the engine is up. + let (core, harness, prompts, _client) = setup_at(&data_dir, vec![None]).await; + wait_for( + || prompts_contain(&prompts, CONTINUE), + "the overdue continuation to fire after boot", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the continuation's turn to settle", + ) + .await; + assert_eq!(sent_prompts(&prompts), vec![CONTINUE]); + // Exactly one continuation entry — the deterministic message id keeps a + // crash-raced re-fire from duplicating the transcript row. + assert_eq!( + user_texts(&core) + .iter() + .filter(|text| text.as_str() == CONTINUE) + .count(), + 1 + ); + core.shutdown().await; +} + +/// **Cancellation by a new message:** a normal send (the composer's Run +/// command) cancels the pending continuation — the user's newer intent wins. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_new_message_cancels_the_pending_continuation() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), None]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + + // The user's own next message (a normal send, not the resume prompt). + queue_run(&core, "try a different angle", "m-next"); + wait_for( + || scheduled_fire_at(&core).is_none(), + "the new message to cancel the continuation", + ) + .await; + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the new turn to start", + ) + .await; + let _ = harness.finish.send(()); + + // The reset time comes and goes: no continuation ever fires. + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE)); + assert_eq!( + sent_prompts(&prompts), + vec!["do the work", "try a different angle"] + ); + core.shutdown().await; +} + +/// **Cancellation by a queued message:** a message the user queued on the +/// Limited chat (the queue is holding) is still their newer intent — the +/// pending continuation dies, and nothing fires at the reset time. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_queued_message_cancels_the_pending_continuation() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + + core.doc_host + .queue_message(CHAT, "queued while limited", Vec::new()) + .expect("queue message"); + wait_for( + || scheduled_fire_at(&core).is_none(), + "the queued message to cancel the continuation", + ) + .await; + + // The reset time comes and goes: the continuation never fires; the row + // stays queued behind the Limited chat's hold. + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE)); + assert_eq!(sent_prompts(&prompts), vec!["do the work"]); + core.shutdown().await; +} + +/// **Cancellation by archiving:** putting the Limited chat away cancels its +/// pending continuation — archived work must not resume on its own +/// overnight. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn archiving_the_chat_cancels_the_pending_continuation() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "setChatArchived", "chatId": CHAT, "archived": true }), + ) + .await + .expect("setChatArchived"); + wait_for( + || scheduled_fire_at(&core).is_none(), + "archiving to cancel the continuation", + ) + .await; + + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE)); + assert_eq!(sent_prompts(&prompts), vec!["do the work"]); + core.shutdown().await; +} + +/// **Archive that landed from another device** — through the synced +/// workspace row, not this engine's RPC: the arm survives (nothing told +/// this engine), and the fire path's own guard must drop it at the reset +/// time instead of resuming archived work on its own. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_remote_archive_drops_the_continuation_at_fire_time() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + // The other device's archive reaches this engine only through the + // workspace doc — the mutate RPC never ran here. + core.workspace + .set_chat_archived(CHAT, true) + .expect("remote setChatArchived"); + assert!( + scheduled_fire_at(&core).is_some(), + "nothing cancelled the arm on this engine" + ); + + // The reset time comes; the firing consumes the continuation and drops + // it at the archived-chat guard instead of dispatching. + wait_for( + || scheduled_fire_at(&core).is_none(), + "the fired continuation to consume the arm", + ) + .await; + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE), "archived work never resumes"); + assert_eq!(sent_prompts(&prompts), vec!["do the work"]); + core.shutdown().await; +} + +/// **Cancellation by deleting:** the chat is gone; its continuation is too. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deleting_the_chat_cancels_the_pending_continuation() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "deleteChat", "chatId": CHAT }), + ) + .await + .expect("deleteChat"); + wait_for( + || scheduled_fire_at(&core).is_none(), + "deleting to cancel the continuation", + ) + .await; + + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE)); + core.shutdown().await; +} + +/// **The setting, off (the default):** a chat becoming Limited arms nothing — +/// the engine settings read back off, no continuation rides the row, and +/// nothing fires when the reset time passes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_setting_defaults_to_off_and_arms_nothing() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(500))]).await; + + let settings = client + .call(methods::GET_ENGINE_SETTINGS, serde_json::json!({})) + .await + .expect("GetEngineSettings"); + assert_eq!(settings["autoResumeLimited"], false, "off by default"); + + run_into_limit(&core, &harness, &prompts).await; + assert!(scheduled_fire_at(&core).is_none(), "nothing auto-armed"); + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert!(!prompts_contain(&prompts, CONTINUE)); + core.shutdown().await; +} + +/// **The setting, on:** the engine arms the continuation automatically when +/// the chat becomes Limited — no click needed — and it fires at the reset +/// time exactly like the manual arm. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_setting_on_arms_the_continuation_automatically() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), None]).await; + set_auto_resume(&client, true).await; + + run_into_limit(&core, &harness, &prompts).await; + // The auto-arm targets the record's reset time — still ahead. + let fire_at = scheduled_fire_at(&core).expect("auto-armed when the chat went Limited"); + let reported = core + .sessions + .session_status(CHAT) + .and_then(|s| s.limit.and_then(|limit| limit.resets_at)) + .expect("the limit record"); + assert_eq!(fire_at, reported, "the auto-arm fires at the reset time"); + + wait_for( + || prompts_contain(&prompts, CONTINUE), + "the auto-armed continuation to fire at the reset time", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the continuation's turn to settle", + ) + .await; + assert_eq!(sent_prompts(&prompts), vec!["do the work", CONTINUE]); + core.shutdown().await; +} + +/// **Overlap, re-armed via a new limit record:** with the setting on, a +/// continuation that fires into a STILL-limited chat becomes Limited again +/// with the new reset time — and the fresh record re-arms it (the new reset +/// genuinely advances past the fired one). The work continues across two +/// limit windows without a click. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_continuation_that_hits_the_limit_again_rearms_via_the_new_record() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), Some(in_ms(6_000)), None]).await; + set_auto_resume(&client, true).await; + + // Turn one ends limited at +400ms → auto-armed. + run_into_limit(&core, &harness, &prompts).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the first limit to auto-arm", + ) + .await; + + // The continuation fires into the same wall — still limited — with a NEW + // (later) reset time. + wait_for( + || prompts_contain(&prompts, CONTINUE), + "the first continuation to fire", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions.session_status(CHAT).is_some_and(|s| { + s.status == SessionStatus::Errored + && s.limit.is_some_and(|l| l.resets_at.is_some()) + }) + }, + "the continuation to hit the limit again", + ) + .await; + // The fresh record re-armed the continuation at the NEW reset time. + wait_for( + || { + scheduled_fire_at(&core) + .is_some_and(|at| at > chrono::Utc::now().timestamp_millis()) + }, + "the new limit record to re-arm the continuation", + ) + .await; + + // The second continuation fires at the new reset time and completes. + wait_for( + || { + prompts + .lock() + .unwrap() + .iter() + .filter(|p| p.as_str() == CONTINUE) + .count() + == 2 + }, + "the re-armed continuation to fire", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the second continuation's turn to settle", + ) + .await; + assert_eq!(sent_prompts(&prompts), vec!["do the work", CONTINUE, CONTINUE]); + core.shutdown().await; +} + +/// **Overlap, never in a loop:** the fired continuation is consumed — a +/// second limit record whose reset does NOT advance past the fired one (the +/// same wall, already past) re-arms nothing. One continuation, then the +/// chat sits Limited waiting for the user, not cycling on its own. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_stale_reset_time_never_rearms_into_a_loop() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), Some(in_ms(2_500))]).await; + set_auto_resume(&client, true).await; + + run_into_limit(&core, &harness, &prompts).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the first limit to auto-arm", + ) + .await; + + // The continuation fires; the run hits the limit again reporting the + // SAME reset time — already in the past by now. + wait_for( + || prompts_contain(&prompts, CONTINUE), + "the continuation to fire", + ) + .await; + let _ = harness.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored) + }, + "the continuation to hit the limit again", + ) + .await; + // No re-arm: the record's reset does not advance past the fired one. + assert!( + scheduled_fire_at(&core).is_none(), + "a stale reset time never re-arms the continuation" + ); + + // And it stays that way — no second continuation, ever. + tokio::time::sleep(Duration::from_millis(3_000)).await; + assert_eq!(sent_prompts(&prompts), vec!["do the work", CONTINUE]); + // The chat is Limited again with the new record — waiting for the user. + assert!(core + .sessions + .session_status(CHAT) + .is_some_and(|s| s.limit.is_some())); + core.shutdown().await; +} + +/// Scheduling requires a Limited chat with a reset time still ahead: a +/// not-limited chat refuses with an actionable error (the honest offer is +/// the manual Resume), and a working arm cancels cleanly and idempotently. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scheduling_refuses_chats_without_an_upcoming_reset_time() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(60_000))]).await; + + // Not Limited yet. + let err = client + .call( + methods::MUTATE, + serde_json::json!({ "op": "scheduleResumeAtReset", "chatId": CHAT }), + ) + .await + .expect_err("not Limited"); + assert!(err.to_string().contains("not Limited"), "{err}"); + + run_into_limit(&core, &harness, &prompts).await; + // A reset far in the future arms fine — then cancels cleanly. + schedule_resume(&client).await; + wait_for( + || scheduled_fire_at(&core).is_some(), + "the continuation to arm", + ) + .await; + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "cancelScheduledResume", "chatId": CHAT }), + ) + .await + .expect("cancelScheduledResume"); + assert!(scheduled_fire_at(&core).is_none()); + // Cancelling again is a no-op, not an error. + client + .call( + methods::MUTATE, + serde_json::json!({ "op": "cancelScheduledResume", "chatId": CHAT }), + ) + .await + .expect("cancelScheduledResume is idempotent"); + core.shutdown().await; +} + +/// An already-due reset refuses: there is nothing left to wait for, and the +/// caller is pointed back at the manual Resume. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn scheduling_refuses_an_already_due_reset() { + let (core, harness, prompts, client) = setup(vec![Some(in_ms(-1))]).await; + run_into_limit(&core, &harness, &prompts).await; + let err = client + .call( + methods::MUTATE, + serde_json::json!({ "op": "scheduleResumeAtReset", "chatId": CHAT }), + ) + .await + .expect_err("the reset already passed"); + assert!(err.to_string().contains("already reset"), "{err}"); + core.shutdown().await; +} diff --git a/crates/mobile-client/src/clock.rs b/crates/mobile-client/src/clock.rs index 5711437bb9..4cac9b87e7 100644 --- a/crates/mobile-client/src/clock.rs +++ b/crates/mobile-client/src/clock.rs @@ -84,6 +84,7 @@ mod tests { started_at: Some(updated - TimeDelta::minutes(5)), updated_at: updated, limit: None, + scheduled_resume: None, } } diff --git a/crates/mobile-client/src/notify.rs b/crates/mobile-client/src/notify.rs index 9d7d67dc8a..ebc0c49f69 100644 --- a/crates/mobile-client/src/notify.rs +++ b/crates/mobile-client/src/notify.rs @@ -693,6 +693,7 @@ mod tests { started_at: Some(updated - chrono::TimeDelta::minutes(5)), updated_at: updated, limit: None, + scheduled_resume: None, } } diff --git a/crates/mobile-client/src/session/composer.rs b/crates/mobile-client/src/session/composer.rs index e33869a95e..ccda754a68 100644 --- a/crates/mobile-client/src/session/composer.rs +++ b/crates/mobile-client/src/session/composer.rs @@ -339,6 +339,7 @@ mod tests { started_at: Some(updated - TimeDelta::minutes(5)), updated_at: updated, limit: None, + scheduled_resume: None, } } diff --git a/crates/mobile-client/src/workspace/view.rs b/crates/mobile-client/src/workspace/view.rs index d52e3f20ef..8df6e24969 100644 --- a/crates/mobile-client/src/workspace/view.rs +++ b/crates/mobile-client/src/workspace/view.rs @@ -1295,6 +1295,7 @@ mod tests { started_at: Some(at(updated)), updated_at: at(updated), limit: None, + scheduled_resume: None, } } diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 4b4570db9c..5d68d9cf54 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -285,6 +285,19 @@ pub struct SessionLimit { pub account_id: Option<String>, } +/// A pending **Resume at reset** continuation (orchestration v2): the +/// engine will send the continue prompt to the Limited chat at `fire_at` +/// — its limit's reset time — unless a newer message, archive or delete +/// cancels it first. Durable: it survives engine restarts, and one whose +/// time passed while the engine was down fires after boot. Absent from +/// every other status row; cleared with the record it armed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ScheduledResume { + /// Epoch millis when the continuation fires. + pub fire_at: i64, +} + /// Live run status for a chat — drives the Working indicator and sidebar status dots. /// Staleness-checked client-side against `updated_at` so a crashed backend never shows /// an eternal "Working". @@ -306,6 +319,11 @@ pub struct Session { /// [`SessionLimit`]). Absent on rows from older engines. #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option<SessionLimit>, + /// The pending Resume-at-reset continuation, when one is armed for this + /// Limited chat (see [`ScheduledResume`]). Absent on rows from older + /// engines and on chats with nothing armed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scheduled_resume: Option<ScheduledResume>, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] @@ -1808,6 +1826,7 @@ mod tests { started_at: None, updated_at: Utc.with_ymd_and_hms(2026, 10, 16, 12, 0, 0).unwrap(), limit: None, + scheduled_resume: None, } } @@ -1854,4 +1873,39 @@ mod tests { let encoded = serde_json::to_value(&old).unwrap(); assert!(encoded.get("limit").is_none()); } + + /// The scheduled-resume record is additive on the session row exactly + /// like the limit one (ADR 0005): stable camelCase, omitted when absent, + /// and rows from older engines still parse with nothing armed. + #[test] + fn session_scheduled_resume_row_is_additive_and_round_trips() { + let mut armed = session_row(SessionStatus::Errored); + armed.scheduled_resume = Some(ScheduledResume { + fire_at: 1_762_675_400_000, + }); + let value = serde_json::to_value(&armed).unwrap(); + assert_eq!(value["scheduledResume"]["fireAt"], 1_762_675_400_000); + assert_eq!(serde_json::from_value::<Session>(value).unwrap(), armed); + + // Nothing armed: the field is omitted, not null. + let idle = session_row(SessionStatus::Idle); + let value = serde_json::to_value(&idle).unwrap(); + assert!(value.get("scheduledResume").is_none()); + + // Older engines never wrote `scheduledResume`; nothing parses as armed. + let legacy = serde_json::json!({ + "chatId": "chat-1", + "deviceId": "device-1", + "status": "errored", + "limit": { + "harness": "claude-code", + "window": "5-hour", + "resetsAt": 1_762_675_400_000, + }, + "updatedAt": "2026-10-16T12:00:00Z", + }); + let old = serde_json::from_value::<Session>(legacy).unwrap(); + assert!(old.limit.is_some()); + assert_eq!(old.scheduled_resume, None); + } } diff --git a/crates/proto/src/view.rs b/crates/proto/src/view.rs index e250a0ac37..9ccdd91829 100644 --- a/crates/proto/src/view.rs +++ b/crates/proto/src/view.rs @@ -76,24 +76,38 @@ pub fn effective_indicator(session: Option<&Session>, now: DateTime<Utc>) -> Ind /// 2h 05m"). A reset already past reads as due — the honest offer is Resume, /// not a countdown into the past. Pure. pub fn format_limit_reset(resets_at: i64, now: DateTime<Utc>) -> String { - let until = chrono::TimeDelta::milliseconds(resets_at - now.timestamp_millis()); + relative_reset_text("resets", resets_at, now) +} + +/// Banner text for a chat's armed Resume-at-reset continuation ("resumes in +/// 45m", "resumes in 2h 05m"). Same shape as [`format_limit_reset`]; a fire +/// time already past reads as due — the continuation is running or about to. +/// Pure. +pub fn format_scheduled_resume(fire_at: i64, now: DateTime<Utc>) -> String { + relative_reset_text("resumes", fire_at, now) +} + +/// The shared relative countdown behind both Limited banner texts — one +/// wall-clock deadline, one phrasing, verb-parameterized only. +fn relative_reset_text(verb: &str, at: i64, now: DateTime<Utc>) -> String { + let until = chrono::TimeDelta::milliseconds(at - now.timestamp_millis()); if until.num_seconds() <= 0 { - return "resets now".to_string(); + return format!("{verb} now"); } let minutes = until.num_minutes(); if minutes < 60 { - return format!("resets in {minutes}m"); + return format!("{verb} in {minutes}m"); } let hours = minutes / 60; let rest = minutes % 60; if hours < 24 { return if rest == 0 { - format!("resets in {hours}h") + format!("{verb} in {hours}h") } else { - format!("resets in {hours}h {rest:02}m") + format!("{verb} in {hours}h {rest:02}m") }; } - format!("resets in {}d", hours / 24) + format!("{verb} in {}d", hours / 24) } /// The full display status for a chat row / tab dot: live states win, then the @@ -766,6 +780,7 @@ mod limited_tests { started_at: None, updated_at: Utc::now(), limit, + scheduled_resume: None, } } @@ -812,6 +827,21 @@ mod limited_tests { assert_eq!(format_limit_reset(ms(-600), now), "resets now"); assert_eq!(format_limit_reset(now.timestamp_millis(), now), "resets now"); } + + #[test] + fn scheduled_resume_text_is_relative_and_never_counts_into_the_past() { + let now = Utc.with_ymd_and_hms(2026, 10, 16, 12, 0, 0).unwrap(); + let ms = |secs: i64| (now + chrono::TimeDelta::seconds(secs)).timestamp_millis(); + assert_eq!(format_scheduled_resume(ms(45 * 60), now), "resumes in 45m"); + assert_eq!(format_scheduled_resume(ms(30), now), "resumes in 0m"); + assert_eq!(format_scheduled_resume(ms(3 * 3600 + 5 * 60), now), "resumes in 3h 05m"); + assert_eq!(format_scheduled_resume(ms(7 * 3600), now), "resumes in 7h"); + assert_eq!(format_scheduled_resume(ms(2 * 86_400 + 3 * 3600), now), "resumes in 2d"); + // A fire time already due: the continuation is about to run, not + // counting into the past. + assert_eq!(format_scheduled_resume(ms(-600), now), "resumes now"); + assert_eq!(format_scheduled_resume(now.timestamp_millis(), now), "resumes now"); + } } #[cfg(test)] @@ -915,6 +945,7 @@ mod notification_tests { started_at: None, updated_at: updated, limit: None, + scheduled_resume: None, }; let working = SessionNotificationState::new(&session(SessionStatus::Working, fresh), now); assert_eq!(working.indicator, Indicator::Working); diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index d25f05af58..6b8f4231a0 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1503,6 +1503,7 @@ mod tests { updated_at: chrono::Utc::now() - chrono::Duration::minutes(10), last_completed_turn: None, limit: None, + scheduled_resume: None, }; // No mid-turn capability is required for a live mailbox delivery. let sent = tools diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 752398c04e..0d7e751455 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -53,6 +53,12 @@ pub mod methods { /// replies with the device's fresh `ListHarnesses` catalog. pub const GET_TITLE_SETTINGS: &str = "GetTitleSettings"; pub const SET_TITLE_SETTINGS: &str = "SetTitleSettings"; + /// The engine's own user-facing settings (Settings → General on desktop; + /// orchestration v2, ticket 07): `autoResumeLimited` — arm Resume-at-reset + /// automatically whenever a chat becomes Limited. `{}` → EngineSettings; + /// Set takes the full EngineSettings and replies with the stored one. + pub const GET_ENGINE_SETTINGS: &str = "GetEngineSettings"; + pub const SET_ENGINE_SETTINGS: &str = "SetEngineSettings"; pub const SET_HARNESS_ENABLED: &str = "SetHarnessEnabled"; pub const LIST_MODELS: &str = "ListModels"; pub const LIST_SKILLS: &str = "ListSkills"; diff --git a/crates/ui/examples/macos-resource-profile.rs b/crates/ui/examples/macos-resource-profile.rs index 150e2e234a..fbe6af5f73 100644 --- a/crates/ui/examples/macos-resource-profile.rs +++ b/crates/ui/examples/macos-resource-profile.rs @@ -158,6 +158,7 @@ fn main() -> anyhow::Result<()> { started_at: Some(chrono::Utc::now()), updated_at: chrono::Utc::now(), limit: None, + scheduled_resume: None, }]); }) }); diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index caac10f69f..40fe08e785 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -12118,6 +12118,7 @@ mod tests { started_at: Some(updated), updated_at: updated, limit: None, + scheduled_resume: None, }]; state.selected_chat = Some("c".into()); state.watch_clock_transitions(cx); diff --git a/crates/ui/src/settings.rs b/crates/ui/src/settings.rs index 36911a8af4..d4ba842d0e 100644 --- a/crates/ui/src/settings.rs +++ b/crates/ui/src/settings.rs @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize}; pub mod accounts; pub mod appearance; pub mod archived; +pub mod auto_resume; pub mod composer; pub mod devices; pub mod files; diff --git a/crates/ui/src/settings/auto_resume.rs b/crates/ui/src/settings/auto_resume.rs new file mode 100644 index 0000000000..a2f5cd8fdc --- /dev/null +++ b/crates/ui/src/settings/auto_resume.rs @@ -0,0 +1,184 @@ +//! Settings → General → Auto-resume limited chats (orchestration v2, +//! ticket 07): the ENGINE setting that arms the Resume-at-reset continuation +//! automatically whenever a chat becomes Limited. Off by default — an engine +//! that starts spending turns on its own is opt-in. Read and written through +//! the engine's `GetEngineSettings` / `SetEngineSettings` (the engine acts on +//! it even with the UI closed), persisted under the engine data dir. + +use gpui::{AnyElement, Context, Entity, Subscription, Task, div, prelude::*, px}; +use roboco_engine::EngineSettings; +use roboco_rpc::methods; + +use crate::popover::Loadable; +use crate::settings::widgets; +use crate::state::AppState; +use crate::theme::Theme; + +pub struct AutoResumeCard { + state: Entity<AppState>, + settings: Loadable<EngineSettings>, + task: Option<Task<()>>, + _state: Subscription, +} + +impl AutoResumeCard { + pub fn new(state: Entity<AppState>, cx: &mut Context<Self>) -> Self { + // Settings can open before the engine connects: load once it does. + let state_sub = cx.observe(&state, |this: &mut Self, _, cx| { + if matches!(this.settings, Loadable::Idle) { + this.load(cx); + } + }); + let mut card = Self { + state, + settings: Loadable::Idle, + task: None, + _state: state_sub, + }; + card.load(cx); + card + } + + /// `GetEngineSettings`, or `SetEngineSettings` when saving; either reply + /// is the engine's authoritative settings. + fn load(&mut self, cx: &mut Context<Self>) { + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + if matches!(self.settings, Loadable::Idle) { + self.settings = Loadable::Loading; + } + self.task = Some(cx.spawn(async move |this, cx| { + let result = engine + .client() + .call(methods::GET_ENGINE_SETTINGS, serde_json::json!({})) + .await + .map_err(|e| e.to_string()) + .and_then(|v| serde_json::from_value::<EngineSettings>(v).map_err(|e| e.to_string())); + this.update(cx, |card, cx| { + match result { + Ok(settings) => { + card.settings = Loadable::Ready(settings); + } + Err(error) => card.settings = Loadable::Error(error), + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + /// Flip the toggle and persist it through the engine. + fn toggle(&mut self, cx: &mut Context<Self>) { + let Loadable::Ready(current) = &self.settings else { + return; + }; + let next = EngineSettings { + auto_resume_limited: !current.auto_resume_limited, + }; + self.settings = Loadable::Ready(next); + let Some(engine) = self.state.read(cx).engine().cloned() else { + return; + }; + self.task = Some(cx.spawn(async move |this, cx| { + let result = engine + .client() + .call( + methods::SET_ENGINE_SETTINGS, + serde_json::to_value(next).unwrap_or_default(), + ) + .await + .map_err(|e| e.to_string()); + this.update(cx, |card, cx| { + if let Err(error) = result { + // Show the failure; the next load reconciles the toggle + // with what the engine actually kept. + card.settings = Loadable::Error(error); + } + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } + + /// Retry the load after an error (the error strip's action). + fn retry(&mut self, cx: &mut Context<Self>) { + self.settings = Loadable::Idle; + self.load(cx); + } +} + +impl Render for AutoResumeCard { + fn render(&mut self, _: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { + let theme = Theme::of(cx).for_settings_surface(); + let enabled = self + .settings + .ready() + .is_some_and(|settings| settings.auto_resume_limited); + let control: AnyElement = match &self.settings { + Loadable::Ready(_) => widgets::toggle_switch(theme, enabled, "auto-resume-limited") + .id("auto-resume-limited-toggle") + .tab_index(0) + .role(gpui::Role::Switch) + .aria_label("Auto-resume limited chats") + .aria_toggled(if enabled { + gpui::Toggled::True + } else { + gpui::Toggled::False + }) + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .cursor_pointer() + .on_click(cx.listener(|card, _, _, cx| card.toggle(cx))) + .into_any_element(), + Loadable::Error(error) => div() + .flex() + .flex_col() + .items_end() + .gap(px(4.0)) + .child( + div() + .text_color(theme.danger) + .child(error.clone()) + .into_any_element(), + ) + .child( + widgets::text_action(&theme, widgets::ActionTone::Quiet, "Retry") + .id("auto-resume-retry") + .tab_index(0) + .role(gpui::Role::Button) + .aria_label("Retry loading engine settings") + .focus_visible(|s| s.border_2().border_color(theme.accent)) + .cursor_pointer() + .on_click(cx.listener(|card, _, _, cx| card.retry(cx))), + ) + .into_any_element(), + Loadable::Idle | Loadable::Loading => div() + .text_color(theme.text_muted) + .child("Loading…") + .into_any_element(), + }; + widgets::section_card(&theme) + .child( + widgets::card_row(&theme, true) + .child( + div() + .flex_1() + .min_w(px(160.0)) + .child(widgets::row_title(&theme, "Auto-resume limited chats")) + .child(widgets::meta_line( + &theme, + vec![div() + .child( + "Continue Limited chats automatically when their \ + limit resets. Survives restarts; your messages \ + always cancel the scheduled resume.", + ) + .into_any_element()], + )), + ) + .child(control), + ) + } +} diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index 738da98b40..5c030aef57 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -87,6 +87,8 @@ pub struct ShortcutsPage { completion_task: Option<gpui::Task<()>>, /// Settings → General's thread naming card (its own title-bound picker). thread_naming: Entity<crate::settings::thread_naming::ThreadNamingCard>, + /// Settings → General's auto-resume card (engine settings RPC). + auto_resume: Entity<crate::settings::auto_resume::AutoResumeCard>, } impl EventEmitter<ShortcutsEvent> for ShortcutsPage {} @@ -130,6 +132,8 @@ impl ShortcutsPage { completion_task: None, thread_naming: cx .new(|cx| crate::settings::thread_naming::ThreadNamingCard::new(state, cx)), + auto_resume: cx + .new(|cx| crate::settings::auto_resume::AutoResumeCard::new(state, cx)), } } @@ -497,7 +501,8 @@ impl ShortcutsPage { .child(compact_model_picker_row) .child(escape_behavior_row), ) - .child(self.thread_naming.clone()), + .child(self.thread_naming.clone()) + .child(self.auto_resume.clone()), ), ) .fade_overflow_y(&self.scroll.scroll), diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index d8a4835c44..4655b04008 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -51,7 +51,7 @@ use crate::settings::{ }; use crate::state::{ AppState, ConnectionStatus, EngineBootConfig, GatePhase, Indicator, format_limit_reset, - format_time_ago, + format_scheduled_resume, format_time_ago, }; use crate::terminal::panel::{TerminalPanel, ToggleTerminal, clamp_terminal_height}; use crate::theme::Theme; @@ -5055,6 +5055,32 @@ impl Shell { self.archive_chat(chat_id, cx); } + /// **Resume at reset** on a Limited chat (orchestration v2, ticket 07): + /// arm the engine's durable continuation — at the limit's reset time the + /// engine sends the continue prompt like the manual Resume, and the + /// banner flips to "Resumes at …" with Cancel. The engine owns the + /// schedule; this only expresses the user's intent. + pub(crate) fn schedule_resume_at_reset(&mut self, chat_id: String, cx: &mut Context<Self>) { + let op = serde_json::json!({ + "op": "scheduleResumeAtReset", + "chatId": chat_id, + }); + self.mutate(&chat_id, op, cx); + cx.notify(); + } + + /// Cancel a Limited chat's armed Resume-at-reset continuation — the + /// banner's Cancel action. A new message, archive or delete cancels it + /// on its own; this is the explicit withdrawal. + pub(crate) fn cancel_scheduled_resume(&mut self, chat_id: String, cx: &mut Context<Self>) { + let op = serde_json::json!({ + "op": "cancelScheduledResume", + "chatId": chat_id, + }); + self.mutate(&chat_id, op, cx); + cx.notify(); + } + pub(super) fn set_chat_archived( &mut self, chat_id: String, @@ -9548,21 +9574,40 @@ impl Shell { .into_any_element(), // LIMITED (orchestration v2): the run ended on a harness usage // limit. The banner names the window and its reset (from the - // harness's own error, else the account's cached windows), and + // harness's own error, else the account's cached windows). // Resume sends the continue prompt as a normal run — queued - // messages stay behind it. + // messages stay behind it. Resume at reset arms the engine's + // durable continuation; once armed the banner shows when it + // fires and offers Cancel instead (ticket 07). Indicator::Limited => { let limit = state.limit_for(&chat_id, now); - let label: SharedString = match &limit { - Some(limit) => { + let scheduled = state.scheduled_resume_for(&chat_id, now); + let label: SharedString = match (&limit, &scheduled) { + (Some(limit), Some(scheduled)) => format!( + "{} limit · {}", + limit.window, + format_scheduled_resume(scheduled.fire_at, now) + ) + .into(), + (Some(limit), None) => { let reset = limit .resets_at .map(|at| format_limit_reset(at, now)) .unwrap_or_else(|| "reset time unknown".into()); format!("{} limit · {reset}", limit.window).into() } - None => "Usage limit reached".into(), + (None, _) => "Usage limit reached".into(), }; + // Resume at reset is offered only when there is a reset time + // still ahead to wait for; a due or unknown reset leaves the + // honest offer as plain Resume. + let armable = scheduled.is_none() + && limit + .as_ref() + .and_then(|limit| limit.resets_at) + .is_some_and(|at| at > now.timestamp_millis()); + let arm_chat_id = chat_id.clone(); + let cancel_chat_id = chat_id.clone(); strip .text_color(theme.warning) .child(div() @@ -9575,6 +9620,57 @@ impl Shell { .rounded_full() .bg(theme.warning)) .child(label)) + .when(armable, |strip| { + strip.child( + div() + .id("limited-schedule-resume") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_click(cx.listener(move |this, _, _, cx| { + this.schedule_resume_at_reset( + arm_chat_id.clone(), + cx, + ); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Continue automatically when the limit resets", + )) + .child(SharedString::from("Resume at reset")), + ) + }) + .when_some(scheduled, |strip, _| { + strip.child( + div() + .id("limited-cancel-resume") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_click(cx.listener(move |this, _, _, cx| { + this.cancel_scheduled_resume(cancel_chat_id.clone(), cx); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Stop the automatic continuation", + )) + .child(SharedString::from("Cancel")), + ) + }) .child( div() .id("limited-resume") @@ -11936,6 +12032,7 @@ mod tests { started_at: Some(updated_at), updated_at, limit: None, + scheduled_resume: None, } } diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index d647b06b3e..5cfc43de2a 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -607,8 +607,9 @@ async fn query_engine_info(client: &RpcClient) -> Result<EngineInfo, RpcError> { // this crate reads them as `state::…`. pub use roboco_proto::view::{ ChatGroup, ConnectionStatus, GatePhase, Indicator, SESSION_STALE_MS, attention_rank, - chat_location, display_status, effective_indicator, format_limit_reset, format_time_ago, - gate_phase, group_chats, project_label, sort_active, sort_chats, sort_spaces, sort_tabs, + chat_location, display_status, effective_indicator, format_limit_reset, + format_scheduled_resume, format_time_ago, gate_phase, group_chats, project_label, + sort_active, sort_chats, sort_spaces, sort_tabs, }; // --------------------------------------------------------------------------- @@ -2172,6 +2173,21 @@ impl AppState { .flatten() } + /// The pending **Resume at reset** continuation for `chat_id`, when its + /// status row reads as Limited and one is armed: the fire time the + /// banner's "Resumes at …" segment and Cancel action act on. `None` + /// when nothing is armed (the banner then offers Resume at reset). + pub fn scheduled_resume_for( + &self, + chat_id: &str, + now: DateTime<Utc>, + ) -> Option<roboco_proto::ScheduledResume> { + let session = self.session_for(chat_id)?; + (self.indicator_for(chat_id, now) == Indicator::Limited) + .then(|| session.scheduled_resume) + .flatten() + } + /// Whether `chat_id` reads as Limited right now — the cheap marker /// predicate for row dots (the record itself is [`Self::limit_for`]). pub fn is_limited(&self, chat_id: &str, now: DateTime<Utc>) -> bool { @@ -4000,6 +4016,7 @@ mod tests { started_at: None, updated_at: now - TimeDelta::seconds(updated_secs_ago), limit: None, + scheduled_resume: None, } } @@ -4043,6 +4060,7 @@ mod tests { started_at: Some(now), updated_at: now, limit: None, + scheduled_resume: None, }; assert!(state.apply_sessions_at(vec![row.clone()], now)); row.updated_at = now + TimeDelta::seconds(30); @@ -4075,6 +4093,7 @@ mod tests { started_at: Some(now), updated_at: now, limit: None, + scheduled_resume: None, }]; let mut row = device("host", "Host"); row.last_seen_at = Some(now); @@ -4356,6 +4375,49 @@ mod tests { assert_eq!(state.limit_for("c", now), None); } + /// A Limited chat with an armed Resume-at-reset continuation exposes + /// it through the state derivation — the banner's "Resumes at …" + /// segment and Cancel action — and only while the row reads Limited: a + /// row that is working again (the continuation fired, or a new turn) + /// shows nothing scheduled, record or no record. + #[test] + fn limited_rows_expose_their_armed_scheduled_resume() { + let mut state = AppState::new(); + let now = Utc::now(); + let mut limited = session("c", SessionStatus::Errored, 10, now); + limited.limit = Some(roboco_proto::SessionLimit { + harness: roboco_proto::HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some((now + TimeDelta::hours(2)).timestamp_millis()), + account_id: None, + }); + // Nothing armed: the banner offers Resume at reset, no scheduled + // state to show. + state.sessions = vec![limited.clone()]; + assert_eq!(state.indicator_for("c", now), Indicator::Limited); + assert_eq!(state.scheduled_resume_for("c", now), None); + + // Armed: the continuation rides the same row. + let armed = roboco_proto::ScheduledResume { + fire_at: (now + TimeDelta::hours(2)).timestamp_millis(), + }; + limited.scheduled_resume = Some(armed); + state.sessions = vec![limited.clone()]; + assert_eq!(state.scheduled_resume_for("c", now), Some(armed)); + + // The new turn clears both records: a Working row shows nothing + // scheduled (the engine cancels the continuation on the send). + limited.status = SessionStatus::Working; + state.sessions = vec![limited.clone()]; + assert_eq!(state.indicator_for("c", now), Indicator::Working); + assert_eq!(state.scheduled_resume_for("c", now), None); + // A cancelled continuation on a still-errored row is also nothing. + limited.status = SessionStatus::Errored; + limited.scheduled_resume = None; + state.sessions = vec![limited]; + assert_eq!(state.scheduled_resume_for("c", now), None); + } + #[test] fn display_status_derivation() { let now = Utc::now(); diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 5773bcacba..afdb732845 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -10036,6 +10036,7 @@ mod tests { started_at: None, updated_at: now, limit: None, + scheduled_resume: None, }]; }); assert!( @@ -13701,6 +13702,7 @@ mod tests { started_at: Some(chrono::Utc::now()), updated_at: chrono::Utc::now(), limit: None, + scheduled_resume: None, }) }); this.on_own_send("chat".into(), "prompt".into(), cx); diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index bdf8b4c032..d984b771cb 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -132,6 +132,7 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::SessionStatus, roboco_proto::Session, roboco_proto::SessionLimit, + roboco_proto::ScheduledResume, roboco_proto::Repo, roboco_proto::RepoRef, roboco_proto::GitHistoryRefKind, @@ -292,6 +293,7 @@ fn export_all(cfg: &Config) -> Result<()> { export!( roboco_engine::registry::HarnessDescriptor, roboco_engine::registry::TitleSettings, + roboco_engine::EngineSettings, roboco_engine::uploads::AttachmentChunk, roboco_engine::uploads::AttachmentTransfer, roboco_engine::doc_host::BeginQueueEditOutcome, diff --git a/web/packages/proto/src/generated/EngineSettings.ts b/web/packages/proto/src/generated/EngineSettings.ts new file mode 100644 index 0000000000..16a01b8ed3 --- /dev/null +++ b/web/packages/proto/src/generated/EngineSettings.ts @@ -0,0 +1,13 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * The engine's user-facing settings. Additive only (ADR 0005): every field + * carries `#[serde(default)]` so settings files from older engines — and + * older engines reading newer files — keep loading. + */ +export type EngineSettings = { +/** + * Arm **Resume at reset** automatically whenever a chat becomes + * Limited (orchestration v2, ticket 07). Off by default. + */ +autoResumeLimited: boolean, }; diff --git a/web/packages/proto/src/generated/MutateParams.ts b/web/packages/proto/src/generated/MutateParams.ts index 5c1d734809..1d87ac82f2 100644 --- a/web/packages/proto/src/generated/MutateParams.ts +++ b/web/packages/proto/src/generated/MutateParams.ts @@ -39,4 +39,4 @@ parentChatId?: string, * notify preference (default on). Absent on UI creations — forks * and blank side chats never notify. */ -agentOrigin?: AgentOrigin, } | { "op": "createSpace", spaceId: string, deviceId: string, path: string, name?: string, gitDetected: boolean, } | { "op": "renameSpace", spaceId: string, name?: string, } | { "op": "deleteSpace", spaceId: string, } | { "op": "renameChat", chatId: string, title: string, } | { "op": "setChatBranch", chatId: string, branch: string, } | { "op": "setChatCwd", chatId: string, cwd: string, } | { "op": "setChatActivity", chatId: string, lastMessageAt?: number, createdAt?: number, } | { "op": "setChatHost", chatId: string, deviceId: string, } | { "op": "setChatArchived", chatId: string, archived: boolean, } | { "op": "setChatConfig", chatId: string, config: ChatConfig, } | { "op": "deleteChat", chatId: string, } | { "op": "renameDevice", deviceId: string, name: string, } | { "op": "markChatSeen", chatId: string, at?: number, }; +agentOrigin?: AgentOrigin, } | { "op": "createSpace", spaceId: string, deviceId: string, path: string, name?: string, gitDetected: boolean, } | { "op": "renameSpace", spaceId: string, name?: string, } | { "op": "deleteSpace", spaceId: string, } | { "op": "renameChat", chatId: string, title: string, } | { "op": "setChatBranch", chatId: string, branch: string, } | { "op": "setChatCwd", chatId: string, cwd: string, } | { "op": "setChatActivity", chatId: string, lastMessageAt?: number, createdAt?: number, } | { "op": "setChatHost", chatId: string, deviceId: string, } | { "op": "setChatArchived", chatId: string, archived: boolean, } | { "op": "setChatConfig", chatId: string, config: ChatConfig, } | { "op": "deleteChat", chatId: string, } | { "op": "renameDevice", deviceId: string, name: string, } | { "op": "markChatSeen", chatId: string, at?: number, } | { "op": "scheduleResumeAtReset", chatId: string, } | { "op": "cancelScheduledResume", chatId: string, }; diff --git a/web/packages/proto/src/generated/ScheduledResume.ts b/web/packages/proto/src/generated/ScheduledResume.ts new file mode 100644 index 0000000000..a0bc279f83 --- /dev/null +++ b/web/packages/proto/src/generated/ScheduledResume.ts @@ -0,0 +1,15 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). + +/** + * A pending **Resume at reset** continuation (orchestration v2): the + * engine will send the continue prompt to the Limited chat at `fire_at` + * — its limit's reset time — unless a newer message, archive or delete + * cancels it first. Durable: it survives engine restarts, and one whose + * time passed while the engine was down fires after boot. Absent from + * every other status row; cleared with the record it armed. + */ +export type ScheduledResume = { +/** + * Epoch millis when the continuation fires. + */ +fireAt: number, }; diff --git a/web/packages/proto/src/generated/Session.ts b/web/packages/proto/src/generated/Session.ts index 2739d99d9f..01bb112d80 100644 --- a/web/packages/proto/src/generated/Session.ts +++ b/web/packages/proto/src/generated/Session.ts @@ -1,6 +1,7 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). // Legacy wire name (ADR 0005): the canonical term is "Chat status" — see CONTEXT.md. +import type { ScheduledResume } from "./ScheduledResume"; import type { SessionLimit } from "./SessionLimit"; import type { SessionStatus } from "./SessionStatus"; @@ -21,4 +22,10 @@ lastCompletedTurn: string | null, chatId: string, deviceId: string, status: Sess * limit — the additive "Limited" detail on the errored status (see * [`SessionLimit`]). Absent on rows from older engines. */ -limit?: SessionLimit | null, }; +limit?: SessionLimit | null, +/** + * The pending Resume-at-reset continuation, when one is armed for this + * Limited chat (see [`ScheduledResume`]). Absent on rows from older + * engines and on chats with nothing armed. + */ +scheduledResume?: ScheduledResume | null, }; diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 3a2e3cdb97..130b4cc879 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -41,6 +41,7 @@ export * from "./DoneStatus"; export * from "./DriveEntry"; export * from "./DriveListing"; export * from "./EngineInfo"; +export * from "./EngineSettings"; export * from "./FileSearchMatch"; export * from "./FinishQueueEditOutcome"; export * from "./FinishQueuedMessageEditAction"; @@ -99,6 +100,7 @@ export * from "./Repo"; export * from "./RepoRef"; export * from "./RunRequest"; export * from "./SandboxLevel"; +export * from "./ScheduledResume"; export * from "./SearchWorkspaceFilesRequest"; export * from "./Session"; export * from "./SessionCommandEntry"; From fc7869e2868716cb9f145f43d3d9652267a42950 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 21:21:35 +0700 Subject: [PATCH 32/96] fix(engine): settle the account and notice unit-test compile errors --- crates/engine/src/agent_accounts.rs | 3 ++- crates/engine/src/agent_accounts/provider_tests.rs | 6 +++--- crates/engine/src/completion_notices.rs | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index da9c1a3c45..ed8df389e5 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -5046,7 +5046,8 @@ mod probe_tests { /// windows and resets already past count for nothing. #[test] fn limited_until_is_the_latest_reset_of_exhausted_windows() { - let at = |hours: i64| Utc.with_ymd_and_hms(2026, 10, 17, 9, 0, 0).unwrap() + TimeDelta::hours(hours); + use chrono::{TimeDelta, TimeZone}; + let at =|hours: i64| Utc.with_ymd_and_hms(2026, 10, 17, 9, 0, 0).unwrap() + TimeDelta::hours(hours); let window = |label: &str, used: f32, reset: Option<DateTime<Utc>>| AgentUsageWindow { label: label.into(), used_fraction: used, diff --git a/crates/engine/src/agent_accounts/provider_tests.rs b/crates/engine/src/agent_accounts/provider_tests.rs index 802bd79165..5c97c15b41 100644 --- a/crates/engine/src/agent_accounts/provider_tests.rs +++ b/crates/engine/src/agent_accounts/provider_tests.rs @@ -2043,7 +2043,7 @@ async fn a_refused_claude_refresh_marks_the_slot_signed_out_beside_cached_window #[tokio::test] async fn signing_in_again_marks_the_account_signed_in() { let server = MockServer::start(|_method, path, body, bearer| match path { - "/api/oauth/usage" => match bearer.as_str() { + "/api/oauth/usage" => match bearer { "alice-token" => (200, claude_usage_body(6.0, 35.0)), "bob-fresh" => (200, claude_usage_body(100.0, 35.0)), _ => (401, String::new()), @@ -2099,7 +2099,7 @@ async fn signing_in_again_marks_the_account_signed_in() { // The post-login reload re-probes Bob immediately: his slot now holds // fresh credentials, so the old rejection no longer applies. - let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + let rows = self::rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); let bob = rows .iter() .find(|a| a.email.as_deref() == Some("bob@example.com")) @@ -2154,7 +2154,7 @@ async fn a_run_error_sign_out_mark_lands_and_a_later_probe_clears_it() { assert_eq!(server.hits("GET /api/oauth/usage"), 0, "the mark is offline"); // The next successful probe re-establishes the account. - let rows = rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); + let rows = self::rows(&accounts.list(true).await.unwrap(), HarnessId::ClaudeCode); assert_eq!(rows[0].sign_in_state, AgentSignInState::SignedIn); // An unknown slot id is refused, not silently ignored. diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 521f654e09..6a2322de06 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -615,7 +615,7 @@ mod tests { fn notice_text_names_title_short_id_outcome_and_read_chat() { let mut completed = side("chat-side-1234", "Implement login", NoticeOutcome::Completed); completed.detail = None; - let single = notice_text(&[completed]); + let single = notice_text(&[completed.clone()]); assert_eq!( single, "Side chat \"Implement login\" (chat-si) finished.\n\ From 4b1e546861150333510de2cbc1710fd524c9624e Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 21:35:34 +0700 Subject: [PATCH 33/96] fix(web): give the ring test account a sign-in state --- web/packages/app/tests/accounts-view.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/packages/app/tests/accounts-view.test.ts b/web/packages/app/tests/accounts-view.test.ts index 31f0749a84..4793ed2c50 100644 --- a/web/packages/app/tests/accounts-view.test.ts +++ b/web/packages/app/tests/accounts-view.test.ts @@ -369,6 +369,7 @@ function ringAccount(harness: HarnessId, active: boolean, used: number[]): Agent planLabel: null, active, switchable: true, + signInState: "unknown", usageWindows: used.map((fraction) => ({ label: "5h", usedFraction: fraction, resetsAt: null })), }; } From e4fab48159970b42266d6bfe3ede70287a5d151d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 22:08:00 +0700 Subject: [PATCH 34/96] fix(pi): answer the settle probe in the reject-while-streaming fake The reject-while-streaming scenario never answered the settle probe the driver arms at the prompted turn agent_settled: the probe get_state sat in the fake stdin through the scripted 0.3 s sleep, so the scenario read_command - expecting the user steer prompt - read the get_state instead, the prompt assert killed the fake pi, and the EOF crash bookkeeping ended the driver run stream with Done{Errored}, panicking a_prompt_rejected_while_pi_streams_keeps_the_session_and_the_message with "stream open". Even without the assert, the in-flight probe would have made the driver queue the steer in driver_steers instead of delivering it as the prompt pi rejects, deadlocking the scenario. Mirror the wake-turn scenario: answer get_state (quiescent) and get_session_stats inline after turn_and_settle, so the driver closes the prompted turn with its first Done and parks before the wake run starts; the sleep then only paces the wake run, the steer arrives as a real prompt command, and the rejection / wind-down / tolerant re-delivery loop proceeds as scripted (three completed turns). The test is unchanged; not run locally (CI policy). --- crates/harness/tests/fixtures/fake-pi-rpc.sh | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index ae2ddfe8a6..8b0fa5dcba 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -504,7 +504,20 @@ def run_prompt(req): # its queue, and re-deliver it once the extension run winds down. user_message(text) turn_and_settle("reply:" + text) - time.sleep(0.3) # the driver parks: probe + stats + first Done + # Close the prompted turn fully (settle probe + stats) before the + # wake run starts — the fake is single-threaded and cannot answer + # the probe while a scenario sleeps. A probe left unanswered would + # both kill this scenario at the prompt assert (the buffered + # get_state is what read_command returns) and make the driver queue + # the steer behind the in-flight probe instead of delivering it as + # the prompt pi rejects. + probe = read_command() + assert probe["type"] == "get_state", probe + response(probe, state_data()) + stats = read_command() + assert stats["type"] == "get_session_stats", stats + response(stats, stats_data()) + time.sleep(0.3) # the driver parks: the turn Done is out streaming = True emit({"type": "agent_start"}) emit({"type": "turn_start"}) From 13a49266e84ae7cc80f320877067bdbc6ccd2f07 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 22:40:37 +0700 Subject: [PATCH 35/96] feat(ui): continue a Limited chat on another harness Ticket 25 (t3-orchestration-v2, spec story 55). The Limited banner gains a second action beside ticket 06's Resume: Continue on another harness, the escape hatch when the harness itself is the wall. - Desktop: the banner action opens a small dialog (its own module, the composer's picker untouched) listing the ENABLED harnesses other than the chat's current one (the composer's offered set minus the current harness - never a re-pick of the one that hit the limit) plus a model pick, defaulting to the first offered harness and the model default at run time. Confirming performs ticket 24's one-way SwitchHarness first - the seam is what arms the budgeted history and drops the limited harness's session - and only after the engine accepts does it stamp the row optimistically (so the composer's send carries the new harness and model) and send the fixed continue prompt through ticket 06's resume path. A refused switch never sends the prompt: nothing is stamped, the chat stays Limited on its old harness, and the failure surfaces in the composer. The dialog is modal (Escape cancels) and rendered beside the fork dialog's overlay. - Engine tests (continue_on_harness.rs): the composition contract - the switch lands while the chat is Limited, the continuation dispatches on the new harness carrying the budgeted transcript above the seam (never resuming the limited harness's session), the limit record survives the switch alone and clears when the continuation runs, and messages queued while Limited follow behind the continuation on the new harness. - CONTEXT.md: the Limited entry names the new banner action. No wire changes - everything rides tickets 06 and 24's additive surface. Tests not run locally (CI runs them): the engine suite line in ui-tests.yml gains --test continue_on_harness. --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 2 +- crates/engine/tests/continue_on_harness.rs | 564 ++++++++++ crates/ui/src/shell.rs | 47 + crates/ui/src/shell/continue_on_harness.rs | 1182 ++++++++++++++++++++ 5 files changed, 1795 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/continue_on_harness.rs create mode 100644 crates/ui/src/shell/continue_on_harness.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 90f05dbfb7..06daea919a 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -66,7 +66,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats --test harness_switch + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats --test harness_switch --test continue_on_harness # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index ff20167f0b..ef0899aad7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,7 +57,7 @@ A chat's live run state: idle, working, awaiting input, or errored. _Avoid_: Session, state (when it means chat status) **Limited**: -An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume; queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. +An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume and Continue on another harness (a one-way harness switch followed by the same continue prompt); queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. _Avoid_: Rate-limited (that is the harness event), throttled, capped **Chat doc**: diff --git a/crates/engine/tests/continue_on_harness.rs b/crates/engine/tests/continue_on_harness.rs new file mode 100644 index 0000000000..263aec92db --- /dev/null +++ b/crates/engine/tests/continue_on_harness.rs @@ -0,0 +1,564 @@ +//! A Limited chat continues on another harness (t3-orchestration-v2 ticket +//! 25): the composition of ticket 06's Limited record and ticket 24's +//! one-way harness switch. +//! +//! "Continue on another harness" performs the switch first — the seam, the +//! dropped session, the saved config — and then sends the continue prompt as +//! a normal run (ticket 06's Resume path, the request carrying the new +//! harness the way the composer's optimistic row stamp routes it), so: +//! - the continuation dispatches on the NEW harness with the budgeted +//! transcript of everything above the seam, never resuming the limited +//! harness's session; +//! - the limit record survives the switch alone (the chat stays Limited +//! until a turn actually starts) and clears when the continuation runs; +//! - messages queued while the chat was Limited wait behind the +//! continuation and then follow on the new harness. +//! +//! The harness seam is scripted: a `Limit` stand-in whose first run ends on +//! a classified usage-limit error exactly the way the Claude/Codex drivers +//! do (`Error { kind }` then an errored `Done`), and a `Capture` stand-in +//! that records every request it ran and holds its turns until the test +//! releases them — one instance per harness id, so the continuation shows +//! up as the requests moving from one capture to the other. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::{MessagePart, MessageRole, SessionCommandPayload}; +use roboco_engine::{AgentAccounts, AgentAccountsConfig, EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentErrorKind, AgentEvent, ChatConfig, DoneStatus, HarnessId, Model, ReasoningLevel, + RunRequest, SandboxLevel, SessionLimit, SessionStatus, SteeringMode, +}; +use roboco_rpc::methods; + +const CHAT: &str = "chat-continue"; +const CONTINUE_PROMPT: &str = roboco_proto::RESUME_CONTINUE_PROMPT; + +/// A scripted harness: the FIRST run ends on a classified usage-limit error +/// (`Error { kind }` then an errored `Done` — the driver shape ticket 05 +/// landed); every run waits for a `finish` signal before its terminal event, +/// so the test owns when the limited turn ends. Later runs never happen in +/// these tests — the continuation always switches to the `Capture` harness. +struct Limit { + finish: tokio::sync::broadcast::Sender<()>, + prompts: Arc<Mutex<Vec<String>>>, +} + +impl Limit { + fn new() -> (Arc<Self>, Arc<Mutex<Vec<String>>>) { + let (finish, _) = tokio::sync::broadcast::channel(16); + let prompts = Arc::new(Mutex::new(Vec::new())); + ( + Arc::new(Self { + finish, + prompts: prompts.clone(), + }), + prompts, + ) + } +} + +#[async_trait] +impl Harness for Limit { + fn id(&self) -> HarnessId { + HarnessId::ClaudeCode + } + fn display_name(&self) -> &str { + "Limit" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.prompts.lock().unwrap().push(request.prompt.clone()); + let mut finish = self.finish.subscribe(); + Ok(futures::stream::once(async move { + // The turn runs until the test says it ends — then it dies on + // the usage limit exactly the way the driver reports one. + let _ = finish.recv().await; + vec![ + Ok(AgentEvent::Error { + message: "Claude 5-hour limit reached — try again after the limit resets." + .into(), + kind: Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some("claude-session".into()), + }), + ] + }) + .flat_map(futures::stream::iter) + .boxed()) + } +} + +/// A scripted harness that records every request it ran and holds each +/// turn until the test releases it (the same control ticket 06's harness +/// has), so queue-order assertions stay deterministic. The continuation +/// switches onto it, so the switch shows up as the requests moving from +/// the `Limit` capture to this one. +struct Capture { + session: &'static str, + reply: &'static str, + requests: Arc<Mutex<Vec<RunRequest>>>, + finish: tokio::sync::broadcast::Sender<()>, +} + +impl Capture { + fn build( + session: &'static str, + reply: &'static str, + ) -> (Arc<Self>, Arc<Mutex<Vec<RunRequest>>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let (finish, _) = tokio::sync::broadcast::channel(16); + ( + Arc::new(Self { + session, + reply, + requests: requests.clone(), + finish, + }), + requests, + ) + } +} + +#[async_trait] +impl Harness for Capture { + fn id(&self) -> HarnessId { + HarnessId::Pi + } + fn display_name(&self) -> &str { + "Capture" + } + fn supports_steering(&self) -> bool { + false + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.requests.lock().unwrap().push(request.clone()); + let reply = self.reply.to_string(); + let session = self.session.to_string(); + let mut finish = self.finish.subscribe(); + Ok(futures::stream::once(async move { + // The turn runs until the test says it ends. + let _ = finish.recv().await; + vec![ + Ok(AgentEvent::TextDelta { text: reply }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session), + }), + ] + }) + .flat_map(futures::stream::iter) + .boxed()) + } +} + +async fn wait_for<F>(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(15)).await; + } +} + +fn claude_config() -> ChatConfig { + ChatConfig { + harness: HarnessId::ClaudeCode, + model: Some("claude-default".into()), + reasoning: None, + model_options: Default::default(), + sandbox: SandboxLevel::WorkspaceWrite, + } +} + +/// Queue a Run command the way the composer's Resume / normal send does (the +/// durable command plane, not the visible message queue). `model` is the +/// pick the dialog landed on for the new harness; `None` leaves the request +/// to the harness's own default. +fn queue_run( + core: &EngineCore, + prompt: &str, + harness: HarnessId, + model: Option<&str>, + message_id: &str, +) { + let request = RunRequest { + prompt: prompt.into(), + harness: Some(harness), + model: model.map(str::to_string), + reasoning: None, + model_options: Default::default(), + cwd: ".".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + mcp: None, + resume: None, + provider_fork: None, + }; + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Run { + request, + message_id: message_id.into(), + }, + ) + .expect("queue run command"); +} + +async fn switch( + core: &EngineCore, + harness: HarnessId, + model: Option<&str>, +) -> Result<serde_json::Value, roboco_rpc::RpcError> { + let client = roboco_rpc::memory_client(core.rpc_service()); + let mut params = serde_json::json!({ "chatId": CHAT, "harness": harness }); + if let Some(model) = model { + params["model"] = serde_json::Value::String(model.into()); + } + client.call(methods::SWITCH_HARNESS, params).await +} + +fn queue_texts(core: &EngineCore) -> Vec<String> { + core.doc_host + .open(CHAT) + .ok() + .and_then(|h| h.doc().read_queue().ok()) + .unwrap_or_default() + .into_iter() + .map(|item| item.text) + .collect() +} + +fn user_texts(core: &EngineCore) -> Vec<String> { + core.doc_host + .open(CHAT) + .ok() + .and_then(|h| h.doc().read_entries().ok()) + .unwrap_or_default() + .iter() + .filter(|e| e.role == MessageRole::User) + .map(|e| { + e.parts + .iter() + .filter_map(|p| match p { + MessagePart::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::<Vec<_>>() + .join("") + }) + .collect() +} + +fn limit_row(core: &EngineCore) -> Option<SessionLimit> { + core.sessions + .session_status(CHAT) + .and_then(|s| s.limit.clone()) +} + +/// The chat row as the workspace doc sees it. +fn chat_row(core: &EngineCore) -> roboco_proto::Chat { + core.workspace.chat(CHAT).expect("chat row").expect("chat exists") +} + +/// The transcript's harness-switch seams naming `harness`: `count` of them, +/// each a complete system row. +fn expect_seams(core: &EngineCore, harness: HarnessId, count: usize) { + let seams: Vec<roboco_doc::SessionMessageEntry> = core + .doc_host + .open(CHAT) + .expect("open chat") + .doc() + .read_entries() + .expect("read entries") + .into_iter() + .filter(|entry| { + entry.role == MessageRole::System + && entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::HarnessSwitch { harness: to, .. } if *to == harness)) + }) + .collect(); + assert_eq!(seams.len(), count, "seams for {harness:?}"); + for seam in &seams { + assert_eq!(seam.status, Some(roboco_doc::MessageStatus::Complete)); + } +} + +/// Assemble a core around the two scripted harnesses, pre-titled so the +/// auto-titler never dispatches a request of its own, and mount an ISOLATED +/// accounts store (empty) so limit records never read the machine's real +/// CLI logins. +fn setup() -> ( + EngineCore, + Arc<Limit>, + Arc<Mutex<Vec<String>>>, + Arc<Capture>, + Arc<Mutex<Vec<RunRequest>>>, + tempfile::TempDir, +) { + let tmp = tempfile::tempdir().unwrap(); + let (limited, limited_prompts) = Limit::new(); + let (pi, pi_requests) = Capture::build("pi-session", "Picked the work back up."); + let registry = HarnessRegistry::new(); + registry.register(limited.clone()); + registry.register(pi.clone()); + let core = EngineCore::assemble(&tmp.path().join("data"), Arc::new(registry), HarnessId::Mock) + .expect("engine core assembles"); + core.sessions.set_agent_accounts(AgentAccounts::new( + AgentAccountsConfig::isolated(&tmp.path().join("accounts")), + )); + core.workspace + .create_chat( + CHAT, + None, + Some(&core.device_id), + Some(claude_config()), + Some("/tmp".into()), + ) + .expect("create chat"); + // Pre-title so the auto-titler never dispatches a harness request of its own. + core.workspace + .rename_chat(CHAT, "Pre-titled") + .expect("rename chat"); + (core, limited, limited_prompts, pi, pi_requests, tmp) +} + +/// Start the turn that hits the limit and wait for the run to be working. +async fn start_limited_turn(prompts: &Arc<Mutex<Vec<String>>>) { + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "Remember PINEAPPLE"), + "the limited run to start", + ) + .await; +} + +/// Release the limited turn and wait for the chat to settle Limited. +async fn settle_limited(core: &EngineCore) { + wait_for(|| limit_row(core).is_some(), "the chat to go Limited").await; +} + +/// Choose "Continue on another harness": the switch (ticket 24), then the +/// continue prompt (ticket 06's Resume path) — the new agent picks the work +/// up from the budgeted history, the limited harness's session never +/// resumes, the limit record clears when the continuation runs, and the +/// messages queued while the chat was Limited follow behind the +/// continuation on the new harness. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn continuing_on_another_harness_switches_then_sends_the_continue_prompt() { + let (core, limited, limited_prompts, pi, pi_requests, _tmp) = setup(); + queue_run(&core, "Remember PINEAPPLE", HarnessId::ClaudeCode, None, "m-open"); + start_limited_turn(&limited_prompts).await; + // A message the user sent while the doomed run was working. + core.doc_host + .queue_message(CHAT, "queued one", Vec::new()) + .expect("queue message"); + let _ = limited.finish.send(()); + settle_limited(&core).await; + // Limited: the queued row WAITS instead of dispatching into the limit. + assert_eq!(queue_texts(&core), vec!["queued one"]); + assert_eq!( + limited_prompts.lock().unwrap().clone(), + vec!["Remember PINEAPPLE"], + "no queued message dispatches into the limited chat" + ); + + // The switch is allowed while the chat is Limited (it is errored, not + // working) — ticket 24's one-way switch runs first. + switch(&core, HarnessId::Pi, Some("pi-default")) + .await + .expect("switch while limited"); + expect_seams(&core, HarnessId::Pi, 1); + let row = chat_row(&core); + assert_eq!(row.config.as_ref().map(|c| c.harness), Some(HarnessId::Pi)); + assert_eq!(row.config.as_ref().and_then(|c| c.model.as_deref()), Some("pi-default")); + assert_eq!( + row.harness_session_id.as_deref(), + Some(""), + "the limited harness's session is tombstoned, never resumed" + ); + // Still holding the queue — the switch thaws nothing by itself. + assert_eq!(queue_texts(&core), vec!["queued one"]); + + // The continue prompt — the composer's normal-send path with the fixed + // prompt, carrying the new harness (the optimistic row stamp routes it) + // and the model the dialog picked. + queue_run(&core, CONTINUE_PROMPT, HarnessId::Pi, Some("pi-default"), "m-resume"); + wait_for( + || pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.ends_with(CONTINUE_PROMPT)), + "the continue prompt to run on the new harness", + ) + .await; + let request = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(request.harness, Some(HarnessId::Pi)); + assert_eq!(request.model.as_deref(), Some("pi-default")); + assert_eq!( + request.resume, None, + "the limited harness's session must never resume" + ); + assert!(request.prompt.contains("Remember PINEAPPLE"), "{}", request.prompt); + assert!(request.prompt.ends_with(CONTINUE_PROMPT), "{}", request.prompt); + assert!(request.prompt.contains("<transcript>"), "{}", request.prompt); + // The continuation owns the turn (it holds until the test releases it); + // the queued row still waits behind it. + assert_eq!(queue_texts(&core), vec!["queued one"]); + // The continuation ran: the limit record is gone. + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.limit.is_none()) + }, + "the continuation to clear the limit record", + ) + .await; + + // The continuation ends → the queue drains behind it, onto the new + // harness, continuing the session the continuation opened. + let _ = pi.finish.send(()); + wait_for( + || chat_row(&core).harness_session_id.as_deref() == Some("pi-session"), + "the new harness's session to be recorded", + ) + .await; + wait_for( + || pi_requests.lock().unwrap().iter().any(|r| r.prompt.contains("queued one")), + "the queued message to follow the continuation on the new harness", + ) + .await; + let follow_up = pi_requests.lock().unwrap().last().cloned().unwrap(); + assert_eq!(follow_up.harness, Some(HarnessId::Pi)); + assert_eq!(follow_up.resume.as_deref(), Some("pi-session")); + assert!(!follow_up.prompt.contains("PINEAPPLE"), "{}", follow_up.prompt); + + let _ = pi.finish.send(()); + wait_for( + || { + queue_texts(&core).is_empty() + && core + .sessions + .session_status(CHAT) + .is_none_or(|s| s.status != SessionStatus::Working) + }, + "the last turn to settle and the queue to drain", + ) + .await; + // The transcript carries the continue prompt as the run's user entry. + assert!(user_texts(&core).contains(&CONTINUE_PROMPT.to_string())); + assert!(queue_texts(&core).is_empty(), "the queue drained"); + assert_eq!(limit_row(&core), None, "the limit record stayed cleared"); + assert_eq!( + limited_prompts.lock().unwrap().clone(), + vec!["Remember PINEAPPLE"], + "the limited harness never ran again" + ); + core.shutdown().await; +} + +/// The switch alone does NOT clear the Limited state: the record lives as +/// long as the errored status it rode, so the banner stays up (and the +/// Resume / continue actions stay offered) until a turn actually starts. A +/// crash between the switch and the send leaves an honest Limited chat on +/// the new harness, ready to resume. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_switch_alone_keeps_the_chat_limited_until_a_turn_runs() { + let (core, limited, limited_prompts, pi, _pi_requests, _tmp) = setup(); + queue_run(&core, "Remember PINEAPPLE", HarnessId::ClaudeCode, None, "m-open"); + start_limited_turn(&limited_prompts).await; + let _ = limited.finish.send(()); + settle_limited(&core).await; + + switch(&core, HarnessId::Pi, None).await.expect("switch"); + expect_seams(&core, HarnessId::Pi, 1); + assert_eq!( + chat_row(&core).config.as_ref().map(|c| c.harness), + Some(HarnessId::Pi) + ); + // Still Limited: errored with the record — no turn has started. + assert!(limit_row(&core).is_some()); + assert_eq!( + core.sessions.session_status(CHAT).map(|s| s.status), + Some(SessionStatus::Errored) + ); + + // The user's own next message (a normal send, not the resume prompt) + // clears it just the same. + queue_run(&core, "try a different angle", HarnessId::Pi, None, "m-next"); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the new turn to start", + ) + .await; + let _ = pi.finish.send(()); + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Idle) + }, + "the new turn to settle", + ) + .await; + assert_eq!(limit_row(&core), None, "the new turn cleared the limit record"); + core.shutdown().await; +} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index c03c8bced2..9266834bd8 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -64,6 +64,7 @@ mod chat_dropzone_tests; #[cfg(test)] mod chat_rename_tests; mod command_palette; +mod continue_on_harness; mod file_mutations; mod files_panel; mod fork_dialog; @@ -1713,6 +1714,11 @@ pub struct Shell { /// model a pending fork runs on. `Some` while open; the confirm hands /// the fork to `fork_chat` with the chosen overrides. fork_dialog: Option<fork_dialog::ForkDialog>, + /// The "Continue on another harness" dialog (t3-orchestration-v2 + /// ticket 25): the harness and model a LIMITED chat's continuation + /// switches to. `Some` while open; the confirm performs ticket 24's + /// one-way switch and then sends the continue prompt. + continue_dialog: Option<continue_on_harness::ContinueOnHarnessDialog>, /// The add-space palette (device tabs + folder search), `Some` while open. add_space: Option<AddSpaceFlow>, /// The New project palette's collapsed-breadcrumbs (`…`) menu. @@ -2121,6 +2127,7 @@ impl Shell { section_menu_active: None, delete_space_confirm: None, fork_dialog: None, + continue_dialog: None, add_space: None, command_palette: None, pending_workspace_command: None, @@ -5253,6 +5260,7 @@ impl Shell { || self.section_menu.is_some() || self.add_space.is_some() || self.fork_dialog.is_some() + || self.continue_dialog.is_some() || self.composer.read(cx).pickers().read(cx).is_open() || self.open_side_chat_pickers(cx).is_some() } @@ -8264,6 +8272,12 @@ impl Shell { self.close_fork_dialog(cx); return true; } + if self.continue_dialog.is_some() { + // The continue-on-harness dialog is a modal over everything else + // open here (t3-orchestration-v2 ticket 25). + self.close_continue_dialog(cx); + return true; + } if self.add_space.is_some() { self.add_space = None; cx.notify(); @@ -8668,6 +8682,9 @@ impl Shell { if let Some(overlay) = self.render_fork_dialog(viewport, cx) { overlays.push(overlay); } + if let Some(overlay) = self.render_continue_on_harness(viewport, cx) { + overlays.push(overlay); + } if let Some(chat_id) = self.delete_confirm.clone() { let title = transcript::single_line( @@ -9616,6 +9633,36 @@ impl Shell { )) .child(SharedString::from("Resume")), ) + // CONTINUE ON ANOTHER HARNESS (orchestration v2 ticket + // 25): a separate action — the escape hatch spec story + // 55 asks for. It opens a small dialog listing the + // enabled harnesses OTHER than the current one (plus a + // model pick); confirming performs ticket 24's one-way + // switch and then sends the same continue prompt, so + // the new agent picks the work up from the budgeted + // history above the "Switched to" seam. + .child( + div() + .id("limited-continue-harness") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_click(cx.listener(|this, _, _, cx| { + this.open_continue_on_harness(cx); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Switch this chat to another harness and continue there", + )) + .child(SharedString::from("Continue on another harness")), + ) .into_any_element() } Indicator::None if sending => strip diff --git a/crates/ui/src/shell/continue_on_harness.rs b/crates/ui/src/shell/continue_on_harness.rs new file mode 100644 index 0000000000..eb02ebec36 --- /dev/null +++ b/crates/ui/src/shell/continue_on_harness.rs @@ -0,0 +1,1182 @@ +//! The "Continue on another harness" dialog (t3-orchestration-v2 ticket 25): +//! the harness and model a Limited chat continues on, chosen when the +//! continuation starts. +//! +//! The Limited banner's Resume (ticket 06) sends the continue prompt on the +//! SAME harness; this dialog is the escape hatch spec story 55 asks for — +//! pick any OTHER enabled harness and a model, and confirming performs +//! ticket 24's one-way harness switch first, then sends the fixed continue +//! prompt (ticket 06's path). The engine writes the "Switched to <harness>" +//! seam, so the new agent's first run carries the budgeted transcript of +//! the conversation above the seam, and the run clears the limit record. +//! +//! The dialog is deliberately local to this flow: the composer's own +//! harness/model picker (and its unlock in ticket 24's one-way switch) stays +//! untouched, so the two features never share state. + +use super::*; + +use roboco_engine::registry::HarnessDescriptor; +use roboco_proto::{ChatConfig, HarnessId, Model, SandboxLevel}; + +/// A pending "Continue on another harness": the Limited chat being switched +/// and the harness and model the continuation runs on. Held by +/// [`Shell::continue_dialog`] while the dialog is open. +pub(super) struct ContinueOnHarnessDialog { + /// The Limited chat the continuation switches — the row the + /// SwitchHarness RPC rewrites. + chat: roboco_proto::Chat, + /// The harness the chat runs today (its row config). Never offered: the + /// continuation is a switch to ANOTHER harness, not a re-pick of the one + /// that hit the limit. + current_harness: Option<HarnessId>, + /// The harness the continuation will run on. `None` until a default + /// resolves — the first OFFERED harness once the catalog lands (the + /// current one is already excluded from that list). + harness: Option<HarnessId>, + /// The model the continuation will run on. `None` = the harness's + /// default at run time. + model: Option<String>, + /// The chat's host device's harness catalog, loaded once per dialog. + harnesses: Loadable<Vec<HarnessDescriptor>>, + /// Model lists per harness, loaded once per dialog per harness. + models: std::collections::HashMap<HarnessId, Loadable<Vec<Model>>>, + /// Stale-response guard: bumped when the dialog closes, so an in-flight + /// catalog load lands on a closed (or reopened) dialog and no-ops. + generation: u64, + /// The model list's scroll. + model_scroll: gpui::ScrollHandle, +} + +/// The SwitchHarness params a confirm sends (ticket 24's RPC): the chat, the +/// chosen harness and the chosen model. Reasoning and model options never +/// ride — the dialog picks none, so the new harness's own defaults apply +/// (the engine stores what arrives; "model options reset to the new +/// harness's defaults unless the user chose them"). The sandbox the switch +/// keeps is the chat's own pick, so the engine RPC reads it from the row. +pub(super) fn switch_params( + chat_id: &str, + harness: HarnessId, + model: Option<&str>, +) -> serde_json::Value { + let mut params = serde_json::json!({ + "chatId": chat_id, + "harness": harness, + }); + if let Some(model) = model.map(str::trim).filter(|id| !id.is_empty()) { + params["model"] = serde_json::Value::String(model.to_owned()); + } + params +} + +/// The harnesses the "Continue on another harness" dialog offers: the same +/// offered set the composer's picker uses (installed and enabled on the +/// listing device), minus the chat's current harness — a Limited chat +/// escapes its limit by switching to ANOTHER harness, never by re-picking +/// the one that hit it. +pub(super) fn continue_offered_harnesses( + catalog: &[HarnessDescriptor], + current: Option<HarnessId>, +) -> Vec<HarnessDescriptor> { + crate::pickers::offered_harnesses(catalog) + .into_iter() + .filter(|descriptor| current != Some(descriptor.id)) + .collect() +} + +impl Shell { + /// Open the "Continue on another harness" dialog (orchestration v2 + /// ticket 25) for the selected Limited chat: pick the harness and model + /// the continuation switches to; confirming performs ticket 24's + /// one-way switch and then sends the continue prompt. + pub(super) fn open_continue_on_harness(&mut self, cx: &mut Context<Self>) { + if self.continue_dialog.is_some() { + return; + } + let Some(chat) = self.state.read(cx).selected_chat_row().cloned() else { + return; + }; + self.continue_dialog = Some(ContinueOnHarnessDialog { + current_harness: chat.config.as_ref().map(|config| config.harness), + chat, + harness: None, + model: None, + harnesses: Loadable::Idle, + models: Default::default(), + generation: 0, + model_scroll: gpui::ScrollHandle::new(), + }); + self.ensure_continue_loads(cx); + cx.notify(); + } + + /// Close the dialog without continuing. The generation bump retires + /// in-flight catalog loads. + pub(super) fn close_continue_dialog(&mut self, cx: &mut Context<Self>) { + if let Some(dialog) = self.continue_dialog.as_mut() { + dialog.generation += 1; + } + self.continue_dialog = None; + cx.notify(); + } + + /// Pick the continuation's harness. A pick resets the model — the + /// previous pick's model id is meaningless on the new harness; its own + /// default applies at run time. + pub(super) fn pick_continue_harness(&mut self, harness: HarnessId, cx: &mut Context<Self>) { + if let Some(dialog) = self.continue_dialog.as_mut() { + if dialog.harness == Some(harness) { + return; + } + dialog.harness = Some(harness); + dialog.model = None; + } + self.ensure_continue_models(harness, cx); + cx.notify(); + } + + /// Pick the continuation's model on the selected harness. + pub(super) fn pick_continue_model(&mut self, model: String, cx: &mut Context<Self>) { + if let Some(dialog) = self.continue_dialog.as_mut() { + dialog.model = Some(model); + } + cx.notify(); + } + + /// Confirm: perform ticket 24's one-way harness switch, then send the + /// continue prompt (ticket 06's Resume path). The switch must land + /// FIRST — its seam is what arms the budgeted history and drops the + /// limited harness's session — so the prompt is sent only after the + /// RPC succeeds. A failed switch leaves the chat untouched (the local + /// row is stamped only on success, so nothing needs reverting): still + /// Limited, still on its old harness, the banner still up. + pub(super) fn confirm_continue_on_harness(&mut self, cx: &mut Context<Self>) { + let Some(dialog) = self.continue_dialog.as_ref() else { + return; + }; + // Nothing picked yet (no other harness is available): nothing to + // confirm — the button renders disabled. + let Some(harness) = dialog.harness else { + return; + }; + let chat_id = dialog.chat.id.clone(); + let model = dialog.model.clone(); + let selected = self.state.read(cx).selected_chat.clone(); + self.continue_dialog = None; + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + // The one field the switch keeps: the chat's sandbox pick stays. + let sandbox = self + .state + .read(cx) + .chats + .iter() + .find(|chat| chat.id == chat_id) + .and_then(|chat| chat.config.as_ref()) + .map(|config| config.sandbox) + .unwrap_or(SandboxLevel::WorkspaceWrite); + let params = switch_params(&chat_id, harness, model.as_deref()); + cx.spawn(async move |this, cx| { + let result = engine.call(methods::SWITCH_HARNESS, params).await; + this.update(cx, |shell, cx| { + match result { + Ok(_) => { + // The engine's row already switched; stamp the local + // row with the same picks so the composer's send + // carries the new harness and model deterministically + // (the chats watch confirms the same value after it). + let config = ChatConfig { + harness, + model: model.clone(), + reasoning: None, + model_options: Default::default(), + sandbox, + }; + shell.state.update(cx, |state, cx| { + state.apply_chat_config(&chat_id, config); + cx.notify(); + }); + // The continue prompt — the same send path, guards and + // queue semantics as the banner's Resume. The run + // starts on the new harness, clears the limit record, + // and queued messages stay behind it. + if selected.as_deref() == Some(chat_id.as_str()) { + shell + .composer + .update(cx, |composer, cx| composer.resume_limited(cx)); + } + } + Err(err) => { + // The switch never landed: the chat keeps its old + // harness and its Limited state — the continuation is + // never sent into the same wall. + tracing::warn!(error = %err, "continue-on-harness switch failed"); + shell.composer.update(cx, |composer, cx| { + composer.show_error( + format!("Could not switch the harness: {err}"), + cx, + ); + }); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + /// Kick every load the dialog's current selection needs: the harness + /// catalog when still idle, and the selected harness's model list. + fn ensure_continue_loads(&mut self, cx: &mut Context<Self>) { + self.ensure_continue_harnesses(cx); + let harness = self.continue_dialog.as_ref().and_then(|dialog| dialog.harness); + if let Some(harness) = harness { + self.ensure_continue_models(harness, cx); + } + } + + /// The catalog load's `targetDeviceId`: the switch runs on the CHAT's + /// host device (the RPC refuses a source hosted elsewhere), so its + /// installed harnesses are the ones that matter. `None` for a + /// locally-hosted chat — the connected engine serves it directly. + fn continue_catalog_target(&self, cx: &App) -> Option<String> { + let local = self.state.read(cx).local_device_id.as_deref()?; + let dialog = self.continue_dialog.as_ref()?; + (local != dialog.chat.device_id.as_str()).then(|| dialog.chat.device_id.clone()) + } + + /// Load the harness catalog once per dialog: the list is short-lived, so + /// no refresh-on-open and no error retry — reopening the dialog retries. + fn ensure_continue_harnesses(&mut self, cx: &mut Context<Self>) { + let idle = self + .continue_dialog + .as_ref() + .is_some_and(|dialog| matches!(dialog.harnesses, Loadable::Idle)); + if !idle { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + let target = self.continue_catalog_target(cx); + let generation = self + .continue_dialog + .as_ref() + .map(|dialog| dialog.generation) + .unwrap_or_default(); + if let Some(dialog) = self.continue_dialog.as_mut() { + dialog.harnesses = Loadable::Loading; + } + cx.spawn(async move |this, cx| { + let mut params = serde_json::Map::new(); + if let Some(target) = &target { + params.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.clone()), + ); + } + let result = engine + .call( + methods::LIST_HARNESSES, + serde_json::Value::Object(params), + ) + .await; + this.update(cx, |shell, cx| { + let Some(dialog) = shell.continue_dialog.as_mut() else { + return; + }; + if dialog.generation != generation { + return; + } + dialog.harnesses = match result { + Ok(value) => match serde_json::from_value::<Vec<HarnessDescriptor>>(value) { + Ok(list) => Loadable::Ready(list), + Err(error) => Loadable::Error(error.to_string()), + }, + Err(error) => Loadable::Error(error.to_string()), + }; + // No pick yet: the first OFFERED harness (the current one is + // already excluded from the offered list) becomes the pick + // once the catalog lands — one click confirms. + let first = dialog.offered().first().map(|descriptor| descriptor.id); + if dialog.harness.is_none() + && let Some(harness) = first + { + dialog.harness = Some(harness); + } + cx.notify(); + }) + .ok(); + // The default may have just resolved; its model list is next. + this.update(cx, |shell, cx| shell.ensure_continue_loads(cx)).ok(); + }) + .detach(); + } + + /// Load one harness's model list, once per dialog per harness. + fn ensure_continue_models(&mut self, harness: HarnessId, cx: &mut Context<Self>) { + let needs_load = self + .continue_dialog + .as_ref() + .and_then(|dialog| dialog.models.get(&harness)) + .is_none_or(|slot| matches!(slot, Loadable::Idle)); + if !needs_load { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + let target = self.continue_catalog_target(cx); + let generation = self + .continue_dialog + .as_ref() + .map(|dialog| dialog.generation) + .unwrap_or_default(); + if let Some(dialog) = self.continue_dialog.as_mut() { + dialog.models.insert(harness, Loadable::Loading); + } + cx.spawn(async move |this, cx| { + let mut params = serde_json::json!({ "harness": harness }); + if let (Some(target), Some(object)) = (&target, params.as_object_mut()) { + object.insert( + "targetDeviceId".into(), + serde_json::Value::String(target.clone()), + ); + } + let result = engine.call(methods::LIST_MODELS, params).await; + this.update(cx, |shell, cx| { + let Some(dialog) = shell.continue_dialog.as_mut() else { + return; + }; + if dialog.generation != generation { + return; + } + let loaded = match result { + Ok(value) => match serde_json::from_value::<Vec<Model>>(value) { + Ok(models) => Loadable::Ready(models), + Err(error) => Loadable::Error(error.to_string()), + }, + Err(error) => Loadable::Error(error.to_string()), + }; + dialog.models.insert(harness, loaded); + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The "Continue on another harness" overlay: a modal card with the + /// harness chips (the current one absent), the selected harness's + /// models, and the continue/cancel pair. + pub(super) fn render_continue_on_harness( + &mut self, + viewport: gpui::Size<Pixels>, + cx: &mut Context<Self>, + ) -> Option<AnyElement> { + let dialog = self.continue_dialog.as_ref()?; + let theme = Theme::of(cx).for_popup(); + let catalog: Vec<HarnessDescriptor> = dialog.harnesses.ready().cloned().unwrap_or_default(); + let offered = continue_offered_harnesses(&catalog, dialog.current_harness); + let harness_loading = matches!(dialog.harnesses, Loadable::Loading); + let harness_error = dialog.harnesses.error().map(str::to_owned); + let selected_harness = dialog.harness; + let selected_model = dialog.model.clone(); + let models: Vec<Model> = selected_harness + .and_then(|harness| dialog.models.get(&harness)) + .and_then(|slot| slot.ready().cloned()) + .unwrap_or_default(); + let models_loading = selected_harness.is_some_and(|harness| { + matches!(dialog.models.get(&harness), Some(Loadable::Loading)) + }); + let models_error = selected_harness + .and_then(|harness| match dialog.models.get(&harness) { + Some(Loadable::Error(error)) => Some(error.clone()), + _ => None, + }); + let scroll = dialog.model_scroll.clone(); + let confirmable = selected_harness.is_some(); + + let mut card = popover::dialog_card(&theme) + .child(popover::dialog_title(&theme, "Continue on another harness")) + .child(div().mt(px(6.0)).child(popover::dialog_body( + &theme, + "This chat hit its usage limit. Switching harnesses is one-way: the chat runs \ + on the one you pick from here on, and the new agent receives a size-limited \ + transcript of the conversation, then the continue prompt.", + ))); + + // ── Harness: one chip per offered harness (never the current one); + // the pick rides the border and fill, like the composer's rail + // tabs. + let label_style = |el: gpui::Div| { + el.mt(px(14.0)) + .mb(px(6.0)) + .text_size(crate::typography::ui_rems(11.0)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(theme.text_muted) + }; + card = card.child(label_style(div()).child(SharedString::from("HARNESS"))); + let mut chips = div().flex().flex_row().flex_wrap().gap(px(6.0)); + for (ix, descriptor) in offered.iter().enumerate() { + let harness = descriptor.id; + let selected = selected_harness == Some(harness); + let label: SharedString = SharedString::from(descriptor.name.clone()); + let (icon_path, tint) = crate::pickers::harness_brand_icon(harness); + let icon_color = tint.unwrap_or(if selected { + theme.text + } else { + theme.text_muted + }); + chips = chips.child( + div() + .id(("continue-harness-chip", ix)) + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .px(px(10.0)) + .py(px(5.0)) + .rounded(px(8.0)) + .border_1() + .border_color(if selected { + theme.border_strong + } else { + theme.border + }) + .when(selected, |el| el.bg(crate::theme::card_selected_bg())) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.pick_continue_harness(harness, cx); + })) + .child(icon(icon_path).size(px(13.0)).text_color(icon_color)) + .child( + div() + .text_size(crate::typography::ui_rems(12.5)) + .text_color(if selected { + theme.text + } else { + theme.text_muted + }) + .child(label), + ), + ); + } + if offered.is_empty() { + let text = if let Some(error) = harness_error { + SharedString::from(format!("Couldn't load the harnesses: {error}")) + } else if harness_loading { + SharedString::from("Loading this device's harnesses…") + } else { + SharedString::from("No other harnesses are available on this device.") + }; + chips = chips.child( + div() + .px(px(10.0)) + .py(px(5.0)) + .rounded(px(8.0)) + .border_1() + .border_color(theme.border) + .text_size(crate::typography::ui_rems(12.5)) + .text_color(theme.text_muted) + .child(text), + ); + } + card = card.child(chips); + + // ── Model: the selected harness's list; the highlighted row is the + // pick, or the harness's default row (first) when nothing is + // picked — a `None` model means "the default at run time". + card = card.child(label_style(div()).child(SharedString::from("MODEL"))); + let mut list = div() + .id("continue-model-list") + .max_h(px(216.0)) + .overflow_y_scroll() + .track_scroll(&scroll) + .flex() + .flex_col() + .gap(px(2.0)); + if models.is_empty() { + let text = if let Some(error) = models_error { + SharedString::from(format!("Couldn't load the models: {error}")) + } else if models_loading { + SharedString::from("Loading models…") + } else { + SharedString::from("This harness lists no models.") + }; + list = list.child( + div() + .py(px(6.0)) + .text_size(crate::typography::ui_rems(12.5)) + .text_color(theme.text_muted) + .child(text), + ); + } + for (ix, model) in models.iter().enumerate() { + let model_id = model.id.clone(); + let selected = selected_model.as_deref() == Some(model.id.as_str()) + || (selected_model.is_none() && ix == 0); + let default_row = selected_model.is_none() && ix == 0; + let label: SharedString = SharedString::from(model.label.clone()); + let subline: Option<SharedString> = model + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(SharedString::from); + let mut row = div() + .id(("continue-model-row", ix)) + .px(px(8.0)) + .py(px(6.0)) + .rounded(px(popover::MENU_ITEM_RADIUS)) + .flex() + .flex_col() + .gap(px(2.0)) + .cursor_pointer(); + if selected { + row = row + .bg(crate::theme::card_selected_bg()) + .shadow(crate::theme::card_selected_shadows()); + } + row = row + .on_click(cx.listener(move |this, _, _, cx| { + this.pick_continue_model(model_id.clone(), cx); + })) + .child( + div() + .w_full() + .flex() + .flex_row() + .items_center() + .gap(px(6.0)) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_size(crate::typography::ui_rems(12.5)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text) + .child(label), + ) + .when(default_row, |el| { + el.child( + div() + .flex_none() + .text_size(crate::typography::ui_rems(10.0)) + .text_color(theme.text_muted) + .child(SharedString::from("Default")), + ) + }), + ) + .when_some(subline, |el, subline| { + el.child( + div() + .w_full() + .truncate() + .text_size(crate::typography::ui_rems(11.0)) + .text_color(theme.text_muted) + .child(subline), + ) + }); + list = list.child(row); + } + card = card.child(list); + + card = card.child( + div() + .mt(px(16.0)) + .flex() + .flex_row() + .justify_end() + .gap(px(8.0)) + .child( + popover::btn_ghost(&theme, "Cancel", "continue-dialog-cancel") + .id("continue-dialog-cancel") + .on_click(cx.listener(|this, _, _, cx| { + this.close_continue_dialog(cx); + })), + ) + .child( + popover::btn_primary(&theme, "Continue") + .id("continue-dialog-confirm") + .when(!confirmable, |el| el.opacity(0.4).cursor_default()) + .on_click(cx.listener(move |this, _, _, cx| { + if confirmable { + this.confirm_continue_on_harness(cx); + } + })), + ), + ); + Some(popover::modal( + "continue-on-harness", + viewport, + card.into_any_element(), + )) + } +} + +impl ContinueOnHarnessDialog { + /// The harnesses this dialog offers — `continue_offered_harnesses` over + /// its catalog, with the current harness always excluded. + fn offered(&self) -> Vec<HarnessDescriptor> { + let catalog = self + .harnesses + .ready() + .map(|list| list.as_slice()) + .unwrap_or_default(); + continue_offered_harnesses(catalog, self.current_harness) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + + fn descriptor(id: HarnessId, name: &str, installed: bool) -> HarnessDescriptor { + HarnessDescriptor { + id, + name: name.into(), + supports_steering: true, + steering_mode: roboco_proto::SteeringMode::StepBoundary, + reasoning_levels: vec![], + installed, + can_install: false, + enabled: Some(true), + } + } + + fn chat_row(id: &str, config: Option<serde_json::Value>) -> roboco_proto::Chat { + let mut json = serde_json::json!({ + "id": id, + "deviceId": "local", + "cwd": "/tmp/main", + "archived": false, + "createdAt": Utc::now(), + }); + if let (Some(config), Some(object)) = (config, json.as_object_mut()) { + object.insert("config".into(), config); + } + serde_json::from_value(json).unwrap() + } + + fn shell_window(dir: &std::path::Path, cx: &mut TestAppContext) -> gpui::WindowHandle<Shell> { + cx.update(|cx| { + settings::init(settings::UiSettings::default(), dir, cx); + crate::history::init( + Default::default(), + Default::default(), + Default::default(), + Default::default(), + cx, + ); + gpui_base::init(cx); + cx.set_global(Theme::default()); + crate::app_menus::init(cx); + }); + cx.add_window(|_, cx| { + let state = cx.new(|_| AppState::new()); + Shell::new( + state, + EngineBootConfig { + data_dir: dir.into(), + ipc_port: 0, + default_harness: roboco_proto::HarnessId::Mock, + }, + cx, + ) + }) + } + + /// The limited chat's row and status: errored with the additive limit + /// record on claude-code, the state the Limited banner renders for. + fn limited_chat(id: &str) -> (roboco_proto::Chat, roboco_proto::Session) { + let chat = chat_row( + id, + Some(serde_json::json!({ + "harness": "claude-code", + "model": "claude-default", + "sandbox": "workspace-write", + })), + ); + let session = roboco_proto::Session { + last_completed_turn: None, + chat_id: id.into(), + device_id: "local".into(), + status: roboco_proto::SessionStatus::Errored, + started_at: None, + updated_at: Utc::now(), + limit: Some(roboco_proto::SessionLimit { + harness: HarnessId::ClaudeCode, + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + account_id: None, + }), + }; + (chat, session) + } + + /// The wire payload: the chat and the chosen harness always ride; the + /// model rides only when one was picked, so the new harness's own + /// default applies otherwise. Blank strings are absent picks. + #[test] + fn switch_params_carry_the_chosen_harness_and_model() { + let plain = switch_params("chat-1", HarnessId::Pi, None); + assert_eq!( + plain, + serde_json::json!({ "chatId": "chat-1", "harness": "pi" }) + ); + let picked = switch_params("chat-1", HarnessId::Codex, Some(" gpt-6-codex ")); + assert_eq!(picked["model"], "gpt-6-codex"); + let blank = switch_params("chat-1", HarnessId::Pi, Some(" ")); + assert!(blank.get("model").is_none(), "a blank model is no pick"); + } + + /// The offered list: the composer's offered set (installed and enabled), + /// with the CURRENT harness always dropped — a Limited chat escapes its + /// limit on another harness, never a re-pick of the one that hit it. + #[test] + fn continue_offered_harnesses_skip_the_current_harness() { + let catalog = vec![ + descriptor(HarnessId::ClaudeCode, "Claude Code", true), + descriptor(HarnessId::Codex, "Codex", true), + descriptor(HarnessId::Pi, "Pi", true), + // Present but not installed: offered_harnesses drops it. + descriptor(HarnessId::Grok, "Grok", false), + ]; + let offered = continue_offered_harnesses(&catalog, Some(HarnessId::ClaudeCode)); + assert_eq!( + offered.iter().map(|d| d.id).collect::<Vec<_>>(), + vec![HarnessId::Codex, HarnessId::Pi] + ); + // The current harness is the ONLY other one: nothing to offer. + let only = continue_offered_harnesses(&catalog[..1], Some(HarnessId::ClaudeCode)); + assert!(only.is_empty()); + // A config-less chat (no current harness): the whole offered set. + let all = continue_offered_harnesses(&catalog, None); + assert_eq!(all.len(), 3); + assert!(continue_offered_harnesses(&[], Some(HarnessId::Pi)).is_empty()); + } + + /// The dialog opens on a Limited chat unpicked; the catalog landing + /// resolves the default to the first OFFERED harness — never the + /// current one, which the offered list already excludes. + #[gpui::test] + fn the_dialog_defaults_to_the_first_other_harness(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat.clone()]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + }); + shell.open_continue_on_harness(cx); + let dialog = shell.continue_dialog.as_ref().unwrap(); + assert_eq!(dialog.chat.id, "limited"); + assert_eq!(dialog.current_harness, Some(HarnessId::ClaudeCode)); + assert_eq!(dialog.harness, None, "no pick before the catalog lands"); + // The catalog lands (as the load task would write it): + let catalog = vec![ + descriptor(HarnessId::ClaudeCode, "Claude Code", true), + descriptor(HarnessId::Pi, "Pi", true), + ]; + let dialog = shell.continue_dialog.as_mut().unwrap(); + dialog.harnesses = Loadable::Ready(catalog); + // The default resolution the load completion applies — + // exercised through the dialog's own offered list here. + let first = dialog.offered().first().map(|d| d.id); + if dialog.harness.is_none() + && let Some(harness) = first + { + dialog.harness = Some(harness); + } + assert_eq!( + shell.continue_dialog.as_ref().unwrap().harness, + Some(HarnessId::Pi), + "the default skips the current harness" + ); + shell.close_continue_dialog(cx); + assert!(shell.continue_dialog.is_none()); + }) + .unwrap(); + } + + /// Picking a harness resets the model (the previous pick's model id is + /// meaningless there); a model pick rides the confirm. Closing retires + /// the dialog without continuing. + #[gpui::test] + fn picking_harnesses_and_models_steers_the_continuation(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat.clone()]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + }); + shell.open_continue_on_harness(cx); + { + let dialog = shell.continue_dialog.as_mut().unwrap(); + dialog.harness = Some(HarnessId::Pi); + dialog.model = Some("stale-model".into()); + } + // A harness pick resets the model. + shell.pick_continue_harness(HarnessId::Codex, cx); + let dialog = shell.continue_dialog.as_ref().unwrap(); + assert_eq!(dialog.harness, Some(HarnessId::Codex)); + assert_eq!(dialog.model, None, "the model resets with the harness"); + // A model pick on the new harness rides the continuation. + shell.pick_continue_model("gpt-6-codex".into(), cx); + assert_eq!( + shell.continue_dialog.as_ref().unwrap().model.as_deref(), + Some("gpt-6-codex") + ); + // Closing retires the dialog without continuing. + shell.close_continue_dialog(cx); + assert!(shell.continue_dialog.is_none()); + }) + .unwrap(); + } + + /// Escape closes the dialog, like the other modal overlays. + #[gpui::test] + fn escape_closes_the_continue_dialog(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + }); + shell.open_continue_on_harness(cx); + assert!(shell.continue_dialog.is_some()); + assert!(shell.overlay_owns_keyboard(cx)); + assert!(shell.capture_escape_surface(cx)); + assert!(shell.continue_dialog.is_none()); + assert!(!shell.overlay_owns_keyboard(cx)); + }) + .unwrap(); + } + + /// The overlay renders while the dialog is open — harness chips, model + /// rows and the disabled confirm against a landed catalog with no pick — + /// and stops once it closes. + #[gpui::test] + fn the_dialog_overlay_builds_from_a_landed_catalog(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + }); + shell.open_continue_on_harness(cx); + let viewport = gpui::size(px(800.0), px(600.0)); + // Nothing has loaded yet: the overlay still renders (its + // loading states). + assert!(shell.render_continue_on_harness(viewport, cx).is_some()); + // A landed catalog and model list, with a resolved pick. + { + let dialog = shell.continue_dialog.as_mut().unwrap(); + dialog.harnesses = Loadable::Ready(vec![ + descriptor(HarnessId::Pi, "Pi", true), + descriptor(HarnessId::Codex, "Codex", true), + ]); + dialog.harness = Some(HarnessId::Pi); + dialog.models.insert( + HarnessId::Pi, + Loadable::Ready(vec![Model { + id: "glm-5.3".into(), + label: "GLM 5.3".into(), + description: Some("the flagship".into()), + reasoning_levels: vec![], + options: vec![], + }]), + ); + } + assert!(shell.render_continue_on_harness(viewport, cx).is_some()); + shell.close_continue_dialog(cx); + assert!(shell.render_continue_on_harness(viewport, cx).is_none()); + }) + .unwrap(); + } + + /// Drive the scripted engine's current_thread runtime and the gpui + /// queue once, collecting every frame that went out. `EngineTarget::call` + /// spawns onto the registry's runtime handle, so a frame lands only once + /// the runtime is driven (the sidebar_state_sync pump idiom). + fn pump_once( + runtime: &tokio::runtime::Runtime, + cx: &mut TestAppContext, + requests: &mut tokio::sync::mpsc::Receiver<String>, + ) -> Vec<serde_json::Value> { + for _ in 0..16 { + runtime.block_on(async { + tokio::task::yield_now().await; + }); + } + cx.run_until_parked(); + let mut frames = Vec::new(); + while let Ok(raw) = requests.try_recv() { + frames.push(serde_json::from_str(&raw).expect("a well-formed frame")); + } + frames + } + + /// Pump until `done` holds over the frames collected SO FAR — selecting a + /// chat starts unrelated standing traffic (doc watches, seen-marks), so + /// the assertions filter by method instead of assuming frame order. + fn pump_until( + runtime: &tokio::runtime::Runtime, + cx: &mut TestAppContext, + requests: &mut tokio::sync::mpsc::Receiver<String>, + mut done: impl FnMut(&[serde_json::Value]) -> bool, + ) -> Vec<serde_json::Value> { + let mut frames = Vec::new(); + for _ in 0..12 { + frames.extend(pump_once(runtime, cx, requests)); + if done(&frames) { + break; + } + } + frames + } + + fn frame_for<'a>(frames: &'a [serde_json::Value], method: &str) -> Option<&'a serde_json::Value> { + frames + .iter() + .find(|frame| frame["method"].as_str() == Some(method)) + } + + /// Confirming a switch: the dialog hands the chat's one-way switch to + /// the engine (harness and model, nothing else), and only once the + /// engine accepts it does the continue prompt go out as a normal send — + /// carrying the new harness (the stamped row) and the picked model. + #[gpui::test] + fn confirm_switches_the_harness_then_sends_the_continue_prompt( + cx: &mut TestAppContext, + ) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel::<String>(256); + let (replies, inbound) = tokio::sync::mpsc::channel::<String>(256); + let engine = crate::state::EngineHandle::from_test_client(roboco_rpc::RpcClient::new( + out, inbound, + )); + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + state.set_test_engine(engine); + }); + shell.open_continue_on_harness(cx); + { + let dialog = shell.continue_dialog.as_mut().unwrap(); + dialog.harness = Some(HarnessId::Pi); + dialog.model = Some("glm-5.3".into()); + } + shell.confirm_continue_on_harness(cx); + assert!(shell.continue_dialog.is_none(), "confirm consumes the dialog"); + }) + .unwrap(); + + // The switch went out, with only the chosen picks. + let frames = pump_until(&runtime, cx, &mut requests, |frames| { + frame_for(frames, "SwitchHarness").is_some() + }); + let switch = frame_for(&frames, "SwitchHarness") + .expect("the switch must reach the engine RPC") + .clone(); + assert_eq!(switch["params"]["chatId"], "limited"); + assert_eq!(switch["params"]["harness"], "pi"); + assert_eq!(switch["params"]["model"], "glm-5.3"); + assert!( + switch["params"].get("reasoning").is_none() + && switch["params"].get("modelOptions").is_none(), + "the dialog picks neither reasoning nor options — the new harness's defaults apply" + ); + // Nothing raced ahead of the reply: no continue prompt before the + // switch lands. + assert!( + frame_for(&frames, "QueueCommand").is_none(), + "no send before the switch lands" + ); + + // The engine accepts; the row is stamped and the continue prompt + // goes out as a normal run. + runtime.block_on(async { + replies + .send(serde_json::json!({ "id": switch["id"], "ok": {} }).to_string()) + .await + .unwrap(); + // Drive the client's reader task until the reply is routed to + // the pending call (the capacity returns when it is consumed). + while replies.capacity() < replies.max_capacity() { + tokio::task::yield_now().await; + } + }); + let frames = pump_until(&runtime, cx, &mut requests, |frames| { + frame_for(frames, "QueueCommand").is_some() + }); + let send = frame_for(&frames, "QueueCommand") + .expect("the continue prompt must follow the switch") + .clone(); + assert_eq!(send["params"]["chatId"], "limited"); + assert_eq!( + send["params"]["command"]["request"]["prompt"], + roboco_proto::RESUME_CONTINUE_PROMPT + ); + assert_eq!(send["params"]["command"]["request"]["harness"], "pi"); + assert_eq!(send["params"]["command"]["request"]["model"], "glm-5.3"); + // Answer the send so the composer's task settles. + runtime.block_on(async { + replies + .send(serde_json::json!({ "id": send["id"], "ok": {} }).to_string()) + .await + .unwrap(); + while replies.capacity() < replies.max_capacity() { + tokio::task::yield_now().await; + } + }); + pump_once(&runtime, cx, &mut requests); + + window + .update(cx, |shell, _, cx| { + // The optimistic stamp: the row names the new harness and + // model, so the composer's send carried them. + let row = shell + .state + .read(cx) + .chats + .iter() + .find(|chat| chat.id == "limited") + .unwrap(); + assert_eq!( + row.config.as_ref().map(|config| config.harness), + Some(HarnessId::Pi) + ); + assert_eq!(row.config.as_ref().and_then(|c| c.model.as_deref()), Some("glm-5.3")); + }) + .unwrap(); + } + + /// A switch the engine refuses never sends the continue prompt: the + /// chat keeps its old harness (nothing was stamped) and the failure + /// surfaces in the composer — the banner stays up for another try. + #[gpui::test] + fn a_refused_switch_never_sends_the_continue_prompt(cx: &mut TestAppContext) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel::<String>(256); + let (replies, inbound) = tokio::sync::mpsc::channel::<String>(256); + let engine = crate::state::EngineHandle::from_test_client(roboco_rpc::RpcClient::new( + out, inbound, + )); + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + state.set_test_engine(engine); + }); + shell.open_continue_on_harness(cx); + { + let dialog = shell.continue_dialog.as_mut().unwrap(); + dialog.harness = Some(HarnessId::Pi); + } + shell.confirm_continue_on_harness(cx); + }) + .unwrap(); + + let frames = pump_until(&runtime, cx, &mut requests, |frames| { + frame_for(frames, "SwitchHarness").is_some() + }); + let switch = frame_for(&frames, "SwitchHarness") + .expect("the switch must reach the engine RPC") + .clone(); + // The engine refuses (the chat is working, say). + runtime.block_on(async { + replies + .send( + serde_json::json!({ + "id": switch["id"], + "err": "The chat is working; switch harnesses between turns" + }) + .to_string(), + ) + .await + .unwrap(); + while replies.capacity() < replies.max_capacity() { + tokio::task::yield_now().await; + } + }); + // Let the refusal land and anything it could have started settle. + let mut frames = frames; + for _ in 0..3 { + frames.extend(pump_once(&runtime, cx, &mut requests)); + } + + // No continue prompt followed the refusal. + assert!( + frame_for(&frames, "QueueCommand").is_none(), + "a refused switch must never send the continue prompt" + ); + window + .update(cx, |shell, _, cx| { + // The row was never stamped: still on the limited harness. + let row = shell + .state + .read(cx) + .chats + .iter() + .find(|chat| chat.id == "limited") + .unwrap(); + assert_eq!( + row.config.as_ref().map(|config| config.harness), + Some(HarnessId::ClaudeCode) + ); + // The failure surfaced in the composer. + let failure = shell + .composer + .read(cx) + .failure() + .map(|failure| failure.to_string()) + .unwrap_or_default(); + assert!(failure.contains("switch"), "{failure}"); + }) + .unwrap(); + } +} From a4fb97924401879659dfe046974a46b3b2ae4290 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 23:10:38 +0700 Subject: [PATCH 36/96] fix(ui): review pass on the continue-on-harness confirm Two review findings on ticket 25's confirm flow: - The selection guard now re-checks the composer's CURRENT selection after the switch lands instead of a snapshot taken at confirm time: if the selection moved while the SwitchHarness RPC was in flight, the switched chat keeps its Limited banner and Resume - the fixed continue prompt is never sent into a chat the switch did not target. - An unreachable engine at confirm no longer swallows the click: the dialog closes with the same composer error surface a refused switch uses. Plus a smoke test that the Limited banner builds with the new action (the strip's Limited branch renders and its click opens the dialog). Tests not run locally (CI runs them). --- crates/ui/src/shell/continue_on_harness.rs | 53 ++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/shell/continue_on_harness.rs b/crates/ui/src/shell/continue_on_harness.rs index eb02ebec36..3a44d73265 100644 --- a/crates/ui/src/shell/continue_on_harness.rs +++ b/crates/ui/src/shell/continue_on_harness.rs @@ -161,12 +161,18 @@ impl Shell { }; let chat_id = dialog.chat.id.clone(); let model = dialog.model.clone(); - let selected = self.state.read(cx).selected_chat.clone(); - self.continue_dialog = None; let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() else { + // No engine to switch on: say so (the same surface a failed + // switch uses) instead of swallowing the click. + self.continue_dialog = None; + self.composer.update(cx, |composer, cx| { + composer.show_error("Engine not connected", cx); + }); + cx.notify(); return; }; + self.continue_dialog = None; // The one field the switch keeps: the chat's sandbox pick stays. let sandbox = self .state @@ -201,8 +207,20 @@ impl Shell { // The continue prompt — the same send path, guards and // queue semantics as the banner's Resume. The run // starts on the new harness, clears the limit record, - // and queued messages stay behind it. - if selected.as_deref() == Some(chat_id.as_str()) { + // and queued messages stay behind it. The prompt is + // addressed through the composer's CURRENT selection + // (`resume_limited` sends to the selected chat), so + // re-check it live here: if the selection moved while + // the switch was in flight, the switched chat keeps + // its Limited banner and Resume — never send another + // chat's continuation into it. + let still_selected = shell + .state + .read(cx) + .selected_chat + .as_deref() + == Some(chat_id.as_str()); + if still_selected { shell .composer .update(cx, |composer, cx| composer.resume_limited(cx)); @@ -919,6 +937,33 @@ mod tests { .unwrap(); } + /// The action appears on Limited chats: the status strip's Limited + /// branch builds the banner — window label, ticket 06's Resume pill, and + /// the new Continue on another harness pill whose click opens this + /// dialog. + #[gpui::test] + fn the_limited_banner_renders_the_continue_action(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + let (chat, session) = limited_chat("limited"); + window + .update(cx, |shell, _, cx| { + shell.state.update(cx, |state, cx| { + state.chats = vec![chat]; + state.sessions = vec![session]; + state.select_chat(Some("limited".into()), cx); + }); + // The Limited branch builds — including the new pill. + let strip = shell.render_status_strip(640.0, cx); + drop(strip); + // The pill's click opens this dialog. + shell.open_continue_on_harness(cx); + assert!(shell.continue_dialog.is_some()); + shell.close_continue_dialog(cx); + }) + .unwrap(); + } + /// Drive the scripted engine's current_thread runtime and the gpui /// queue once, collecting every frame that went out. `EngineTarget::call` /// spawns onto the registry's runtime handle, so a frame lands only once From 11bed3e6c7277c9f71eea55768119251da695aba Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 23:17:41 +0700 Subject: [PATCH 37/96] fix(ui): settle the two picker tests ticket 24 unlocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compact_picker_shares_selection_reset_and_thread_restrictions asserted the pre-ticket-24 rule that any existing chat locks the harness picker: it selected "thread" with no session row and expected model_rows_len 1. Ticket 24 unlocks an idle chat for a one-way switch, so the bare selection now leaves both harnesses listed and the count read 2. Give the thread a Working session — the lock this block probes lives only while a turn runs — and the restriction assertions (own-harness rows, no foreign search hits, provider page shut) keep their meaning. switching_an_idle_chat_tells_the_engine_the_resolved_picks never saw the switch frame: EngineTarget::call spawns the RPC onto the registry's tokio runtime handle (captured by EngineRegistry::test_local inside set_test_engine), and cx.run_until_parked only polls the GPUI mutate task up to that spawn point — the current_thread runtime is never driven, so client.call never runs and requests stays Empty. Add the sidebar_state_sync pump idiom (16 yield_now block_on rounds, then run_until_parked) so the frame lands. The pick itself is one frame: seed the Codex model catalog up front so ensure_models has nothing to prefetch — without it the ListModels chaser would follow the switch onto the wire and the "no chaser" assert would fail. busy check, engine resolution and mutate_task retention were all correct; no production code changed. Not run locally (CI policy). --- crates/ui/src/pickers.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index bfad570ab2..568a6c5b5b 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -6270,6 +6270,15 @@ mod tests { )); }); pickers.update(cx, |pickers, cx| { + // The Codex catalog the rail would have prefetched, present + // up front: the pick below must be exactly one SwitchHarness + // frame, with no ListModels prefetch muddying the wire. The + // remembered model still rides the switch even though the + // fresh catalog no longer lists it. + pickers.models.insert( + HarnessId::Codex, + Loadable::Ready(vec![bare_model("codex-refreshed", "Codex")]), + ); // Remembered defaults for the new harness ride the switch; // nothing remembered means the engine resets to the // harness's own defaults. @@ -6284,6 +6293,16 @@ mod tests { }); }); cx.run_until_parked(); + // `EngineTarget::call` spawns onto the registry's runtime handle — + // this current_thread runtime — so the frame lands only once the + // runtime is driven; `run_until_parked` alone polls only the GPUI + // task up to the spawn point (the sidebar_state_sync pump idiom). + for _ in 0..16 { + runtime.block_on(async { + tokio::task::yield_now().await; + }); + } + cx.run_until_parked(); let frame = requests.try_recv().expect("the switch reached the engine"); assert!(frame.contains("SwitchHarness"), "{frame}"); assert!(frame.contains("\"chatId\":\"main\""), "{frame}"); @@ -8198,6 +8217,21 @@ mod tests { assert_eq!(picker.config.model.as_deref(), Some("codex-model")); picker.state.update(cx, |state, cx| { state.selected_chat = Some("thread".into()); + // Ticket 24: an idle chat unlocks the picker for a + // one-way switch, so the restrictions this block probes + // live only while a turn runs — give the thread a + // Working session (the lock-under-work rule the + // standard picker test also covers). + state.apply_sessions(vec![roboco_proto::Session { + last_completed_turn: None, + chat_id: "thread".into(), + device_id: "local".into(), + status: roboco_proto::SessionStatus::Working, + started_at: None, + updated_at: chrono::Utc::now(), + limit: None, + scheduled_resume: None, + }]); cx.notify(); }); picker.show_compact_models(cx); @@ -8206,7 +8240,8 @@ mod tests { picker.search.update(cx, |input, cx| input.set_text("Claude", cx)); assert_eq!(picker.model_rows_len(cx), 0); picker.search.update(cx, |input, cx| input.set_text("", cx)); - // A chat's provider is fixed: the provider page stays shut. + // A working chat's provider is fixed: the provider page + // stays shut. picker.compact_model_list = false; picker.show_compact_providers(cx); assert!(!picker.compact_providers); From bac3c51a7b361cf11c697e9c3b79fad1254e3a0a Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Fri, 16 Oct 2026 23:30:47 +0700 Subject: [PATCH 38/96] fix(pi): keep a FIFO delivery record per input, not an ack-time counter a_burst_of_steers_is_delivered_in_order failed on CI with 2 of 3 Steered boundaries: the driver counted queued deliveries at the prompt ACKNOWLEDGEMENT (pending_echo_deliveries += 1 on a queued disposition) and consumed them at the user message_start echo. The driver select may process an echo before the ready acceptance future, so the burst third echo arrived while its ack was still unprocessed, the counter was short, and the echo produced no boundary - its engine at-least-once ledger entry would never retire. Upstream PROTOCOL.md (df0cd2980) states the contract: keep one FIFO delivery record per ORIGINAL INPUT, including duplicate text - neither the prompt ACK nor queue_update is a consumption receipt; message_start for each consumed user input emits its own Steered. Port that rule: - A DeliveryRecord (id + text) is created when the input GOES OUT - the mailbox mid-run push and the parked delivery - so the echo consumes it whether or not the acknowledgement has been processed. - The echo pops the oldest record and emits its Steered; the started/handled acknowledgements retire their record instead, firing the boundary only when the echo has not consumed it yet - whoever consumes the record first emits the one boundary the input owes. - A started steer claims the awaiting echo (its run's own user message) so it cannot steal the next queued steer's record. - Rejections retire the record with no boundary (pi never consumed the input; the re-delivery creates a fresh record); clear_queue recovery retires the stranded texts' records the same way. The test and the fixture are unchanged - the driver was wrong. Traced against every Pi scenario (burst, flow, image, strand, settle race, reject-while-streaming, parked, old-pi, stop-clears-queue); not run locally per the no-build override - CI is the signal. --- crates/harness/src/pi/mod.rs | 225 ++++++++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 56 deletions(-) diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index 6847aa6e66..e069492712 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -30,7 +30,13 @@ //! is unbounded: pi's preflight (extensions, the Roboco MCP bridge, //! compaction) may take arbitrarily long, and only Stop interrupts it. A //! rejected prompt never ends the session — the message returns to the -//! driver queue and the rejection surfaces. A mid-run steer carries its +//! driver queue and the rejection surfaces. Delivery tracking keeps one +//! FIFO record per original input, created when the input goes out (the +//! acknowledgement is never a consumption receipt, upstream PROTOCOL.md): +//! a consumed user `message_start` echo pops the oldest record and emits +//! its `Steered` boundary — whichever of the echo or the +//! started/handled acknowledgement processes first consumes the record, +//! so a burst is never short a boundary. A mid-run steer carries its //! staged images inline, exactly like a turn-start prompt. Pi is a //! STEP-BOUNDARY harness (the engine registry descriptor agrees): the //! steering mode is set to `"all"` at session start unless the user @@ -774,15 +780,41 @@ const EOF_DRAIN: Duration = Duration::from_millis(50); /// A mid-run steer in flight: the mailbox message and its `prompt` /// acknowledgement. -type SteerCall = BoxFuture<'static, (SteerMessage, Result<Value, HarnessError>)>; +type SteerCall = BoxFuture<'static, (u64, SteerMessage, Result<Value, HarnessError>)>; /// One armed prompt: its acceptance future and the mailbox message it /// delivers (None = the run's own first prompt). The pair always travels /// together — the message is owed a `Steered` boundary once pi accepts the -/// prompt, and returns to the queue if pi rejects it. +/// prompt, and returns to the queue if pi rejects it. `delivery` names the +/// [`DeliveryRecord`] the arming created, so the acknowledgement (or a +/// rejection) can retire it. struct Armed { acceptance: BoxFuture<'static, Result<Value, HarnessError>>, message: Option<SteerMessage>, + delivery: Option<u64>, +} + +/// One FIFO delivery record per original input sent to pi (upstream +/// PROTOCOL.md: neither the prompt ACK nor `queue_update` is a consumption +/// receipt). The record is created when the input GOES OUT, so a user +/// message echo that races its acknowledgement still consumes it — the +/// driver's select may process the echo before the ready acceptance +/// future. Whoever consumes the record first — the echo, or the +/// Started/Handled acknowledgement that confirms delivery at acceptance — +/// emits the one `Steered` boundary the input owes; a record pi never +/// consumes (a steer stranded in its queue at settle) is retired by +/// clear_queue and re-delivered as the next turn's prompt. +struct DeliveryRecord { + id: u64, + text: String, +} + +/// Retire `id`'s delivery record; `true` when it was still pending (the +/// boundary it owes has not been emitted at the echo yet). +fn retire_delivery(records: &mut VecDeque<DeliveryRecord>, id: u64) -> bool { + let before = records.len(); + records.retain(|record| record.id != id); + before != records.len() } /// pi's answer to a `prompt`: what became of it. Pi ≥ 0.99 reports @@ -1086,10 +1118,13 @@ async fn run_session(session: Session) { // Agent activity since the last settle (extension-initiated runs deliver // output with no prompt of ours; their settle still emits a Done). let mut saw_activity = false; - // Mailbox prompts accepted into pi's steer queue (disposition `queued`, - // or an acknowledgement without one on pi < 0.99) whose delivery pi - // confirms with a user message echo — the consumed signal. - let mut pending_echo_deliveries: usize = 0; + // FIFO delivery records for the mailbox inputs sent to pi — the + // consumption ledger behind the `Steered` boundaries. One record per + // original input, created when it goes out (not when pi acknowledges + // it), so an echo racing its acknowledgement still consumes it. + let mut sent_deliveries: VecDeque<DeliveryRecord> = VecDeque::new(); + // The delivery-record id sequence. + let mut delivery_seq: u64 = 0; let mut steer_calls: FuturesUnordered<SteerCall> = FuturesUnordered::new(); // Messages the driver itself owns — rejected by pi, stranded in pi's // queue at settle, or queued while the settle probe winds down — @@ -1135,6 +1170,7 @@ async fn run_session(session: Session) { let mut armed = Some(Armed { acceptance: prompt_acceptance(&client, &request.prompt, images), message: None, + delivery: None, }); // Whether the current acceptance cycle has already spent its one // immediate at-rest re-delivery of a rejected message: a pi that keeps @@ -1160,7 +1196,13 @@ async fn run_session(session: Session) { ), if exit_drain_deadline.is_some() => break 'main, res = async { (&mut armed.as_mut().expect("guarded by if").acceptance).await }, if armed.is_some() => { - let Armed { message: mailbox, .. } = armed.take().expect("guarded by if"); + let Armed { message: mailbox, delivery, .. } = + armed.take().expect("guarded by if"); + // The delivery record this prompt created (None = the run's + // own first prompt, whose echo is the awaiting one). + // Started/Handled retire it — the boundary fires here only + // when the echo has not consumed the record yet: the record + // is the single owner of the one `Steered` each input owes. match res { Ok(data) => { // A prompt acknowledgement bumps the settle @@ -1176,8 +1218,11 @@ async fn run_session(session: Session) { // A run began: it streams until // agent_settled, where the settle probe // takes over. A mailbox message is delivered - // at acceptance. - if mailbox.is_some() + // at acceptance — unless its echo already + // consumed the record (the processing race). + let retired = delivery + .is_some_and(|id| retire_delivery(&mut sent_deliveries, id)); + if retired && !confirm_delivery( &event_tx, &mut assistant_message_id, @@ -1186,17 +1231,20 @@ async fn run_session(session: Session) { { break 'main; } - awaiting_prompt_echo = true; + // Only when the boundary fired here does the + // run's own user echo become the awaiting + // one; otherwise the echo already delivered. + awaiting_prompt_echo |= retired; run_active = true; } Disposition::Queued => { - // Accepted into pi steer queue: delivery - // (and the boundary) land at the user - // message echo; a strand is recovered by the + // Accepted into pi steer queue: the delivery + // record stays — the user message echo + // consumes it (the delivery signal), in + // whatever order the acknowledgement + // processes. A strand is recovered by the // settle probe clear_queue. - if mailbox.is_some() { - pending_echo_deliveries += 1; - } + // // The run own first prompt queued behind an // extension run: awaiting_prompt_echo // already covers its echo. @@ -1207,7 +1255,9 @@ async fn run_session(session: Session) { // extension command): the boundary is owed // now, and the turn ends once pi reports it // is not streaming. - if mailbox.is_some() + let retired = delivery + .is_some_and(|id| retire_delivery(&mut sent_deliveries, id)); + if retired && !confirm_delivery( &event_tx, &mut assistant_message_id, @@ -1230,7 +1280,9 @@ async fn run_session(session: Session) { // probe (after a grace, so a starting run // can flip isStreaming) ends the turn if // nothing runs. - if mailbox.is_some() + let retired = delivery + .is_some_and(|id| retire_delivery(&mut sent_deliveries, id)); + if retired && !confirm_delivery( &event_tx, &mut assistant_message_id, @@ -1239,7 +1291,7 @@ async fn run_session(session: Session) { { break 'main; } - awaiting_prompt_echo = true; + awaiting_prompt_echo |= retired; if probe.is_none() { probe_generation = generation; probe = Some(rearm_probe(&client, true)); @@ -1254,7 +1306,13 @@ async fn run_session(session: Session) { // returns to the queue, and it re-delivers once // the turn ends — immediately when the session // is already at rest, else after the probe - // closes the turn. + // closes the turn. The delivery record retires + // without a boundary: pi never consumed this + // input, so the engine at-least-once ledger + // keeps its entry until the re-delivery. + if let Some(id) = delivery { + retire_delivery(&mut sent_deliveries, id); + } let _ = send( &event_tx, AgentEvent::Error { @@ -1278,6 +1336,8 @@ async fn run_session(session: Session) { && !deliver_parked_steer( &client, &mut armed, + &mut sent_deliveries, + &mut delivery_seq, &mut awaiting_prompt_echo, &mut run_active, &mut done_current, @@ -1341,10 +1401,18 @@ async fn run_session(session: Session) { // pi is idle yet still held messages (steers // stranded by the settle race): they were cleared // and re-queue, in send order, as the next turn's - // prompts. + // prompts. Their delivery records retire — pi will + // never echo them — so the re-delivery's own echo + // consumes the fresh records it creates. probe_failures = 0; - pending_echo_deliveries = - pending_echo_deliveries.saturating_sub(texts.len()); + for text in &texts { + if let Some(pos) = sent_deliveries + .iter() + .position(|record| record.text == *text) + { + sent_deliveries.remove(pos); + } + } for text in texts.into_iter().rev() { driver_steers.push_front(SteerMessage::text(text)); } @@ -1402,6 +1470,8 @@ async fn run_session(session: Session) { && !deliver_parked_steer( &client, &mut armed, + &mut sent_deliveries, + &mut delivery_seq, &mut awaiting_prompt_echo, &mut run_active, &mut done_current, @@ -1450,7 +1520,7 @@ async fn run_session(session: Session) { }, res = steer_calls.next(), if !steer_calls.is_empty() => match res { - Some((msg, res)) => match res { + Some((delivery, msg, res)) => match res { Ok(data) => { // The steer acknowledgement bumps the settle // generation: a probe whose reading predates it must @@ -1461,38 +1531,49 @@ async fn run_session(session: Session) { Disposition::Started => { // The steer raced the run end and pi started // a run with it: delivered now, and the run - // streams to its own settle. - if !confirm_delivery( - &event_tx, - &mut assistant_message_id, - ) - .await + // streams to its own settle — unless its + // echo already consumed the record (the + // processing race). + let retired = + retire_delivery(&mut sent_deliveries, delivery); + if retired + && !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await { break 'main; } + // The started run's own user echo is the + // awaiting one — it must not consume the + // next queued steer's record. + awaiting_prompt_echo |= retired; run_active = true; } Disposition::Queued => { - // Accepted into pi steer queue: the boundary - // lands at the user message echo. - pending_echo_deliveries += 1; + // Accepted into pi steer queue: the delivery + // record stays — the user message echo + // consumes it, in whatever order this + // acknowledgement processes. } Disposition::Handled => { // pi handled it without starting a run: the - // message is delivered. - if !confirm_delivery( - &event_tx, - &mut assistant_message_id, - ) - .await + // message is delivered, at acceptance unless + // an echo already consumed the record. + if retire_delivery(&mut sent_deliveries, delivery) + && !confirm_delivery( + &event_tx, + &mut assistant_message_id, + ) + .await { break 'main; } } Disposition::Unknown => { // pi < 0.99: delivery lands at the echo, if - // one ever comes. - pending_echo_deliveries += 1; + // one ever comes — the record stays for it. } } }, @@ -1504,7 +1585,11 @@ async fn run_session(session: Session) { // the queue — it re-delivers once the turn ends // (immediately when the session is already at // rest, else when the settle probe closes the - // turn). + // turn). The delivery record retires without a + // boundary: pi never consumed this input, so the + // engine at-least-once ledger keeps its entry until + // the re-delivery. + retire_delivery(&mut sent_deliveries, delivery); let _ = send( &event_tx, AgentEvent::Error { @@ -1528,6 +1613,8 @@ async fn run_session(session: Session) { && !deliver_parked_steer( &client, &mut armed, + &mut sent_deliveries, + &mut delivery_seq, &mut awaiting_prompt_echo, &mut run_active, &mut done_current, @@ -1566,14 +1653,19 @@ async fn run_session(session: Session) { if role == Some("assistant") { tracker.reset(); } else if role == Some("user") { - // Our prompt's own echo, or a queued steer's - // delivery out of pi's queue. + // Our prompt's own echo, or a queued input's + // delivery out of pi's queue (upstream + // PROTOCOL.md: message_start for each + // consumed user input emits its own + // Steered; the ACK and queue_update are + // never consumption receipts). if awaiting_prompt_echo { awaiting_prompt_echo = false; - } else if pending_echo_deliveries > 0 { - // A queued steer's consumed signal: its - // user echo is what delivers it. - pending_echo_deliveries -= 1; + } else if sent_deliveries.pop_front().is_some() { + // The consumed signal: this echo + // delivers the oldest record — whether + // or not its acknowledgement has been + // processed yet. if !confirm_delivery( &event_tx, &mut assistant_message_id, @@ -1879,7 +1971,13 @@ async fn run_session(session: Session) { // staged attachments inline as images, exactly like // a turn-start prompt. generation += 1; // the prompt goes out - steer_calls.push(steer_request(&client, msg)); + delivery_seq += 1; + let delivery = delivery_seq; + sent_deliveries.push_back(DeliveryRecord { + id: delivery, + text: msg.prompt.clone(), + }); + steer_calls.push(steer_request(&client, msg, delivery)); } else if probe.is_some() || !steer_calls.is_empty() || !driver_steers.is_empty() { // The settle probe or a steer acknowledgement is // still winding down: FIFO behind it. @@ -1888,6 +1986,8 @@ async fn run_session(session: Session) { if !deliver_parked_steer( &client, &mut armed, + &mut sent_deliveries, + &mut delivery_seq, &mut awaiting_prompt_echo, &mut run_active, &mut done_current, @@ -2042,11 +2142,12 @@ fn prompt_acceptance( /// One mid-run steer: the same `prompt` with steer behavior (pi queues it /// while streaming and starts a run when idle). Carries the mailbox -/// message so a rejection can hand it back to the queue verbatim, and -/// stages the message's attachments as inline images — a steer delivers -/// its images exactly like a turn-start prompt (the path refs ride the -/// text as a fallback either way). -fn steer_request(client: &PiClient, msg: SteerMessage) -> SteerCall { +/// message so a rejection can hand it back to the queue verbatim, its +/// delivery-record id so the acknowledgement (or a rejection) can retire +/// it, and stages the message's attachments as inline images — a steer +/// delivers its images exactly like a turn-start prompt (the path refs +/// ride the text as a fallback either way). +fn steer_request(client: &PiClient, msg: SteerMessage, delivery: u64) -> SteerCall { let client = client.clone(); Box::pin(async move { let images = load_image_contents(&msg.attachments).await; @@ -2058,7 +2159,7 @@ fn steer_request(client: &PiClient, msg: SteerMessage) -> SteerCall { params["images"] = Value::Array(images); } let result = client.request("prompt", params).await; - (msg, result) + (delivery, msg, result) }) } @@ -2090,6 +2191,8 @@ async fn confirm_delivery( async fn deliver_parked_steer( client: &PiClient, armed: &mut Option<Armed>, + sent_deliveries: &mut VecDeque<DeliveryRecord>, + delivery_seq: &mut u64, awaiting_prompt_echo: &mut bool, run_active: &mut bool, done_current: &mut bool, @@ -2102,9 +2205,19 @@ async fn deliver_parked_steer( let _ = configure_live(client, next).await; } let images = load_image_contents(&msg.attachments).await; + // The delivery record is created when the input goes out: pi's echo + // consumes it whether or not the acknowledgement has been processed + // yet (upstream PROTOCOL.md: the ACK is not a consumption receipt). + *delivery_seq += 1; + let delivery = *delivery_seq; + sent_deliveries.push_back(DeliveryRecord { + id: delivery, + text: msg.prompt.clone(), + }); *armed = Some(Armed { acceptance: prompt_acceptance(client, &msg.prompt, images), message: Some(msg), + delivery: Some(delivery), }); // Set per disposition once pi answers: a started run echoes its own // user message; a queued acceptance delivers at the echo instead. From 3080089bee45ccf98dae9cf01d3d99f667557b72 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 00:11:59 +0700 Subject: [PATCH 39/96] feat(engine): resume a Limited chat on another harness account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The t3-orchestration-v2 ticket 09 failover: a Limited Claude Code or Codex chat can continue on a different account of the same harness. - Candidates come from the engine's ranking of the CACHED harness accounts (no network): switchable, not an API key, not signed out, not still limited, not the account that hit the limit nor the live login; lowest usage first, accounts sharing the limited one's org or workspace ranked lower. The chosen account is probed once before anything is touched (the probe honoring the usage cache's own backoff, one account only — never the sweep that forgets removed slots). - The switch: activate, retire the chat's warm runtime, and queue the continuation through the durable command plane — the same harness session resumed with the continue prompt, the budgeted history from ticket 01 riding as the request's additive resumeFallbackHistory. - The credential fingerprint: every warm runtime is stamped with the live login's fingerprint at launch, and dispatch compares a fresh read against the stamp — a runtime started under another login is never reused (any chat on the harness, not just the failover's). - Drivers: Codex pastes the fallback history in front of the prompt on the fresh thread a refused thread/resume starts; Claude detects a resume the CLI will refuse (no session file in its store) and starts fresh the same way. The engine converts the fallback through the same harness_prompt pass the fork fallback gets. - The chain: a signed-out failure after the switch is already marked by the run's own wiring (ticket 08); the engine moves to the next candidate. Any other failure restores the previously active account. Exhausted chains restore too — a failed failover leaves the harness as it found it. - RPC: ListResumeAccountCandidates and ResumeOnAnotherAccount, the replies (ResumeAccountCandidates, ResumeOnAccountOutcome) additive on the wire, regenerated TS hand-written in the wiregen layout. - Desktop: the Limited banner offers "Resume on another account" for switchable harnesses with a non-empty candidate list (empty = only Resume / Resume at reset remain), a picker popover with the ranked rows (usage meters, reset times, the recommended first) and the all-chats warning, and the outcome as a strip line (failures also ride the composer's failure chip). - Wire: RunRequest.resumeFallbackHistory + the two reply types, additive (ADR 0005), registered in wiregen. Tests: proto wire round-trips (fallback history, candidates, outcome), ranking unit tests (exclusions, usage-then-org order, org/workspace groups), engine integration tests at the RPC seam with a seeded fake account store and a scripted Claude-shaped harness (ranking, switch + same-session resume + warm-runtime retirement by fingerprint, refused resume fallback, signed-out chain, restore, no candidates, explicit pick), Codex and Claude driver tests with new fake-CLI scenarios, and shell view-model tests (outcome notice, reset caption). Not run locally per the verification policy; CI is the signal. --- .github/workflows/ui-tests.yml | 2 +- CONTEXT.md | 2 +- crates/doc/src/commands.rs | 1 + crates/doc/tests/attachments_roundtrip.rs | 1 + crates/engine/examples/android_fixture.rs | 1 + crates/engine/src/account_failover.rs | 480 +++++++++ crates/engine/src/agent_accounts.rs | 359 ++++++- crates/engine/src/doc_host.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/rpc.rs | 28 + crates/engine/src/sessions.rs | 97 +- crates/engine/src/titles.rs | 2 + crates/engine/tests/account_failover.rs | 960 ++++++++++++++++++ crates/engine/tests/account_sign_in_state.rs | 1 + crates/engine/tests/acp_lifecycle.rs | 1 + crates/engine/tests/adapter_chaos_live.rs | 2 + crates/engine/tests/agent_origin.rs | 1 + crates/engine/tests/codex_subagents.rs | 1 + crates/engine/tests/completion_notices.rs | 2 + crates/engine/tests/cursor_live.rs | 1 + crates/engine/tests/e2e.rs | 2 + crates/engine/tests/limited_chats.rs | 1 + .../tests/m5c_accounts_uploads_titles.rs | 2 + crates/engine/tests/pi_resume.rs | 1 + crates/engine/tests/projectless.rs | 1 + crates/engine/tests/queued_attachments.rs | 1 + crates/engine/tests/restart_resume.rs | 2 + crates/engine/tests/rich_composer_delivery.rs | 1 + crates/engine/tests/runtime_survival.rs | 1 + crates/engine/tests/runtime_survival_live.rs | 1 + crates/engine/tests/self_continued_quiesce.rs | 1 + crates/engine/tests/side_chats.rs | 5 + crates/engine/tests/subagent_idle_reap.rs | 1 + crates/engine/tests/turn_quiesce.rs | 1 + crates/engine/tests/unarchive_on_send.rs | 1 + crates/engine/tests/worktree_on_run.rs | 1 + .../examples/cursor_stability_probe.rs | 4 + .../harness/examples/cursor_steering_probe.rs | 1 + crates/harness/examples/devin_models_probe.rs | 1 + .../harness/examples/grok_subagent_probe.rs | 1 + .../examples/opencode_subagent_probe.rs | 1 + .../harness/examples/opencode_turn_probe.rs | 1 + crates/harness/src/claude/mod.rs | 58 +- crates/harness/src/codex/mod.rs | 17 +- crates/harness/tests/acp.rs | 1 + crates/harness/tests/acp_quiet.rs | 1 + crates/harness/tests/acp_stall.rs | 1 + crates/harness/tests/claude.rs | 34 + crates/harness/tests/codex.rs | 39 + crates/harness/tests/cursor.rs | 1 + crates/harness/tests/fixtures/fake-claude.sh | 15 + crates/harness/tests/fixtures/fake-codex.sh | 14 + crates/harness/tests/managed_install.rs | 1 + .../harness/tests/managed_install_failure.rs | 1 + crates/harness/tests/opencode.rs | 1 + crates/harness/tests/pi.rs | 1 + crates/harness/tests/real_acp_lifecycle.rs | 1 + crates/harness/tests/real_quiet_survey.rs | 1 + crates/harness/tests/windows_native.rs | 1 + crates/mobile-client/src/session/commands.rs | 1 + crates/mobile-client/tests/notify_engine.rs | 1 + crates/proto/src/agent.rs | 34 + crates/proto/src/entities.rs | 136 +++ crates/roboco-mcp/src/tools.rs | 1 + crates/rpc/src/lib.rs | 15 + crates/ui/src/composer.rs | 1 + crates/ui/src/shell.rs | 515 ++++++++++ crates/wiregen/src/main.rs | 10 + .../src/generated/ResumeAccountCandidates.ts | 29 + .../src/generated/ResumeOnAccountOutcome.ts | 16 + .../proto/src/generated/RunRequest.ts | 11 + web/packages/proto/src/generated/index.ts | 2 + 72 files changed, 2919 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/account_failover.rs create mode 100644 crates/engine/tests/account_failover.rs create mode 100644 web/packages/proto/src/generated/ResumeAccountCandidates.ts create mode 100644 web/packages/proto/src/generated/ResumeOnAccountOutcome.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index de879e54b0..659c4dda5e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -66,7 +66,7 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib - cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats + cargo test --locked -p roboco-engine --lib --test engine_local --test restart_resume --test codex_subagents --test local_profiles --test project_actions_rpc --test project_action_commands --test project_actions_remote --test remote_settings --test side_chats --test agent_origin --test account_sign_in_state --test completion_notices --test limited_chats --test account_failover # Mobile Rust core (spec D1, android-app#02): the crates the Android app's # Kotlin painter leans on. Pure Rust, no GPUI system deps. diff --git a/CONTEXT.md b/CONTEXT.md index 92f403c93c..57eea465d2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,7 +57,7 @@ A chat's live run state: idle, working, awaiting input, or errored. _Avoid_: Session, state (when it means chat status) **Limited**: -An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume; queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. +An errored chat whose last run ended on a harness usage limit — the additive limit record (harness, window, reset time, account) on its status row. Offers Resume (and, on a switchable harness, Resume on another account); queued messages wait behind the continuation. Not a separate wire status: the chat stays errored. _Avoid_: Rate-limited (that is the harness event), throttled, capped **Chat doc**: diff --git a/crates/doc/src/commands.rs b/crates/doc/src/commands.rs index 3c187117eb..e63f6a47a2 100644 --- a/crates/doc/src/commands.rs +++ b/crates/doc/src/commands.rs @@ -403,6 +403,7 @@ mod tests { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } } diff --git a/crates/doc/tests/attachments_roundtrip.rs b/crates/doc/tests/attachments_roundtrip.rs index a5b95633e4..3816cd2fd7 100644 --- a/crates/doc/tests/attachments_roundtrip.rs +++ b/crates/doc/tests/attachments_roundtrip.rs @@ -20,6 +20,7 @@ fn run_request_attachments_survive_command_round_trip() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; doc.queue_command(&SessionCommandEntry { origin_chat_id: None, diff --git a/crates/engine/examples/android_fixture.rs b/crates/engine/examples/android_fixture.rs index 3ef9217614..73f2b61705 100644 --- a/crates/engine/examples/android_fixture.rs +++ b/crates/engine/examples/android_fixture.rs @@ -579,6 +579,7 @@ fn seed_request(repo: &Path, chat: &SeedChat) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/src/account_failover.rs b/crates/engine/src/account_failover.rs new file mode 100644 index 0000000000..2b1dca9757 --- /dev/null +++ b/crates/engine/src/account_failover.rs @@ -0,0 +1,480 @@ +//! Resume on another account (orchestration v2, ticket 09): the engine +//! operation behind a Limited chat's account failover. +//! +//! A Limited **Claude Code** or **Codex** chat can continue on a different +//! harness account of the same harness. The engine: +//! +//! 1. ranks the CACHED harness accounts (no network — see +//! [`AgentAccounts::failover_candidates`]): switchable, not an API key, +//! not signed out, not still limited, not the account that hit the limit +//! nor the live login; lowest usage first, accounts sharing the limited +//! one's org or workspace ranked lower; +//! 2. probes the chosen one once ([`AgentAccounts::probe_account`]) — a +//! probe that reveals a dead login or an exhausted window moves to the +//! next candidate; +//! 3. activates it (the CLI store swap every chat on that harness runs +//! under from now on); +//! 4. retires the chat's warm harness runtime, and stamps every warm +//! runtime with the login's credential fingerprint so a runtime started +//! under another login is never reused (the dispatch guard in +//! `sessions.rs`); +//! 5. resumes the chat's SAME harness session with the continue prompt — +//! through the durable command plane, so queued messages stay behind +//! the continuation exactly as a plain Resume. +//! +//! If the harness refuses the native resume, the continuation request +//! carries the budgeted history (ticket 01) as its fallback: the driver +//! pastes it in front of the prompt on the fresh session it starts. If the +//! resumed turn fails with a signed-out error, the run's own marking +//! (ticket 08) has already marked that account; the engine moves to the +//! next candidate. Any other failure restores the previously active +//! account — a failed failover leaves the harness as it found it. +//! +//! The RPC entry points (`ListResumeAccountCandidates`, +//! `ResumeOnAnotherAccount`) live in `rpc.rs`; this module is the +//! engine-side state machine, driven through [`SessionsEngine`]. + +use std::collections::HashSet; +use std::time::Duration; + +use roboco_doc::SessionCommandPayload; +use roboco_proto::{ + AgentAccount, AgentSignInState, HarnessId, ResumeAccountCandidates, ResumeOnAccountOutcome, + RESUME_CONTINUE_PROMPT, +}; + +use crate::agent_accounts::AgentAccounts; +use crate::sessions::SessionsEngine; +use crate::{new_id, EngineError}; + +/// Only these harnesses fail over: each keeps one switchable account login +/// whose sessions survive a switch (research 03). Pure. +pub(crate) fn failover_supported(harness: HarnessId) -> bool { + matches!(harness, HarnessId::ClaudeCode | HarnessId::Codex) +} + +fn harness_label(harness: HarnessId) -> &'static str { + match harness { + HarnessId::ClaudeCode => "Claude Code", + HarnessId::Codex => "Codex", + _ => "This harness", + } +} + +/// How long the watcher waits for the continuation turn to START. The +/// command dispatches within milliseconds on an idle chat; a wide bound +/// only covers an agent update holding the queue. +const TURN_START_WINDOW: Duration = Duration::from_secs(30); + +/// Cap on how many candidates one failover chain tries: the ranked list is +/// short, but a store that keeps returning the same row must never spin. +const MAX_ATTEMPTS: usize = 8; + +/// The ranked switch targets for `chat_id` (ticket 09): the cached rows of +/// the chat's harness, best first, plus the account the chat's limit hit. +/// Empty for a harness without failover, or when every other account is +/// limited, signed out, an API key, or unswitchable — then only Resume at +/// reset is offered. [`SessionsEngine::resume_account_candidates`]. +pub(crate) async fn candidates( + engine: &SessionsEngine, + chat_id: &str, +) -> Result<ResumeAccountCandidates, EngineError> { + let session = engine.session_status(chat_id); + // The harness whose accounts to switch: the limit record's when the + // chat is Limited (the common path), else the chat's last run's. + let harness = session + .as_ref() + .and_then(|row| row.limit.as_ref().map(|limit| limit.harness)) + .or_else(|| engine.last_request(chat_id).and_then(|last| last.harness)); + let Some(harness) = harness else { + return Ok(ResumeAccountCandidates { + chat_id: chat_id.to_string(), + harness: HarnessId::ClaudeCode, + limited_account_id: None, + candidates: Vec::new(), + }); + }; + let (limited_account_id, candidates) = match engine.agent_accounts() { + Some(accounts) => { + let limited = session + .as_ref() + .and_then(|row| row.limit.as_ref()) + .and_then(|limit| limit.account_id.clone()); + let rows = if failover_supported(harness) { + accounts.failover_candidates(harness, limited.as_deref()).await + } else { + Vec::new() + }; + (limited, rows) + } + None => (None, Vec::new()), + }; + Ok(ResumeAccountCandidates { + chat_id: chat_id.to_string(), + harness, + limited_account_id, + candidates, + }) +} + +/// **Resume on another account** (ticket 09): rank, probe, activate, +/// retire the warm runtime, dispatch the continuation. Returns the +/// synchronous outcome; the turn's own result arrives through the chat's +/// status, and a signed-out failure is handled by the engine (next +/// candidate, else the previous account restored) before the chat +/// settles. [`SessionsEngine::resume_on_another_account`]. +pub(crate) async fn resume_on_another_account( + engine: &SessionsEngine, + chat_id: &str, + chosen: Option<&str>, +) -> Result<ResumeOnAccountOutcome, EngineError> { + let Some(accounts) = engine.agent_accounts() else { + return Ok(ResumeOnAccountOutcome::Failed { + reason: "This engine has no harness accounts.".into(), + }); + }; + let Some(limit) = engine.session_status(chat_id).and_then(|row| row.limit) else { + return Ok(ResumeOnAccountOutcome::Failed { + reason: "Only a Limited chat can resume on another account.".into(), + }); + }; + let harness = limit.harness; + if !failover_supported(harness) { + return Ok(ResumeOnAccountOutcome::Failed { + reason: format!("{} has no switchable accounts.", harness_label(harness)), + }); + } + // The account to restore if the failover fails: the login the harness + // runs under right now (the account the limit hit, unless the user + // switched since the chat went Limited). + let previous = accounts.active_account(harness).await.map(|row| row.id); + let attempt = attempt( + engine, + &accounts, + chat_id, + harness, + limit.account_id.as_deref(), + previous.clone(), + HashSet::new(), + chosen, + ) + .await; + match attempt { + Attempt::Switched(row) => Ok(ResumeOnAccountOutcome::Switched { + account_id: row.id.clone(), + account_email: row.email.clone().or_else(|| row.display_name.clone()), + }), + Attempt::NoCandidates => Ok(ResumeOnAccountOutcome::NoCandidates), + Attempt::Failed(reason) => Ok(ResumeOnAccountOutcome::Failed { reason }), + } +} + +/// One attempt's result. +enum Attempt { + /// The account was probed, activated and the continuation dispatched; + /// the watcher owns the rest. + Switched(AgentAccount), + /// No healthy candidate remains. + NoCandidates, + /// The attempt failed; nothing was switched. + Failed(String), +} + +/// Probe, activate, retire and dispatch one candidate (skipping it when +/// the probe reveals it is dead or limited — then the next). `pick` +/// names the user's chosen account — used for the FIRST candidate only, +/// later iterations fall back to the best-ranked. `tried` guards against +/// a store that keeps offering the same row. +#[allow(clippy::too_many_arguments)] +async fn attempt( + engine: &SessionsEngine, + accounts: &AgentAccounts, + chat_id: &str, + harness: HarnessId, + limited_account_id: Option<&str>, + previous: Option<String>, + mut tried: HashSet<String>, + mut pick: Option<&str>, +) -> Attempt { + for _ in 0..MAX_ATTEMPTS { + if engine.shutting_down() { + return Attempt::Failed("the engine is shutting down".into()); + } + let mut ranked = accounts + .failover_candidates(harness, limited_account_id) + .await; + // Rows this chain already tried are out — a probe that failed + // transiently (the row never changed) must move ON, not re-pick the + // same account and die on the second attempt. Skipped rows (signed + // out, still limited) drop out of the ranking on their own: their + // probes marked them. + ranked.retain(|row| !tried.contains(&row.id)); + // The user's pick must be a candidate — anything else (the limited + // account, a signed-out row, a foreign id) is refused, not silently + // replaced by the recommendation. + let candidate = match pick.take() { + Some(id) => { + let Some(at) = ranked.iter().position(|row| row.id == id) else { + return Attempt::Failed( + "That account is not a candidate — it is limited, signed out, or in use." + .into(), + ); + }; + ranked.remove(at) + } + None => ranked.first().cloned(), + }; + let Some(candidate) = candidate else { + return Attempt::NoCandidates; + }; + tried.insert(candidate.id.clone()); + // The chosen account is probed ONCE before switching: fresh meters, + // a hard rejection caught before anything is touched. + let row = match accounts.probe_account(harness, &candidate.id).await { + Ok(row) => row, + Err(err) => { + tracing::warn!( + chat = %chat_id, + account = %candidate.id, + error = %err, + "failover probe failed; trying the next candidate" + ); + continue; + } + }; + if row.sign_in_state == AgentSignInState::SignedOut { + tracing::info!( + chat = %chat_id, + account = %candidate.id, + "failover candidate is signed out; trying the next" + ); + continue; + } + if row.limited_until.is_some_and(|until| until > chrono::Utc::now()) { + tracing::info!( + chat = %chat_id, + account = %candidate.id, + "failover candidate is still limited; trying the next" + ); + continue; + } + // Activate — the swap every chat on this harness now runs under. + if let Err(err) = accounts.activate(harness, &row.id).await { + tracing::warn!( + chat = %chat_id, + account = %row.id, + error = %err, + "failover activation failed; trying the next candidate" + ); + continue; + } + // Retire the chat's warm runtime so the continuation starts under + // the new login (the fingerprint guard covers every other chat's + // warm runtime at its next dispatch). + let _ = engine.terminate(chat_id).await; + if let Err(reason) = dispatch_continuation(engine, chat_id, harness) { + // The switch landed but the continuation could not be queued: + // undo it, or the harness is left on an account nothing uses. + restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + return Attempt::Failed(reason); + } + // The watcher owns the turn's outcome: signed-out → the next + // candidate; other failures → the previous account back. + let watcher_engine = engine.clone(); + let watcher_accounts = accounts.clone(); + let watcher_chat = chat_id.to_string(); + let watcher_limited = limited_account_id.map(str::to_owned); + let watcher_previous = previous.clone(); + tokio::spawn(async move { + watch( + &watcher_engine, + &watcher_accounts, + &watcher_chat, + harness, + watcher_limited.as_deref(), + watcher_previous, + tried, + ) + .await; + }); + return Attempt::Switched(row); + } + Attempt::Failed("too many failover attempts".into()) +} + +/// Queue the continuation: the same harness session, resumed with the +/// continue prompt, through the durable command plane (the same seam the +/// composer's Resume uses — queued messages wait behind it, and the turn +/// survives an engine restart between the switch and the dispatch). The +/// budgeted transcript (ticket 01) rides as the request's fallback +/// history, delivered by the driver only when the harness refuses the +/// native resume and starts a fresh session. +fn dispatch_continuation( + engine: &SessionsEngine, + chat_id: &str, + harness: HarnessId, +) -> Result<(), String> { + let last = engine + .last_request(chat_id) + .ok_or_else(|| "the chat has no run configuration to continue".to_string())?; + let resume = engine.harness_session_to_resume(chat_id, &last.cwd); + let fallback = fallback_history(engine, chat_id); + let request = roboco_proto::RunRequest { + prompt: RESUME_CONTINUE_PROMPT.into(), + harness: Some(harness), + model: last.model.clone(), + reasoning: last.reasoning.clone(), + model_options: last.model_options.clone(), + cwd: last.cwd.clone(), + sandbox: last.sandbox, + auto_approve: last.auto_approve, + // The continuation is text only: attachments and worktrees belong + // to the turn that asked for them; the checkout is already where + // the chat ran. Dispatch stamps its own MCP server. + attachments: Vec::new(), + worktree: None, + mcp: None, + // The stored session, resumed EXPLICITLY (bypassing dispatch's + // re-derivation) so the fallback history pairs with it; a failover + // never forks. + resume, + resume_fallback_history: fallback, + provider_fork: None, + }; + let host = engine + .host() + .ok_or_else(|| "the engine's doc host is not wired".to_string())?; + host.queue_command( + chat_id, + SessionCommandPayload::Run { + request, + message_id: new_id(), + }, + ) + .map_err(|err| err.to_string()) +} + +/// The chat's own budgeted transcript — the fallback a fresh session gets +/// when the harness refuses the native resume. The chat itself is the +/// readable source (`read_chat`); the selection rules and budget live in +/// [`crate::transcript_budget`]. +fn fallback_history(engine: &SessionsEngine, chat_id: &str) -> Option<String> { + let handle = engine.host()?.open(chat_id).ok()?; + let entries = handle.doc().read_entries().unwrap_or_default(); + crate::transcript_budget::budgeted_transcript_prefix(&entries, Some(chat_id)) +} + +/// Watch the continuation turn (fire-and-forget): once it settles, +/// a signed-out failure moves to the next candidate (the run's error has +/// already marked the account — ticket 08); any other failure restores the +/// previously active account. A turn the user stopped, or a chat that +/// vanished, ends the chain and leaves the switch standing — the user's +/// stop is not a failed failover. +async fn watch( + engine: &SessionsEngine, + accounts: &AgentAccounts, + chat_id: &str, + harness: HarnessId, + limited_account_id: Option<&str>, + previous: Option<String>, + tried: HashSet<String>, +) { + // Phase 1: the continuation starts. The queued command dispatches + // within milliseconds; the bound only covers a held queue. + let start_by = tokio::time::Instant::now() + TURN_START_WINDOW; + loop { + if engine.shutting_down() { + return; + } + match engine.session_status(chat_id).map(|row| row.status) { + Some(roboco_proto::SessionStatus::Working) => break, + _ if tokio::time::Instant::now() >= start_by => { + tracing::info!( + chat = %chat_id, + "failover continuation never started; leaving the queued command to drain" + ); + return; + } + _ => tokio::time::sleep(Duration::from_millis(100)).await, + } + } + // Phase 2: settle. AwaitingInput is still the user's turn in progress. + let settled = loop { + if engine.shutting_down() { + return; + } + let status = engine.session_status(chat_id).map(|row| row.status); + match status { + Some(roboco_proto::SessionStatus::Idle) + | Some(roboco_proto::SessionStatus::Errored) + | None => break status, + Some( + roboco_proto::SessionStatus::Working + | roboco_proto::SessionStatus::AwaitingInput, + ) => tokio::time::sleep(Duration::from_millis(250)).await, + } + }; + match settled { + Some(roboco_proto::SessionStatus::Errored) => { + // Why did it fail? A signed-out error has already marked the + // switched-to account (the run's own wiring, ticket 08): the + // ACTIVE row reading SignedOut is that mark. + let active = accounts.active_account(harness).await; + let signed_out = active + .as_ref() + .is_some_and(|row| row.sign_in_state == AgentSignInState::SignedOut); + if signed_out { + tracing::info!( + chat = %chat_id, + "failover resume failed signed out; trying the next candidate" + ); + let next = attempt( + engine, + accounts, + chat_id, + harness, + limited_account_id, + previous.clone(), + tried, + None, + ) + .await; + // The chain ran out of candidates (or the store broke): the + // failover failed as a whole — put the previous account + // back so the harness is not left on a login whose turn + // just failed and nothing uses. + if matches!(next, Attempt::NoCandidates | Attempt::Failed(_)) { + restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + } + } else { + restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + } + } + // Completed, interrupted (the user's stop — not a failed + // failover), or the chat went away: the chain ends here. + _ => {} + } +} + +/// Put the previously active account back (best-effort): a failover that +/// failed left the harness on an account whose turn errored — the world +/// returns to the login the chats were running under. +async fn restore( + engine: &SessionsEngine, + accounts: &AgentAccounts, + chat_id: &str, + harness: HarnessId, + previous: Option<&str>, +) { + let Some(previous) = previous else { + return; + }; + if let Err(err) = accounts.activate(harness, previous).await { + tracing::warn!( + chat = %chat_id, + account = %previous, + error = %err, + "could not restore the previous harness account after a failed failover" + ); + } +} diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index da9c1a3c45..d875d1426a 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -1002,6 +1002,135 @@ impl AgentAccounts { .find(|account| account.harness == harness && account.active) } + /// The ranked failover candidates for `harness` (orchestration v2, + /// ticket 09) — the CACHED rows, no network: the forced probe is the + /// engine's call on the CHOSEN candidate only ([`Self::probe_account`]). + /// `limited_account_id` names the account the chat's limit hit (the + /// limit record's account, else the harness's active login when the + /// record carried none). See [`rank_failover_candidates`] for the + /// exclusion and ranking rules. + pub async fn failover_candidates( + &self, + harness: HarnessId, + limited_account_id: Option<&str>, + ) -> Vec<AgentAccount> { + let Ok(snapshot) = self.list(false).await else { + return Vec::new(); + }; + // The org/workspace group of every row the ranker demotes by. The + // Codex row carries no account key on the wire, so the groups come + // from the slots (id → key); Claude's group IS the row's + // organization field. + let slot_groups: HashMap<String, Option<String>> = self + .read_slots(harness) + .into_iter() + .map(|slot| { + let group = failover_group( + harness, + slot.profile.organization.as_deref(), + &slot.account_key, + ); + (slot.id, group) + }) + .collect(); + let rows: Vec<FailoverCandidate> = snapshot + .accounts + .into_iter() + .filter(|row| row.harness == harness) + .map(|row| { + let group = match harness { + HarnessId::ClaudeCode => { + failover_group(harness, row.organization.as_deref(), "") + } + _ => slot_groups.get(&row.id).cloned().flatten(), + }; + FailoverCandidate { row, group } + }) + .collect(); + // The account the limit hit: by id when the record names it, else + // the live login. Its group demotes its same-pool peers. + let limited = rows.iter().find(|candidate| { + limited_account_id.is_some_and(|id| id == candidate.row.id) + || (limited_account_id.is_none() && candidate.row.active) + }); + let (limited_id, limited_group) = match limited { + Some(candidate) => (Some(candidate.row.id.clone()), candidate.group.clone()), + None => (None, None), + }; + rank_failover_candidates(rows, limited_id.as_deref(), limited_group.as_deref()) + } + + /// One candidate's usage probe (ticket 09): refresh ONLY this account's + /// cached meters — honoring the cache's own backoff and freshness gates, + /// so a rate-limited usage endpoint is never re-probed to death — and + /// return the fresh row. The engine probes the chosen candidate once + /// before switching: a hard rejection (`SignedOut`) or a newly-exhausted + /// window tells it to move to the next candidate instead. Never touches + /// the other accounts' cache entries (the sweep that forgets removed + /// slots belongs to [`Self::refresh_usage`], which sees every slot). + pub async fn probe_account( + &self, + harness: HarnessId, + account_id: &str, + ) -> Result<AgentAccount, EngineError> { + let _ops = self.inner.ops.lock().await; + let slot = self + .read_slots(harness) + .into_iter() + .find(|s| s.id == account_id) + .ok_or_else(|| { + EngineError::Other( + "That saved login no longer exists — refresh and try again.".into(), + ) + })?; + let active = self + .live_account_key(harness, slot.store_key.as_deref()) + .await + .as_deref() + == Some(slot.account_key.as_str()); + let key = usage_key(harness, &slot.account_key); + let credentials = credentials_fingerprint(&slot.credentials); + let now = now_ms(); + let due = lock(&self.inner.usage) + .get(&key) + .is_none_or(|entry| entry.probe_due(&credentials, now)); + if due && lock(&self.inner.inflight_probes).insert(key.clone()) { + let result = self.probe_usage(harness, &slot, active).await; + lock(&self.inner.inflight_probes).remove(&key); + let mut usage = lock(&self.inner.usage); + usage + .entry(key) + .or_default() + .record(result, credentials, now); + self.persist_usage(&usage); + } + self.list_locked(false) + .await? + .accounts + .into_iter() + .find(|row| row.harness == harness && row.id == account_id) + .ok_or_else(|| { + EngineError::Other("The account vanished while probing it.".into()) + }) + } + + /// Fingerprint of the CLI store's live login for `harness` — a digest of + /// the credential bytes every new run on this harness would use. The + /// sessions engine stamps it on every warm runtime it starts, so a + /// runtime launched under another login is never reused after a switch + /// (ticket 09). `None` for a harness whose live store roboco does not + /// read — and for a Claude Keychain login, which has no file: no + /// fingerprint, no forced retirement. + pub fn live_credentials_fingerprint(&self, harness: HarnessId) -> Option<String> { + let file = match harness { + HarnessId::ClaudeCode => self.inner.config.claude_creds_file(), + HarnessId::Codex => self.inner.config.codex_auth_file(), + _ => return None, + }; + let bytes = std::fs::read(&file).ok()?; + Some(crate::repos::hex(&Sha256::digest(&bytes))[..16].to_string()) + } + /// Caller holds [`Inner::ops`]. async fn list_locked(&self, force_usage: bool) -> Result<AgentAccountsSnapshot, EngineError> { let mut warnings: Vec<AgentAccountWarning> = Vec::new(); @@ -2844,8 +2973,13 @@ impl AgentAccounts { .map(|(harness, slot, _)| usage_key(*harness, &slot.account_key)) .collect(); usage.retain(|key, _| live.contains(key)); - // Persist under the lock: overlapping lists must not interleave - // writes of the same file. + self.persist_usage(&usage); + } + + /// Write the usage cache to disk under its lock: overlapping probes and + /// lists must not interleave writes of the same file. Best-effort — a + /// failed write keeps the in-memory state (the next change retries). + fn persist_usage(&self, usage: &HashMap<String, UsageEntry>) { let file = UsageCacheFile { entries: usage.clone(), }; @@ -3622,6 +3756,109 @@ fn usage_key(harness: HarnessId, account_key: &str) -> String { format!("{}:{account_key}", harness_slug(harness)) } +/// One row in the failover ranking (ticket 09): the wire account plus the +/// engine-local org/workspace group the ranking demotes shared-limit pools +/// by (see [`failover_group`]). +struct FailoverCandidate { + row: AgentAccount, + group: Option<String>, +} + +/// The org or workspace an account may share a limit pool with (ticket 09): +/// Claude's organization name, Codex's ChatGPT workspace (the account key's +/// `user::workspace` tail — team seats share the workspace id; personal +/// logins keep their own key and share nothing). `None` when the account +/// names no group. Lower-cased: it is an identity, not a display value. +fn failover_group( + harness: HarnessId, + organization: Option<&str>, + account_key: &str, +) -> Option<String> { + match harness { + HarnessId::ClaudeCode => organization + .map(str::trim) + .filter(|org| !org.is_empty()) + .map(|org| org.to_ascii_lowercase()), + HarnessId::Codex => Some( + account_key + .split_once("::") + .map(|(_, workspace)| workspace) + .unwrap_or(account_key) + .to_ascii_lowercase(), + ), + _ => None, + } +} + +/// Rank the failover candidates for a Limited chat's "Resume on another +/// account" (ticket 09). Pure. +/// +/// **Excluded:** +/// - the account that hit the limit (`limited_id`, the row named by the +/// chat's limit record) — and every ACTIVE row (the login the next run +/// would use anyway; switching to it is a no-op); +/// - non-switchable rows (Hermes' own pool, credentials roboco could not +/// read, single-login agents); +/// - API-key logins (no plan windows — a switch has nothing to fail over +/// to); +/// - accounts marked signed out (ticket 08 — they offer "Sign in again" +/// instead); +/// - accounts still limited (`limited_until` in the future). +/// +/// **Ranked:** lowest usage first — the account's most-used window +/// (`0.0` when the meters are unknown); among equals, accounts OUTSIDE the +/// limited account's org/workspace rank first (a shared pool may hit the +/// same wall again). The pre-rank order (slot creation order) breaks the +/// remaining ties so the ranking is deterministic. +fn rank_failover_candidates( + candidates: Vec<FailoverCandidate>, + limited_id: Option<&str>, + limited_group: Option<&str>, +) -> Vec<AgentAccount> { + let now = Utc::now(); + let mut ranked: Vec<(bool, f32, usize, AgentAccount)> = Vec::new(); + for (index, candidate) in candidates.into_iter().enumerate() { + let row = candidate.row; + if row.active || limited_id.is_some_and(|id| id == row.id) { + continue; // the account the limit hit / the live login + } + if !row.switchable { + continue; // Hermes' pool, unreadable credentials, single logins + } + if row.auth_kind == Some(AgentAuthKind::ApiKey) { + continue; // API keys have no plan windows to fail over to + } + if row.sign_in_state == AgentSignInState::SignedOut { + continue; // dead login — "Sign in again", not a candidate + } + if row + .limited_until + .is_some_and(|until| until > now) + { + continue; // still limited + } + // Lowest usage first: the account's most-used window (an account + // without meters has no cached evidence of exhaustion). + let usage = row + .usage_windows + .iter() + .map(|window| window.used_fraction.clamp(0.0, 1.0)) + .fold(0.0_f32, f32::max); + let shares_pool = candidate + .group + .as_deref() + .zip(limited_group) + .is_some_and(|(mine, limited)| mine == limited); + ranked.push((shares_pool, usage, index, row)); + } + ranked.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.total_cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + ranked.into_iter().map(|(_, _, _, row)| row).collect() +} + /// A short digest of a slot's credential blob — tells "same token that was /// rejected" from "the CLI refreshed / the user re-logged in". Never the /// secret itself: this lands in the usage cache file. @@ -5125,6 +5362,124 @@ mod probe_tests { "the transport-failed probe must not be followed by a refresh POST" ); } + + // ── failover ranking (orchestration v2, ticket 09) ──────────────────── + + fn candidate( + id: &str, + used: Option<f32>, + group: Option<&str>, + mut row: AgentAccount, + ) -> FailoverCandidate { + row.id = id.into(); + row.usage_windows = used + .map(|fraction| { + vec![AgentUsageWindow { + label: "Session".into(), + used_fraction: fraction, + resets_at: None, + }] + }) + .unwrap_or_default(); + FailoverCandidate { + row, + group: group.map(str::to_owned), + } + } + + fn plain_row() -> AgentAccount { + serde_json::from_value(serde_json::json!({ + "id": "row", + "harness": "claude-code", + "email": null, + "planLabel": null, + "active": false, + "switchable": true, + "authKind": "oauth", + })) + .unwrap() + } + + fn ids(rows: &[AgentAccount]) -> Vec<&str> { + rows.iter().map(|row| row.id.as_str()).collect() + } + + /// The exclusions, one per row: the limit's own account and the active + /// login, non-switchable rows, API keys, signed-out accounts, and + /// accounts still limited never surface as candidates. + #[test] + fn failover_ranking_excludes_dead_and_current_rows() { + let mut active = plain_row(); + active.active = true; + let mut unswitchable = plain_row(); + unswitchable.switchable = false; + let mut api_key = plain_row(); + api_key.auth_kind = Some(AgentAuthKind::ApiKey); + let mut signed_out = plain_row(); + signed_out.sign_in_state = AgentSignInState::SignedOut; + let mut still_limited = plain_row(); + still_limited.limited_until = Some(Utc::now() + TimeDelta::hours(3)); + let expired_limit = { + // A limited_until in the PAST: the window lifted. + let mut row = plain_row(); + row.limited_until = Some(Utc::now() - TimeDelta::hours(1)); + row + }; + let rows = vec![ + candidate("a-active", Some(0.1), None, active), + candidate("b-limited-id", Some(0.2), None, plain_row()), + candidate("c-unswitchable", Some(0.3), None, unswitchable), + candidate("d-api-key", Some(0.4), None, api_key), + candidate("e-signed-out", Some(0.5), None, signed_out), + candidate("f-still-limited", Some(0.6), None, still_limited), + candidate("g-lifted", Some(0.7), None, expired_limit), + candidate("h-healthy", Some(0.8), None, plain_row()), + ]; + let ranked = rank_failover_candidates(rows, Some("b-limited-id"), None); + assert_eq!(ids(&ranked), vec!["g-lifted", "h-healthy"]); + } + + /// Lowest usage first (unknown meters read as nothing used), and among + /// equals the accounts OUTSIDE the limited account's org or workspace + /// rank first. + #[test] + fn failover_ranking_prefers_low_usage_then_other_pools() { + let rows = vec![ + candidate("high", Some(0.9), Some("acme"), plain_row()), + candidate("low", Some(0.1), Some("acme"), plain_row()), + candidate("other-pool", Some(0.1), Some("globex"), plain_row()), + candidate("unknown", None, Some("acme"), plain_row()), + ]; + let ranked = rank_failover_candidates(rows, Some("limited"), Some("acme")); + assert_eq!( + ids(&ranked), + vec!["other-pool", "unknown", "low", "high"], + "lowest usage first (no meters read as nothing used); a different pool wins ties" + ); + } + + /// The org/workspace group: Claude's organization (case-insensitive), + /// Codex's workspace tail of the account key, absent elsewhere. + #[test] + fn failover_groups_map_claude_orgs_and_codex_workspaces() { + assert_eq!( + failover_group(HarnessId::ClaudeCode, Some("Acme Corp "), ""), + Some("acme corp".into()) + ); + assert_eq!(failover_group(HarnessId::ClaudeCode, Some(""), ""), None); + assert_eq!(failover_group(HarnessId::ClaudeCode, None, ""), None); + assert_eq!( + failover_group(HarnessId::Codex, None, "user-1::ws-9"), + Some("ws-9".into()) + ); + // A personal login (no workspace tail) shares nothing: its whole key + // IS its identity. + assert_eq!( + failover_group(HarnessId::Codex, None, "dev@example.com"), + Some("dev@example.com".into()) + ); + assert_eq!(failover_group(HarnessId::Pi, Some("Acme"), "k"), None); + } } #[cfg(test)] diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index e17abb7819..aaa909b8a5 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -3264,6 +3264,7 @@ impl DocHost { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74629c7ebb..8d776492b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ use roboco_rpc::{RpcError, RpcReply, RpcService, methods}; use roboco_sync::DocsStore; +pub mod account_failover; pub mod agent_accounts; pub mod change_requests; mod chat_persistence; diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 21d4b67c75..d945de6f11 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -556,6 +556,16 @@ struct AgentAccountParams { account_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResumeOnAccountParams { + chat_id: String, + /// The account the user picked; `None` takes the engine's + /// recommendation (the best-ranked candidate). + #[serde(default)] + account_id: Option<String>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct StartAgentLoginParams { @@ -3021,6 +3031,24 @@ impl RpcService for EngineRpc { .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&snapshot) } + methods::LIST_RESUME_ACCOUNT_CANDIDATES => { + let p: ChatParams = parse_params(params)?; + let candidates = self + .sessions + .resume_account_candidates(&p.chat_id) + .await + .map_err(|e| RpcError::Failed(e.to_string()))?; + RpcReply::value(&candidates) + } + methods::RESUME_ON_ANOTHER_ACCOUNT => { + let p: ResumeOnAccountParams = parse_params(params)?; + let outcome = self + .sessions + .resume_on_another_account(&p.chat_id, p.account_id.as_deref()) + .await + .map_err(|e| RpcError::Failed(e.to_string()))?; + RpcReply::value(&outcome) + } methods::FORGET_AGENT_ACCOUNT => { let p: AgentAccountParams = parse_params(params)?; let snapshot = self diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 335ae114c2..a261be6505 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -30,8 +30,9 @@ use roboco_doc::{ }; use roboco_harness::{CancellationToken, Harness, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentErrorKind, AgentEvent, DoneStatus, HarnessId, RunRequest, Session, SessionLimit, - SessionStatus, UserInputAnswer, UserInputQuestion, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, ResumeAccountCandidates, + ResumeOnAccountOutcome, RunRequest, Session, SessionLimit, SessionStatus, UserInputAnswer, + UserInputQuestion, }; use crate::doc_host::{ChatDocHandle, DocHost}; @@ -143,6 +144,14 @@ struct RunHandle { /// This runtime's provider session holds the fork's copied history (a /// side chat's bootstrap went out on its run or on one of its steers). fork_history_sent: Arc<std::sync::atomic::AtomicBool>, + /// The credential fingerprint of the harness login this runtime's + /// processes were launched under (account failover, orchestration v2 + /// ticket 09): warm runtimes keep the token they started with, so a + /// dispatch whose fresh read of the live credentials differs never + /// routes into this runtime — it replaces it. `None` when the harness's + /// login is not fingerprinted (no account store, a Keychain login, a + /// harness roboco never reads). + credential_fingerprint: Option<String>, /// The parent chat this run's CURRENT turn owes a completion notice to /// (orchestration v2): set when the turn's starting message or a steer /// within it came from the notifying parent that created the chat, @@ -327,6 +336,47 @@ impl SessionsEngine { *lock(&self.inner.agent_accounts) = Some(accounts); } + /// The engine's harness-account store (crate-internal): the account + /// failover (orchestration v2, ticket 09) ranks, probes and switches + /// accounts on it, and every dispatch fingerprints the live login. + pub(crate) fn agent_accounts(&self) -> Option<crate::agent_accounts::AgentAccounts> { + lock(&self.inner.agent_accounts).clone() + } + + /// The harness-native session a run of `chat_id` from `cwd` would + /// resume (crate-internal; the failover's continuation names it + /// explicitly so its fallback history can pair with it). + pub(crate) fn harness_session_to_resume(&self, chat_id: &str, cwd: &str) -> Option<String> { + self.inner.resume_for(chat_id, cwd) + } + + /// The ranked switch targets for one Limited chat's "Resume on another + /// account" (orchestration v2, ticket 09): the cached harness accounts + /// of the chat's harness, best first. Empty when every other account is + /// limited, signed out, an API key, or unswitchable — then only Resume + /// at reset is offered. + pub async fn resume_account_candidates( + &self, + chat_id: &str, + ) -> Result<ResumeAccountCandidates, EngineError> { + crate::account_failover::candidates(self, chat_id).await + } + + /// **Resume on another account** (ticket 09): rank the cached harness + /// accounts, probe the chosen one (or the best), activate it, retire the + /// chat's warm runtime, and queue the continuation — the same harness + /// session resumed with the continue prompt, the budgeted history + /// (ticket 01) riding as the fallback for a refused native resume. A + /// signed-out failure after the switch moves to the next candidate; + /// any other failure restores the previously active account. + pub async fn resume_on_another_account( + &self, + chat_id: &str, + chosen: Option<&str>, + ) -> Result<ResumeOnAccountOutcome, EngineError> { + crate::account_failover::resume_on_another_account(self, chat_id, chosen).await + } + fn note_turn_start(&self, chat_id: &str, cwd: &str) { if let Some(listener) = self.inner.turn_listener.get() { listener(chat_id, cwd); @@ -622,11 +672,29 @@ impl SessionsEngine { // cross-harness delivery before recording or routing the user turn. roboco_proto::invocation::validate_harness_invocations(&request.prompt, harness_id) .map_err(EngineError::Other)?; + // The live login's fingerprint, fresh from the CLI store (no value + // when the harness's login is not fingerprinted). Compared against + // the warm runtime's stamp below: a runtime started under another + // login is never reused — it holds the old token in memory (ticket 09). + let live_login = self.inner.live_login_fingerprint(harness_id); let routed = lock(&self.inner.runs).get(chat_id).map(|h| { + // A fingerprint mismatch (a switch the engine observed since this + // runtime launched) downgrades ANY route — including an in-place + // reconfiguration — to "replace the runtime": the warm process + // must not run the next turn on the old token. + let same_login = h.credential_fingerprint.as_deref().is_none_or( + |fingerprint| { + live_login + .as_deref() + .is_none_or(|live| live == fingerprint) + }, + ); ( h.run_id.clone(), h.steerable && !h.retiring.load(std::sync::atomic::Ordering::Acquire), - h.runtime_config.route(harness_id, &request), + h.runtime_config + .route(harness_id, &request) + .filter(|_| same_login), h.steer_tx.clone(), h.routed_steers.clone(), h.fork_history_sent.clone(), @@ -873,6 +941,10 @@ impl SessionsEngine { pending_inputs, routed_steers: Arc::new(Mutex::new(std::collections::VecDeque::new())), fork_history_sent: fork_history_sent.clone(), + // Stamped at launch with the login the runtime's processes + // start under: later dispatches compare against a fresh read + // and replace this runtime when the login has changed. + credential_fingerprint: live_login.clone(), notifiable: Mutex::new( self.notifiable_parent_for(chat_id, origin_chat_id.as_deref()), ), @@ -1369,6 +1441,7 @@ impl SessionsEngine { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }) @@ -1757,6 +1830,16 @@ impl Inner { self.doc_host().and_then(|host| host.workspace().cloned()) } + /// The harness login's current credential fingerprint, read fresh from + /// the CLI store (account failover, orchestration v2 ticket 09). `None` + /// when no account store is wired or the harness's login is not + /// file-readable — then no runtime is ever retired by fingerprint. + fn live_login_fingerprint(&self, harness: HarnessId) -> Option<String> { + lock(&self.agent_accounts) + .clone() + .and_then(|accounts| accounts.live_credentials_fingerprint(harness)) + } + /// Sidebar freshness: push a message-persist preview into the chat's workspace row. fn note_message(&self, chat_id: &str, text: &str) { if text.is_empty() { @@ -2637,6 +2720,8 @@ async fn drive_run( // pasted prompt would get (Codex: file mentions), so a // refused fork's fresh session reads a transcript as // close to the native paste as the driver can deliver. + // The resume fallback (ticket 09's account-failover + // continuation) converts the same way. if let Some(fork) = wire_request.provider_fork.as_mut() && let Some(history) = fork.fallback_history.clone() { @@ -2644,6 +2729,11 @@ async fn drive_run( roboco_proto::invocation::harness_prompt(&history, harness_id), ); } + if let Some(history) = wire_request.resume_fallback_history.clone() { + wire_request.resume_fallback_history = Some( + roboco_proto::invocation::harness_prompt(&history, harness_id), + ); + } } harness.run(wire_request, controls).await } @@ -4263,6 +4353,7 @@ mod tests { auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: Vec::new(), worktree: None, mcp: None, diff --git a/crates/engine/src/titles.rs b/crates/engine/src/titles.rs index c564c80ef9..310fd52d2d 100644 --- a/crates/engine/src/titles.rs +++ b/crates/engine/src/titles.rs @@ -211,6 +211,7 @@ impl TitleGenerator { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }; @@ -382,6 +383,7 @@ mod tests { attachments: vec![], worktree: None, provider_fork: None, + resume_fallback_history: None, mcp: None, }; let result = collect_text(&harness, request, None).await; diff --git a/crates/engine/tests/account_failover.rs b/crates/engine/tests/account_failover.rs new file mode 100644 index 0000000000..b9044c6f40 --- /dev/null +++ b/crates/engine/tests/account_failover.rs @@ -0,0 +1,960 @@ +//! Resume on another account (orchestration v2, ticket 09): the engine +//! operation behind a Limited Claude Code chat's account failover. +//! +//! The candidates come from a SEEDED, fully isolated harness-account store +//! (slot files + usage cache + a live Claude login — no network: the cache +//! is fresh, so the failover's one probe reads it), and the harness seam is +//! a scripted stand-in that records every run request it receives and plays +//! the driver's part: a refused native resume starts a fresh session and +//! pastes the budgeted history in front of the prompt, exactly the way the +//! Codex driver does. +//! +//! Coverage (the ticket's checklist, at the engine RPC seam): +//! - the ranking (excludes signed-out, limited, API-key and the live +//! account; lowest usage first, same-org accounts ranked lower); +//! - the switch itself (activate, retire the warm runtime by credential +//! fingerprint, resume the SAME harness session id with the continue +//! prompt and the budgeted fallback history); +//! - the refused-resume fresh-session fallback; +//! - a signed-out failure after the switch moving to the next candidate; +//! a plain failure restoring the previous account; +//! - no healthy candidate → `noCandidates` (only Resume at reset). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::stream::BoxStream; + +use roboco_doc::SessionCommandPayload; +use roboco_engine::{AgentAccounts, AgentAccountsConfig, EngineCore, HarnessRegistry}; +use roboco_harness::{Harness, HarnessError, RunControls}; +use roboco_proto::{ + AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, ResumeAccountCandidates, + ResumeOnAccountOutcome, RunRequest, SandboxLevel, SessionStatus, SteeringMode, +}; +use roboco_rpc::methods; + +const LIMITED: &str = "chat-limited"; +const WARM: &str = "chat-warm"; + +/// What the scripted harness plays for the chat's continuation run (run +/// index 1 on the limited chat). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Continuation { + /// The native resume works: the same session id answers. + Resumed, + /// The harness REFUSES the native resume: a fresh session id answers, + /// the budgeted history pasted in front of the prompt (the driver's + /// part — what Codex does on a failed `thread/resume`). + Refused, + /// The continuation fails with ticket 05's signed-out kind. + SignedOut, + /// The continuation fails with an unclassified error. + PlainFailure, +} + +/// One run the scripted harness received, as the engine handed it over plus +/// what a driver would have delivered (the fallback pasted on a refusal). +#[derive(Clone, Debug)] +struct RecordedRun { + chat: String, + run: usize, + prompt: String, + resume: Option<String>, + fallback: Option<String>, + delivered_prompt: String, + session_id: String, +} + +/// A persistent, steerable Claude-shaped stand-in. Run 0 of the limited +/// chat ends on a classified usage-limit error (the driver shape ticket 05 +/// landed, the settle ticket 06 records); run 0 of the warm chat completes +/// and PARKS (the stream never ends) so its runtime stays warm; run 1 of +/// the limited chat plays [`Continuation`]; later runs complete. +struct FailoverHarness { + continuation: Continuation, + runs: Arc<Mutex<Vec<RecordedRun>>>, + counts: Arc<Mutex<HashMap<String, usize>>>, +} + +impl FailoverHarness { + fn new(continuation: Continuation) -> Arc<Self> { + Arc::new(Self { + continuation, + runs: Arc::new(Mutex::new(Vec::new())), + counts: Arc::new(Mutex::new(HashMap::new())), + }) + } + + fn runs_for(&self, chat: &str) -> Vec<RecordedRun> { + self.runs + .lock() + .unwrap() + .iter() + .filter(|run| run.chat == chat) + .cloned() + .collect() + } + + fn run_count(&self, chat: &str) -> usize { + self.counts.lock().unwrap().get(chat).copied().unwrap_or(0) + } +} + +#[async_trait] +impl Harness for FailoverHarness { + fn id(&self) -> HarnessId { + HarnessId::ClaudeCode + } + fn display_name(&self) -> &str { + "Claude Code" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::TurnBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + fn deterministic_turn_end(&self) -> bool { + true + } + async fn run( + &self, + request: RunRequest, + _controls: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + // The engine stamps the originating chat on the injected MCP server. + let chat = request + .mcp + .as_ref() + .and_then(|mcp| mcp.env.get("ROBOCO_CHAT_ID").cloned()) + .unwrap_or_default(); + let run = { + let mut counts = self.counts.lock().unwrap(); + let next = counts.entry(chat.clone()).or_insert(0); + *next += 1; + *next - 1 + }; + // The driver's delivery rule: a refused native resume pastes the + // budgeted fallback in front of the prompt on the fresh session. + let refuse = run == 1 && chat == LIMITED && self.continuation == Continuation::Refused; + let delivered_prompt = match (&request.resume_fallback_history, refuse) { + (Some(history), true) => format!("{history}{}", request.prompt), + _ => request.prompt.clone(), + }; + let session_id = match (chat.as_str(), run) { + (LIMITED, 0) => "sess-0".to_string(), + (LIMITED, 1) if self.continuation == Continuation::Refused => "fresh-9".to_string(), + (LIMITED, 1) => "sess-0".to_string(), + (LIMITED, _) => "sess-0".to_string(), + (WARM, 0) => "warm-sess".to_string(), + (WARM, _) => format!("warm-sess-{run}"), + _ => "sess".to_string(), + }; + self.runs.lock().unwrap().push(RecordedRun { + chat: chat.clone(), + run, + prompt: request.prompt.clone(), + resume: request.resume.clone(), + fallback: request.resume_fallback_history.clone(), + delivered_prompt, + session_id: session_id.clone(), + }); + let harness = HarnessId::ClaudeCode; + let cwd = request.cwd.clone(); + let mut events = vec![Ok(AgentEvent::SessionStarted { + harness, + model: "claude-test".into(), + tools: vec![], + cwd, + session_id: session_id.clone(), + assistant_message_id: format!("a-{chat}-{run}"), + })]; + match (chat.as_str(), run) { + (LIMITED, 0) => { + // The classified limit error, then the errored Done — the + // shape that turns the chat Limited (ticket 06). + events.push(Ok(AgentEvent::Error { + message: "Claude 5-hour limit reached — try again after it resets.".into(), + kind: Some(roboco_proto::AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events).boxed()) + } + (LIMITED, 1) => match self.continuation { + Continuation::Resumed | Continuation::Refused => { + events.push(Ok(AgentEvent::TextDelta { + text: "Picked the work back up on another account.".into(), + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events).boxed()) + } + Continuation::SignedOut => { + events.push(Ok(AgentEvent::Error { + message: "Authentication failed — sign in to Claude again.".into(), + kind: Some(roboco_proto::AgentErrorKind::SignedOut), + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("Authentication failed — sign in to Claude again.".into()), + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events).boxed()) + } + Continuation::PlainFailure => { + events.push(Ok(AgentEvent::Error { + message: "The agent child crashed.".into(), + kind: None, + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events).boxed()) + } + }, + (WARM, 0) => { + // A cleanly completed turn on a steerable harness PARKS: the + // stream stays open, the runtime stays warm for the next + // routed dispatch. + events.push(Ok(AgentEvent::TextDelta { + text: "warm turn done".into(), + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events) + .chain(futures::stream::pending::<Result<AgentEvent, HarnessError>>()) + .boxed()) + } + _ => { + // The chain's later runs (and the warm chat's replaced + // runtime): plain completions. + events.push(Ok(AgentEvent::TextDelta { + text: "later turn done".into(), + })); + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some(session_id), + })); + Ok(futures::stream::iter(events).boxed()) + } + } + } +} + +/// A local stand-in for the usage endpoint: nothing should ever reach it +/// (the seeded cache is fresh, so the failover's probe reads it), but a +/// stray probe answers green instead of touching the network. +async fn usage_server() -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind mock usage server"); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + let mut request = vec![0u8; 16 * 1024]; + let _ = socket.read(&mut request).await.unwrap_or(0); + let body = + r#"{"five_hour":{"utilization":6.0,"resets_at":"2026-10-18T08:00:00Z"}}"#; + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: \ + {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(reply.as_bytes()).await; + let _ = socket.shutdown().await; + }); + } + }); + base +} + +/// The live Claude login (the account the limit hits) under `config`. +fn write_claude_login(config: &AgentAccountsConfig) { + std::fs::create_dir_all(&config.claude_config_dir).expect("claude config dir"); + std::fs::write( + &config.claude_config_file, + serde_json::json!({ + "oauthAccount": { + "accountUuid": "uuid-alice", + "emailAddress": "alice@example.com", + "organizationName": "Acme", + }, + }) + .to_string(), + ) + .expect("claude config"); + std::fs::write( + config.claude_config_dir.join(".credentials.json"), + serde_json::json!({ + "claudeAiOauth": { + "accessToken": "alice-token", + "refreshToken": "refresh-alice-token", + // Far-future expiry: probes must never try to rotate it. + "expiresAt": 4_102_444_800_000i64, + } + }) + .to_string(), + ) + .expect("claude creds"); +} + +/// One saved Claude slot + its cached usage. `windows`/`sign_in` seed the +/// usage cache keyed by the slot's account key; a FRESH `checked_at` (now) +/// keeps the failover's probe reading the cache instead of the network. +fn write_slot( + config: &AgentAccountsConfig, + id: &str, + account_key: &str, + email: &str, + organization: Option<&str>, + auth_kind: &str, + usage: &str, + sign_in: Option<&str>, +) { + let dir = config.data_dir.join("agent-accounts").join("claude-code"); + std::fs::create_dir_all(&dir).expect("slots dir"); + let token = format!("{}-token", account_key); + let slot = serde_json::json!({ + "id": id, + "harness": "claude-code", + "accountKey": account_key, + "profile": { + "email": email, + "organization": organization, + "plan": "Pro", + "authKind": auth_kind, + }, + "credentials": { + "claudeAiOauth": { + "accessToken": token, + "refreshToken": format!("refresh-{token}"), + "expiresAt": 4_102_444_800_000i64, + } + }, + "savedAt": 1_762_000_000_000i64, + }); + std::fs::write(dir.join(format!("{id}.json")), slot.to_string()).expect("slot file"); + + // The usage cache: keyed `claude-code:{account_key}`, fresh so nothing + // probes. `usage` is the windows array JSON; `sign_in` the hard evidence. + let cache_path = config.data_dir.join("agent-accounts").join("usage-cache.json"); + let mut cache: serde_json::Value = std::fs::read_to_string(&cache_path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| serde_json::json!({ "entries": {} })); + cache["entries"][&format!("claude-code:{account_key}")] = serde_json::json!({ + "usage": { "windows": serde_json::from_str::<serde_json::Value>(usage).unwrap() }, + "checked_at": chrono::Utc::now().timestamp_millis(), + "sign_in": sign_in, + }); + std::fs::write(&cache_path, cache.to_string()).expect("usage cache"); +} + +/// The full candidate zoo: carol (0.05, other org — the recommendation), +/// bob (0.2, no org), gina (0.05, the limited account's org — ranked below +/// bob), and the three exclusions (signed-out dave, still-limited erin, +/// API-key frank). +fn seed_store(config: &AgentAccountsConfig) { + write_claude_login(config); + write_slot( + config, + "slot-carol", + "uuid-carol", + "carol@example.com", + Some("Globex"), + "oauth", + r#"[{"label":"Session","usedFraction":0.05,"resetsAt":"2026-10-18T08:00:00Z"}]"#, + Some("signed-in"), + ); + write_slot( + config, + "slot-bob", + "uuid-bob", + "bob@example.com", + None, + "oauth", + r#"[{"label":"Session","usedFraction":0.2,"resetsAt":"2026-10-18T08:00:00Z"}]"#, + Some("signed-in"), + ); + write_slot( + config, + "slot-gina", + "uuid-gina", + "gina@example.com", + Some("Acme"), + "oauth", + r#"[{"label":"Session","usedFraction":0.05,"resetsAt":"2026-10-18T08:00:00Z"}]"#, + Some("signed-in"), + ); + write_slot( + config, + "slot-dave", + "uuid-dave", + "dave@example.com", + None, + "oauth", + r#"[{"label":"Session","usedFraction":0.1,"resetsAt":"2026-10-18T08:00:00Z"}]"#, + Some("signed-out"), + ); + // Erin's exhausted window resets in the future: still limited. + let reset = (chrono::Utc::now() + chrono::TimeDelta::hours(2)) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + write_slot( + config, + "slot-erin", + "uuid-erin", + "erin@example.com", + None, + "oauth", + &format!(r#"[{{"label":"Session","usedFraction":1.0,"resetsAt":"{reset}"}}]"#), + Some("signed-in"), + ); + write_slot( + config, + "slot-frank", + "uuid-frank", + "frank@example.com", + None, + "api-key", + r#"[]"#, + None, + ); +} + +/// Which store the engine under test gets. Every variant starts from the +/// limited live login (alice); the rest is the candidate set the test +/// needs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Store { + /// The full candidate zoo (the ranking test). + Full, + /// Two healthy candidates (carol 0.05, bob 0.2) — the failover and the + /// chain tests, whose shape depends on a known next account. + Two, + /// Nothing else (no candidate remains). + Bare, +} + +fn seed(config: &AgentAccountsConfig, store: Store) { + match store { + Store::Full => seed_store(config), + Store::Two => { + write_claude_login(config); + for (id, key, email, org, usage) in [ + ( + "slot-carol", + "uuid-carol", + "carol@example.com", + Some("Globex"), + 0.05, + ), + ("slot-bob", "uuid-bob", "bob@example.com", None, 0.2), + ] { + write_slot( + config, + id, + key, + email, + org, + "oauth", + &format!( + r#"[{{"label":"Session","usedFraction":{usage},"resetsAt":"2026-10-18T08:00:00Z"}}]"# + ), + Some("signed-in"), + ); + } + } + Store::Bare => write_claude_login(config), + } +} + +/// The engine under test: the scripted Claude harness registered under +/// `claude-code`, the seeded isolated account store wired into BOTH the +/// sessions engine (ranking, probing, switching) and the RPC surface. +async fn setup( + continuation: Continuation, + store: Store, +) -> (EngineCore, Arc<FailoverHarness>, roboco_rpc::RpcClient) { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.keep(); + let registry = HarnessRegistry::new(); + let harness = FailoverHarness::new(continuation); + registry.register(harness.clone()); + let core = EngineCore::assemble(&root, Arc::new(registry), HarnessId::ClaudeCode) + .expect("engine core assembles"); + // The mocked usage endpoint only ever sees a stray probe. + let base = usage_server().await; + let config = AgentAccountsConfig::isolated(&root); + seed(&config, store); + let accounts = AgentAccounts::with_mocked_endpoints(config, &base); + // One store, two handles: the RPC lists from it, the failover switches + // on it — replacing the detected store `assemble` wired in. + core.agent_accounts = accounts.clone(); + core.sessions.set_agent_accounts(accounts); + // The injected MCP server names the chat, so the scripted harness can + // tell the two chats' runs apart. + core.sessions.set_ipc_port(27699); + let client = roboco_rpc::memory_client(core.rpc_service()); + (core, harness, client) +} + +async fn create_chat(core: &EngineCore, chat: &str) { + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": chat, + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + // Pre-titled, so the auto-titler never dispatches a harness request of + // its own (it would land on the scripted harness as an extra run). + core.workspace + .rename_chat(chat, &format!("Pre-titled {chat}")) + .expect("rename chat"); +} + +fn run_request(prompt: &str) -> RunRequest { + RunRequest { + prompt: prompt.into(), + harness: Some(HarnessId::ClaudeCode), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: ".".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + attachments: Vec::new(), + worktree: None, + mcp: None, + resume: None, + provider_fork: None, + resume_fallback_history: None, + } +} + +/// Queue a Run command the way the composer's send does (the durable +/// command plane, ahead of any held queue). +fn queue_run(core: &EngineCore, chat: &str, prompt: &str, message_id: &str) { + core.doc_host + .queue_command( + chat, + SessionCommandPayload::Run { + request: run_request(prompt), + message_id: message_id.into(), + }, + ) + .expect("queue run command"); +} + +async fn wait_for(mut predicate: impl FnMut() -> bool, what: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// [`wait_for`] for predicates that must await the engine (an RPC round +/// trip per probe). +async fn wait_for_async<F, Fut>(mut predicate: F, what: &str) +where + F: FnMut() -> Fut, + Fut: std::future::Future<Output = bool>, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !predicate().await { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_for_status(core: &EngineCore, chat: &str, status: SessionStatus) { + wait_for( + || { + core.sessions + .session_status(chat) + .is_some_and(|s| s.status == status) + }, + "the chat to settle", + ) + .await; +} + +/// Run the chat's first turn into the usage limit and wait for the Limited +/// record (the failover's precondition). +async fn drive_into_limit(core: &EngineCore, harness: &FailoverHarness, chat: &str) { + queue_run(core, chat, "do the work", "m-open"); + wait_for( + || harness.run_count(chat) >= 1, + "the limited run to start", + ) + .await; + wait_for( + || { + core.sessions + .session_status(chat) + .is_some_and(|s| s.status == SessionStatus::Errored && s.limit.is_some()) + }, + "the chat to go Limited", + ) + .await; +} + +/// The candidates as the RPC ranks them. +async fn candidates( + client: &roboco_rpc::RpcClient, + chat: &str, +) -> ResumeAccountCandidates { + let value = client + .call( + methods::LIST_RESUME_ACCOUNT_CANDIDATES, + serde_json::json!({ "chatId": chat }), + ) + .await + .expect("ListResumeAccountCandidates"); + serde_json::from_value(value).expect("candidates reply parses") +} + +/// The failover RPC. +async fn resume_on_another_account( + client: &roboco_rpc::RpcClient, + chat: &str, + account_id: Option<&str>, +) -> ResumeOnAccountOutcome { + let mut params = serde_json::json!({ "chatId": chat }); + if let (Some(id), Some(object)) = (account_id, params.as_object_mut()) { + object.insert("accountId".into(), serde_json::json!(id)); + } + let value = client + .call(methods::RESUME_ON_ANOTHER_ACCOUNT, params) + .await + .expect("ResumeOnAnotherAccount"); + serde_json::from_value(value).expect("outcome reply parses") +} + +/// The active Claude account's row, through the accounts RPC. +async fn active_row( + client: &roboco_rpc::RpcClient, +) -> Option<roboco_proto::AgentAccount> { + let value = client + .call( + methods::LIST_AGENT_ACCOUNTS, + serde_json::json!({ "forceUsage": false }), + ) + .await + .expect("ListAgentAccounts"); + let snapshot: roboco_proto::AgentAccountsSnapshot = + serde_json::from_value(value).expect("snapshot parses"); + snapshot + .accounts + .into_iter() + .find(|row| row.harness == HarnessId::ClaudeCode && row.active) +} + +/// The candidate ranking (ticket checklist 1): the limited account, signed-out, +/// still-limited and API-key rows never surface; lowest usage first, and the +/// account sharing the limited one's org ranks below a higher-usage account +/// from another org. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn candidates_exclude_dead_rows_and_rank_by_usage_then_org() { + let (core, _harness, client) = setup(Continuation::Resumed, Store::Full).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &_harness, LIMITED).await; + + let ranked = candidates(&client, LIMITED).await; + assert_eq!(ranked.harness, HarnessId::ClaudeCode); + // The account the limit hit (the live login's slot id). + assert!(ranked.limited_account_id.is_some()); + let ids: Vec<&str> = ranked + .candidates + .iter() + .map(|row| row.id.as_str()) + .collect(); + assert_eq!( + ids, + vec!["slot-carol", "slot-bob", "slot-gina"], + "lowest usage first; a different org outranks the limited account's own pool" + ); + // The meters ride the rows: carol's Session window at 5%. + let carol = ranked.candidates.first().unwrap(); + assert_eq!(carol.usage_windows.len(), 1); + assert_eq!(carol.usage_windows[0].used_fraction, 0.05); + core.shutdown().await; +} + +/// The switch itself (ticket checklist 2): the chosen account is probed and +/// ACTIVATED (the CLI store now holds its login), the chat's warm runtime is +/// retired, and the continuation resumes the SAME harness session id with +/// the continue prompt — the budgeted history riding as the fallback. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_failover_switches_and_resumes_the_same_harness_session() { + let (core, harness, client) = setup(Continuation::Resumed, Store::Two).await; + create_chat(&core, LIMITED).await; + // A second chat with a PARKED warm runtime, stamped with the limited + // login's fingerprint: after the switch it must NOT be reused. + create_chat(&core, WARM).await; + queue_run(&core, WARM, "warm first turn", "m-warm-1"); + wait_for( + || harness.run_count(WARM) == 1, + "the warm chat's first run to start", + ) + .await; + wait_for_status(&core, WARM, SessionStatus::Idle).await; + + drive_into_limit(&core, &harness, LIMITED).await; + let outcome = resume_on_another_account(&client, LIMITED, None).await; + assert_eq!( + outcome, + ResumeOnAccountOutcome::Switched { + account_id: "slot-carol".into(), + account_email: Some("carol@example.com".into()), + } + ); + // The switch landed: the live login is carol's. + let active = active_row(&client).await.expect("an active account"); + assert_eq!(active.id, "slot-carol"); + assert_eq!(active.email.as_deref(), Some("carol@example.com")); + // The continuation resumes the chat's session with the continue prompt… + wait_for( + || harness.run_count(LIMITED) >= 2, + "the continuation to dispatch", + ) + .await; + let continuation = &harness.runs_for(LIMITED)[1]; + assert_eq!(continuation.prompt, roboco_proto::RESUME_CONTINUE_PROMPT); + assert_eq!(continuation.resume.as_deref(), Some("sess-0")); + // …and carries the chat's budgeted history as the fallback for a + // refused native resume. + let fallback = continuation.fallback.as_deref().expect("fallback history"); + assert!(fallback.contains("do the work"), "fallback: {fallback}"); + assert!(fallback.contains("<transcript>"), "fallback: {fallback}"); + // The continuation completes: the chat is back to Idle. + wait_for_status(&core, LIMITED, SessionStatus::Idle).await; + + // The warm chat's NEXT message must replace its runtime (the login + // changed under it): a fresh run, never a steer into the parked one. + queue_run(&core, WARM, "warm second turn", "m-warm-2"); + wait_for( + || harness.run_count(WARM) >= 2, + "the warm chat's replaced runtime to start", + ) + .await; + let warm_runs = harness.runs_for(WARM); + assert_eq!(warm_runs.len(), 2, "a fresh runtime, not the parked one"); + assert_eq!(warm_runs[1].prompt, "warm second turn"); + core.shutdown().await; +} + +/// A refused native resume (ticket checklist 3): the harness starts a FRESH +/// session — its reported session id differs — and the budgeted history is +/// delivered in front of the continue prompt on that fresh session. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_refused_native_resume_falls_back_to_a_fresh_session_with_budgeted_history() { + let (core, harness, client) = setup(Continuation::Refused, Store::Two).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &harness, LIMITED).await; + + let outcome = resume_on_another_account(&client, LIMITED, None).await; + assert!(matches!(outcome, ResumeOnAccountOutcome::Switched { .. })); + wait_for( + || harness.run_count(LIMITED) >= 2, + "the continuation to dispatch", + ) + .await; + let continuation = &harness.runs_for(LIMITED)[1]; + // The engine asked for the native resume… + assert_eq!(continuation.resume.as_deref(), Some("sess-0")); + assert!(continuation.fallback.is_some()); + // …the harness refused it (the driver's part): a fresh session id, the + // budgeted history pasted in front of the prompt. + assert_ne!(continuation.session_id, "sess-0"); + assert_eq!(continuation.session_id, "fresh-9"); + assert!(continuation.delivered_prompt.contains("do the work")); + assert!(continuation.delivered_prompt.contains("<transcript>")); + assert!(continuation + .delivered_prompt + .ends_with(roboco_proto::RESUME_CONTINUE_PROMPT)); + wait_for_status(&core, LIMITED, SessionStatus::Idle).await; + core.shutdown().await; +} + +/// A signed-out failure after the switch (ticket checklist 4): the account +/// is marked signed out (the run's own wiring) and the failover moves to +/// the NEXT candidate, whose continuation succeeds. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_signed_out_failure_marks_the_account_and_moves_to_the_next_candidate() { + let (core, harness, client) = setup(Continuation::SignedOut, Store::Two).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &harness, LIMITED).await; + + let outcome = resume_on_another_account(&client, LIMITED, None).await; + assert!(matches!( + outcome, + ResumeOnAccountOutcome::Switched { + account_id, .. + } if account_id == "slot-carol" + )); + // The carol continuation fails signed out; the chain retries on bob. + wait_for( + || harness.run_count(LIMITED) >= 3, + "the next candidate's continuation to dispatch", + ) + .await; + let runs = harness.runs_for(LIMITED); + assert_eq!(runs.len(), 3); + // Both continuations resume the same harness session. + assert_eq!(runs[1].resume.as_deref(), Some("sess-0")); + assert_eq!(runs[2].resume.as_deref(), Some("sess-0")); + assert_eq!(runs[2].prompt, roboco_proto::RESUME_CONTINUE_PROMPT); + // The chat settles Idle on bob's continuation. + wait_for_status(&core, LIMITED, SessionStatus::Idle).await; + // Carol is marked signed out (never offered again)… + let snapshot = client + .call( + methods::LIST_AGENT_ACCOUNTS, + serde_json::json!({ "forceUsage": false }), + ) + .await + .expect("ListAgentAccounts"); + let carol = snapshot["accounts"] + .as_array() + .expect("accounts") + .iter() + .find(|row| row["id"] == "slot-carol") + .expect("carol's row"); + assert_eq!(carol["signInState"], "signed-out"); + // …and the live login is bob's. + let active = active_row(&client).await.expect("an active account"); + assert_eq!(active.id, "slot-bob"); + core.shutdown().await; +} + +/// Any other failure (ticket checklist 4's other arm): the previously +/// active account is restored — a failed failover leaves the harness as it +/// found it — and no second candidate is tried. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_plain_failure_restores_the_previous_account() { + let (core, harness, client) = setup(Continuation::PlainFailure, Store::Two).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &harness, LIMITED).await; + + let outcome = resume_on_another_account(&client, LIMITED, None).await; + assert!(matches!(outcome, ResumeOnAccountOutcome::Switched { .. })); + wait_for( + || harness.run_count(LIMITED) >= 2, + "the continuation to dispatch", + ) + .await; + wait_for_status(&core, LIMITED, SessionStatus::Errored).await; + // The restore is a real account switch: the live login is alice's again. + wait_for_async( + || async { + active_row(&client) + .await + .is_some_and(|row| row.id != "slot-carol") + }, + "the previous account to be restored", + ) + .await; + let active = active_row(&client).await.expect("an active account"); + assert_eq!(active.email.as_deref(), Some("alice@example.com")); + // No second candidate: the chain ends with the restore. The settle + // beat lets a stray third run fail the count assertion. + tokio::time::sleep(Duration::from_millis(750)).await; + assert_eq!(harness.run_count(LIMITED), 2, "no third run on the chat"); + core.shutdown().await; +} + +/// No healthy candidate (ticket checklist 5): the reply is `noCandidates` — +/// the surface offers only Resume at reset — and nothing switched. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn with_no_healthy_candidate_only_resume_at_reset_remains() { + let (core, harness, client) = setup(Continuation::Resumed, Store::Bare).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &harness, LIMITED).await; + + let ranked = candidates(&client, LIMITED).await; + assert!(ranked.candidates.is_empty(), "no candidates offered"); + let outcome = resume_on_another_account(&client, LIMITED, None).await; + assert_eq!(outcome, ResumeOnAccountOutcome::NoCandidates); + // Nothing switched, nothing dispatched: the live login is still the + // limited account's, and the chat stays Limited. + let active = active_row(&client).await.expect("an active account"); + assert_eq!(active.email.as_deref(), Some("alice@example.com")); + assert_eq!(harness.run_count(LIMITED), 1); + assert!(core + .sessions + .session_status(LIMITED) + .is_some_and(|s| s.limit.is_some())); + core.shutdown().await; +} + +/// The user's explicit pick is honored when it is a candidate, and refused +/// when it is not (the limited account itself). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_explicit_pick_switches_to_it_and_a_non_candidate_is_refused() { + let (core, harness, client) = setup(Continuation::Resumed, Store::Two).await; + create_chat(&core, LIMITED).await; + drive_into_limit(&core, &harness, LIMITED).await; + + let outcome = resume_on_another_account(&client, LIMITED, Some("slot-bob")).await; + assert_eq!( + outcome, + ResumeOnAccountOutcome::Switched { + account_id: "slot-bob".into(), + account_email: Some("bob@example.com".into()), + } + ); + wait_for( + || harness.run_count(LIMITED) >= 2, + "bob's continuation to dispatch", + ) + .await; + wait_for_status(&core, LIMITED, SessionStatus::Idle).await; + core.shutdown().await; +} diff --git a/crates/engine/tests/account_sign_in_state.rs b/crates/engine/tests/account_sign_in_state.rs index 46e1f01759..7bfec2035a 100644 --- a/crates/engine/tests/account_sign_in_state.rs +++ b/crates/engine/tests/account_sign_in_state.rs @@ -164,6 +164,7 @@ fn run_request(prompt: &str) -> RunRequest { attachments: vec![], worktree: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/acp_lifecycle.rs b/crates/engine/tests/acp_lifecycle.rs index 60f6d7e90d..3fc68a3ba5 100644 --- a/crates/engine/tests/acp_lifecycle.rs +++ b/crates/engine/tests/acp_lifecycle.rs @@ -70,6 +70,7 @@ async fn quiet_acp_prompt_stays_working_until_response() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }, }, ); diff --git a/crates/engine/tests/adapter_chaos_live.rs b/crates/engine/tests/adapter_chaos_live.rs index 39dc5b49aa..41560d5351 100644 --- a/crates/engine/tests/adapter_chaos_live.rs +++ b/crates/engine/tests/adapter_chaos_live.rs @@ -152,6 +152,7 @@ impl Suite { worktree: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } @@ -1217,6 +1218,7 @@ fn restart_request(name: &str, cwd: &str, prompt: &str) -> RunRequest { worktree: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/agent_origin.rs b/crates/engine/tests/agent_origin.rs index 5159f31226..2e7160aabb 100644 --- a/crates/engine/tests/agent_origin.rs +++ b/crates/engine/tests/agent_origin.rs @@ -127,6 +127,7 @@ fn run_request(prompt: &str) -> RunRequest { auto_approve: false, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, } diff --git a/crates/engine/tests/codex_subagents.rs b/crates/engine/tests/codex_subagents.rs index f4c72178f1..8d679cac83 100644 --- a/crates/engine/tests/codex_subagents.rs +++ b/crates/engine/tests/codex_subagents.rs @@ -66,6 +66,7 @@ async fn check_persistence( mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; core.sessions .dispatch( diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index bd9778cf70..6b50ddf5b9 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -259,6 +259,8 @@ fn run_request(prompt: &str) -> RunRequest { auto_approve: false, resume: None, provider_fork: None, + resume_fallback_history: None, + resume_fallback_history: None, attachments: vec![], worktree: None, } diff --git a/crates/engine/tests/cursor_live.rs b/crates/engine/tests/cursor_live.rs index 2cf381fcae..fd7027af59 100644 --- a/crates/engine/tests/cursor_live.rs +++ b/crates/engine/tests/cursor_live.rs @@ -85,6 +85,7 @@ fn start(core: &EngineCore, cwd: &std::path::Path, prompt: String) { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }, }, ) diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index 6ee126ccd7..ad5ded8383 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -40,6 +40,7 @@ fn run_request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } @@ -2451,6 +2452,7 @@ async fn real_claude_sees_uploaded_image_inline() { attachments: vec![path], resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }; diff --git a/crates/engine/tests/limited_chats.rs b/crates/engine/tests/limited_chats.rs index 88b4408836..d391182c5e 100644 --- a/crates/engine/tests/limited_chats.rs +++ b/crates/engine/tests/limited_chats.rs @@ -171,6 +171,7 @@ fn run_request(prompt: &str, harness: HarnessId) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/m5c_accounts_uploads_titles.rs b/crates/engine/tests/m5c_accounts_uploads_titles.rs index 60efcf9c9a..581c09a6b6 100644 --- a/crates/engine/tests/m5c_accounts_uploads_titles.rs +++ b/crates/engine/tests/m5c_accounts_uploads_titles.rs @@ -968,6 +968,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; core.sessions .dispatch(chat_id, HarnessId::Mock, request, None, None) @@ -1015,6 +1016,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; core.sessions .dispatch(chat_id, HarnessId::Mock, request, None, None) diff --git a/crates/engine/tests/pi_resume.rs b/crates/engine/tests/pi_resume.rs index 594085763a..5d906c253f 100644 --- a/crates/engine/tests/pi_resume.rs +++ b/crates/engine/tests/pi_resume.rs @@ -37,6 +37,7 @@ async fn pi_idle_crash_next_dispatch_resumes_the_stored_session() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; core.sessions .dispatch(chat, HarnessId::Pi, req, None, None) diff --git a/crates/engine/tests/projectless.rs b/crates/engine/tests/projectless.rs index 40e906a345..1cf4a547e9 100644 --- a/crates/engine/tests/projectless.rs +++ b/crates/engine/tests/projectless.rs @@ -294,6 +294,7 @@ async fn exercise_projectless(command_first: bool) { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }, message_id: message_id.into(), }, diff --git a/crates/engine/tests/queued_attachments.rs b/crates/engine/tests/queued_attachments.rs index ae97f17de4..f36a6774af 100644 --- a/crates/engine/tests/queued_attachments.rs +++ b/crates/engine/tests/queued_attachments.rs @@ -133,6 +133,7 @@ fn run_payload(message_id: &str, pending_ref: &str) -> SessionCommandPayload { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }, message_id: message_id.into(), } diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index 919977fa96..30e697d448 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -50,6 +50,7 @@ fn run_request(prompt: &str, cwd: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } @@ -866,6 +867,7 @@ async fn real_claude_remembers_codeword_across_engine_restart() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let assemble_real = || { EngineCore::assemble( diff --git a/crates/engine/tests/rich_composer_delivery.rs b/crates/engine/tests/rich_composer_delivery.rs index 6d5b0efe58..2298718b74 100644 --- a/crates/engine/tests/rich_composer_delivery.rs +++ b/crates/engine/tests/rich_composer_delivery.rs @@ -160,6 +160,7 @@ fn request(prompt: &str) -> RunRequest { auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, mcp: None, diff --git a/crates/engine/tests/runtime_survival.rs b/crates/engine/tests/runtime_survival.rs index 6a2011b3c2..c1d823b57f 100644 --- a/crates/engine/tests/runtime_survival.rs +++ b/crates/engine/tests/runtime_survival.rs @@ -245,6 +245,7 @@ fn request(prompt: &str) -> RunRequest { worktree: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/runtime_survival_live.rs b/crates/engine/tests/runtime_survival_live.rs index 00ebc1ce19..dcc69630b1 100644 --- a/crates/engine/tests/runtime_survival_live.rs +++ b/crates/engine/tests/runtime_survival_live.rs @@ -112,6 +112,7 @@ fn run( worktree: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/self_continued_quiesce.rs b/crates/engine/tests/self_continued_quiesce.rs index 5e4f96b4e9..d57c9e444e 100644 --- a/crates/engine/tests/self_continued_quiesce.rs +++ b/crates/engine/tests/self_continued_quiesce.rs @@ -61,6 +61,7 @@ fn run_request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 408be18e4c..a1f7f41e0a 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -219,6 +219,7 @@ async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_sessi auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, }, @@ -389,6 +390,7 @@ async fn assistant_entries_record_the_provider_turn_ref() { auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, }, @@ -1318,6 +1320,7 @@ async fn side_turn( auto_approve: true, resume, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, }, @@ -1529,6 +1532,7 @@ async fn warm_side_chat_sends_owed_fork_history_once() { auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, }; @@ -1715,6 +1719,7 @@ async fn orphaned_history_steer_still_owes_the_history() { auto_approve: true, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, }; diff --git a/crates/engine/tests/subagent_idle_reap.rs b/crates/engine/tests/subagent_idle_reap.rs index b9b11528a7..5b4e06b408 100644 --- a/crates/engine/tests/subagent_idle_reap.rs +++ b/crates/engine/tests/subagent_idle_reap.rs @@ -53,6 +53,7 @@ fn run_request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/turn_quiesce.rs b/crates/engine/tests/turn_quiesce.rs index 25e6d5ee0b..881a2b3621 100644 --- a/crates/engine/tests/turn_quiesce.rs +++ b/crates/engine/tests/turn_quiesce.rs @@ -62,6 +62,7 @@ fn run_request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/engine/tests/unarchive_on_send.rs b/crates/engine/tests/unarchive_on_send.rs index 91c4755743..007cfcb8e1 100644 --- a/crates/engine/tests/unarchive_on_send.rs +++ b/crates/engine/tests/unarchive_on_send.rs @@ -113,6 +113,7 @@ fn run_payload(message_id: &str) -> SessionCommandPayload { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }, message_id: message_id.into(), } diff --git a/crates/engine/tests/worktree_on_run.rs b/crates/engine/tests/worktree_on_run.rs index c5b8482366..8ca1962f1b 100644 --- a/crates/engine/tests/worktree_on_run.rs +++ b/crates/engine/tests/worktree_on_run.rs @@ -122,6 +122,7 @@ fn run_payload(message_id: &str, repo_path: &str, space_id: Option<&str>) -> Ses attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: Some(WorktreeSpec { repo_path: repo_path.into(), base: "main".into(), diff --git a/crates/harness/examples/cursor_stability_probe.rs b/crates/harness/examples/cursor_stability_probe.rs index dd14aa8b90..4b8a0dbfb3 100644 --- a/crates/harness/examples/cursor_stability_probe.rs +++ b/crates/harness/examples/cursor_stability_probe.rs @@ -46,6 +46,7 @@ async fn turn( mcp: None, resume: session.clone(), provider_fork: None, + resume_fallback_history: None, }; let mut stream = harness .run(request, controls) @@ -168,6 +169,7 @@ async fn parked(harness: &CursorHarness, count: usize) { worktree: None, mcp: None, provider_fork: None, + resume_fallback_history: None, resume: None, }; let mut stream = harness.run(request, controls).await.unwrap(); @@ -267,6 +269,7 @@ async fn history(harness: &CursorHarness, count: usize) { worktree: None, mcp: None, provider_fork: None, + resume_fallback_history: None, resume: None, }; let mut stream = harness.run(request, controls).await.unwrap(); @@ -389,6 +392,7 @@ async fn burst(harness: &CursorHarness, count: usize, cancel: bool) { worktree: None, mcp: None, provider_fork: None, + resume_fallback_history: None, resume: seed, }; let mut stream = harness.run(request, controls).await.unwrap(); diff --git a/crates/harness/examples/cursor_steering_probe.rs b/crates/harness/examples/cursor_steering_probe.rs index 724bffec12..f4a1caa095 100644 --- a/crates/harness/examples/cursor_steering_probe.rs +++ b/crates/harness/examples/cursor_steering_probe.rs @@ -20,6 +20,7 @@ async fn main() -> anyhow::Result<()> { model_options: Default::default(), cwd: workspace.path().to_string_lossy().into_owned(), sandbox: SandboxLevel::DangerFullAccess, auto_approve: true, provider_fork: None, + resume_fallback_history: None, attachments: vec![], worktree: None, resume: None, mcp: None, }; let mut stream = CursorHarness::new() diff --git a/crates/harness/examples/devin_models_probe.rs b/crates/harness/examples/devin_models_probe.rs index a6b3720ac0..fc72b14697 100644 --- a/crates/harness/examples/devin_models_probe.rs +++ b/crates/harness/examples/devin_models_probe.rs @@ -51,6 +51,7 @@ async fn main() -> anyhow::Result<()> { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; println!("Running real Devin ACP with {model}"); let mut stream = harness.run(request, controls).await?; diff --git a/crates/harness/examples/grok_subagent_probe.rs b/crates/harness/examples/grok_subagent_probe.rs index 8f862926ac..f36f74377f 100644 --- a/crates/harness/examples/grok_subagent_probe.rs +++ b/crates/harness/examples/grok_subagent_probe.rs @@ -52,6 +52,7 @@ async fn main() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let mut stream = AcpHarness::grok() .run(request, controls) diff --git a/crates/harness/examples/opencode_subagent_probe.rs b/crates/harness/examples/opencode_subagent_probe.rs index 4965b9a263..70d8c90252 100644 --- a/crates/harness/examples/opencode_subagent_probe.rs +++ b/crates/harness/examples/opencode_subagent_probe.rs @@ -62,6 +62,7 @@ async fn main() { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }; diff --git a/crates/harness/examples/opencode_turn_probe.rs b/crates/harness/examples/opencode_turn_probe.rs index ab370c56fc..93b3f699cc 100644 --- a/crates/harness/examples/opencode_turn_probe.rs +++ b/crates/harness/examples/opencode_turn_probe.rs @@ -34,6 +34,7 @@ async fn main() { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, }; diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index f260f9d13f..c5ae83b6de 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -103,6 +103,34 @@ fn mcp_config_arg(mcp: &roboco_proto::McpServer) -> String { .to_string() } +/// Whether the CLI's own session store knows `session_id` — one +/// `<config>/projects/*/{session_id}.jsonl` file (the same walk +/// `Normalizer::for_resume` restores subagent chips from). A resume the +/// CLI would refuse is what triggers the account-failover fallback +/// (ticket 09): the driver starts a fresh session with the engine's +/// budgeted history instead of passing an id `--resume` will reject. +async fn session_known(config_root: &std::path::Path, session_id: &str) -> bool { + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return false; + } + let config_root = config_root.to_owned(); + let session_id = session_id.to_owned(); + tokio::task::spawn_blocking(move || { + let Ok(projects) = std::fs::read_dir(config_root.join("projects")) else { + return false; + }; + projects + .flatten() + .any(|project| project.path().join(format!("{session_id}.jsonl")).exists()) + }) + .await + .unwrap_or(false) +} + /// The `--model` value: the 1M context window is selected via a model-id /// suffix (`sonnet[1m]`), exactly how the CLI itself does it. fn model_arg(request: &RunRequest) -> Option<String> { @@ -578,11 +606,33 @@ impl Harness for ClaudeHarness { impl ClaudeHarness { async fn run_with_mode( &self, - request: RunRequest, + mut request: RunRequest, controls: RunControls, title_only: bool, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { let exe = self.resolve_executable()?; + let config_root = crate::model_context::root( + "CLAUDE_CONFIG_DIR", + crate::executable::home_or_current_dir().join(".claude"), + ); + // A resume the CLI will REFUSE — its session store has no such id — + // starts a fresh session instead when the engine attached a + // fallback history (the account-failover continuation, ticket 09): + // the budgeted transcript rides in front of the prompt, so the + // fresh session still knows the conversation. Plain resumes carry + // no fallback and keep today's behavior: pass `--resume` and let + // the CLI report the bad id itself. + if let (Some(resume), Some(history)) = + (request.resume.clone(), request.resume_fallback_history.clone()) + && !session_known(&config_root, &resume).await + { + tracing::debug!( + target: "roboco_harness::claude", + "resume of {resume} has no session file; starting fresh with the budgeted history" + ); + request.resume = None; + request.prompt = format!("{history}{}", request.prompt); + } let mut cmd = self.build_command(&exe, &request); if title_only { cmd.args([ @@ -603,11 +653,7 @@ impl ClaudeHarness { cmd.args(["--mcp-config", &mcp_config_arg(mcp)]); } let normalizer = if let Some(session_id) = &request.resume { - let config = crate::model_context::root( - "CLAUDE_CONFIG_DIR", - crate::executable::home_or_current_dir().join(".claude"), - ); - Normalizer::for_resume(&config, session_id).await + Normalizer::for_resume(&config_root, session_id).await } else { Normalizer::new() }; diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index 382808725f..dc4985e6fe 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -1119,6 +1119,10 @@ async fn run_session(session: Session) { // The budgeted transcript the engine attached for a refused fork; the // interrupted-start path leaves it unset (nothing was attempted). let mut fork_fallback: Option<String> = None; + // The same for a REFUSED RESUME (account failover, ticket 09): the + // engine attaches the chat's budgeted history to the continuation so a + // fresh thread still knows the conversation. + let mut resume_fallback: Option<String> = None; let setup = async { client .request( @@ -1192,6 +1196,10 @@ async fn run_session(session: Session) { target: "roboco_harness::codex", "thread/resume failed (starting fresh): {e}" ); + // A refused resume with the engine's budgeted fallback + // history (ticket 09's account-failover continuation): + // the fresh thread still owes the chat its context. + resume_fallback = request.resume_fallback_history.clone(); client .request("thread/start", Value::Object(start_params.clone())) .await? @@ -1241,10 +1249,11 @@ async fn run_session(session: Session) { // the budgeted transcript rides in front of the prompt, exactly as the // engine would have pasted it for a fresh session. The prompt itself // still governs native-command routing below (a native command never - // carries a fallback — the engine attaches none). - let delivered_prompt = match &fork_fallback { - Some(history) => format!("{history}{}", request.prompt), - None => request.prompt.clone(), + // carries a fallback — the engine attaches none). A refused RESUME + // delivers the same way (ticket 09). + let delivered_prompt = match (&fork_fallback, &resume_fallback) { + (Some(history), _) | (_, Some(history)) => format!("{history}{}", request.prompt), + (None, None) => request.prompt.clone(), }; // Model, effort and service tier ride every `turn/start` (each sticks // for later turns): a steer's in-place `config` takes effect from the diff --git a/crates/harness/tests/acp.rs b/crates/harness/tests/acp.rs index 966035a886..5e99de9a3d 100644 --- a/crates/harness/tests/acp.rs +++ b/crates/harness/tests/acp.rs @@ -49,6 +49,7 @@ fn request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/harness/tests/acp_quiet.rs b/crates/harness/tests/acp_quiet.rs index 7ff0bfd5d0..683a84993e 100644 --- a/crates/harness/tests/acp_quiet.rs +++ b/crates/harness/tests/acp_quiet.rs @@ -54,6 +54,7 @@ fn request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/harness/tests/acp_stall.rs b/crates/harness/tests/acp_stall.rs index 42650eb231..14cc63b284 100644 --- a/crates/harness/tests/acp_stall.rs +++ b/crates/harness/tests/acp_stall.rs @@ -61,6 +61,7 @@ async fn silent_agent_errors_via_the_prompt_stall_watchdog() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let harness = AcpHarness::grok().with_executable(fixture_path()); let stream = harness.run(request, controls).await.expect("run starts"); diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index be2575f504..ba62f12fff 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -50,6 +50,7 @@ fn request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } @@ -94,6 +95,39 @@ async fn run_to_end( .expect("run finished in time") } +/// A resume whose session file the CLI store does not know, carrying the +/// engine's budgeted fallback (ticket 09's account-failover continuation): +/// the driver starts FRESH — no `--resume` reaches the CLI (the fake fails +/// the run if it does) — with the history in front of the prompt. +#[tokio::test] +async fn a_resume_without_a_session_file_starts_fresh_with_the_history() { + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:resume-missing"); + // An id no real Claude session store knows (the check only READS the + // store — no login, no write). + req.resume = Some("roboco-missing-3f9c".into()); + req.resume_fallback_history = Some("HISTORY PINEAPPLE ".into()); + let events = run_to_end(&harness(), req, controls).await; + + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } + if text.contains("HISTORY PINEAPPLE") && text.contains("scenario:resume-missing") + )), + "the budgeted history rode in front of the prompt: {events:?}" + ); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-resume-missing".into()), + }) + ); +} + #[tokio::test] async fn happy_path_normalizes_events_and_tags_subagents() { let (controls, _steer, _token) = controls("A"); diff --git a/crates/harness/tests/codex.rs b/crates/harness/tests/codex.rs index 37be78e271..520bd1865a 100644 --- a/crates/harness/tests/codex.rs +++ b/crates/harness/tests/codex.rs @@ -105,6 +105,7 @@ fn request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } @@ -866,6 +867,44 @@ async fn resume_falls_back_to_fresh_thread() { ); } +/// A refused resume with the engine's budgeted fallback (ticket 09's +/// account-failover continuation): the fresh thread's first turn carries +/// the history in front of the user message — the fake fails the turn +/// unless both reach the input. +#[tokio::test] +async fn a_refused_resume_falls_back_with_the_budgeted_history() { + let (controls, _steer, _token) = controls("Yes"); + let mut req = request("scenario:resume-fallback"); + req.resume = Some("resume-fail".into()); + req.resume_fallback_history = Some( + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" + .into(), + ); + let events = run_to_end(&harness(), req, controls).await; + + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "th-fresh" + )), + "refused resume fell back to a fresh thread: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "resumed with history" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("th-fresh".into()), + }) + ); +} + #[tokio::test] async fn resume_reuses_the_existing_thread() { let (controls, _steer, _token) = controls("Yes"); diff --git a/crates/harness/tests/cursor.rs b/crates/harness/tests/cursor.rs index 230e33f388..34063875e8 100644 --- a/crates/harness/tests/cursor.rs +++ b/crates/harness/tests/cursor.rs @@ -44,6 +44,7 @@ fn request(prompt: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 114cbf12e3..7f256c0fe4 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -35,6 +35,21 @@ case "$first" in emit '{"type":"result","subtype":"success","result":"echoed","usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-command-echo"}' ;; +*scenario:resume-missing*) + # The account-failover fallback (ticket 09): the session file is unknown + # to the CLI store, so the driver must start FRESH -- no --resume flag -- + # with the budgeted history in front of the prompt. Seeing --resume here + # fails the run. + for arg in "$@"; do + case "$arg" in + --resume=*) echo "refused resume still passed --resume" >&2; exit 1 ;; + esac + done + content=$(printf '%s\n' "$first" | sed 's/.*"content":"\([^"]*\)".*/\1/') + emit "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"$content\"}}}" + emit '{"type":"result","subtype":"success","result":"echoed","usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-resume-missing"}' + ;; + *scenario:title*) tools_off=false system_set=false diff --git a/crates/harness/tests/fixtures/fake-codex.sh b/crates/harness/tests/fixtures/fake-codex.sh index dc9da92511..9d977240f7 100755 --- a/crates/harness/tests/fixtures/fake-codex.sh +++ b/crates/harness/tests/fixtures/fake-codex.sh @@ -404,6 +404,20 @@ case "$turnline" in emit '{"method":"turn/completed","params":{"threadId":"th-fresh","turn":{"id":"t-f2","status":"completed"}}}' ;; +*scenario:resume-fallback*) + # A REFUSED RESUME (ticket 09's account-failover continuation) fell back + # to a fresh thread: the input must carry the engine's budgeted history + # in front of the user message, exactly like the refused fork. + has "$turnline" '"threadId":"th-fresh"' || { fail_turn "$tid" "turn not on the fresh thread"; exit 0; } + for want in 'PINEAPPLE' 'scenario:resume-fallback'; do + has "$turnline" "$want" || { fail_turn "$tid" "fallback prompt missing: $want"; exit 0; } + done + emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-rf\"}}}" + emit '{"method":"turn/started","params":{"threadId":"th-fresh","turn":{"id":"t-rf"}}}' + emit '{"method":"item/agentMessage/delta","params":{"threadId":"th-fresh","itemId":"m1","delta":"resumed with history"}}' + emit '{"method":"turn/completed","params":{"threadId":"th-fresh","turn":{"id":"t-rf","status":"completed"}}}' + ;; + # NOTE: steer-race before steer — `case` takes the first matching glob. *scenario:steer-race*) emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" diff --git a/crates/harness/tests/managed_install.rs b/crates/harness/tests/managed_install.rs index 9aba54d65b..cc7a4db0ae 100644 --- a/crates/harness/tests/managed_install.rs +++ b/crates/harness/tests/managed_install.rs @@ -49,6 +49,7 @@ async fn managed_install_reaches_session_started() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let mut stream = tokio::time::timeout( diff --git a/crates/harness/tests/managed_install_failure.rs b/crates/harness/tests/managed_install_failure.rs index 91ca3d42a2..713aa9e994 100644 --- a/crates/harness/tests/managed_install_failure.rs +++ b/crates/harness/tests/managed_install_failure.rs @@ -61,6 +61,7 @@ async fn silent_npm_enoent_death_surfaces_decoded_error() { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let err = match harness.run(request, controls).await { diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index cc3e956bfe..fdf26cc226 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -271,6 +271,7 @@ fn request(prompt: &str) -> RunRequest { attachments: Vec::new(), resume: None, provider_fork: None, + resume_fallback_history: None, worktree: None, mcp: None, } diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 16f98148f0..13035ca6e2 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -56,6 +56,7 @@ fn request_cwd(prompt: &str, cwd: &str) -> RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/harness/tests/real_acp_lifecycle.rs b/crates/harness/tests/real_acp_lifecycle.rs index 1f590c2cad..935ef87328 100644 --- a/crates/harness/tests/real_acp_lifecycle.rs +++ b/crates/harness/tests/real_acp_lifecycle.rs @@ -34,6 +34,7 @@ async fn live_run(cancel: bool) { model_options: serde_json::Map::new(), cwd: cwd.path().display().to_string(), sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, provider_fork: None, + resume_fallback_history: None, attachments: Vec::new(), worktree: None, resume: None, mcp: None, }; let mut stream = PiHarness::new() diff --git a/crates/harness/tests/real_quiet_survey.rs b/crates/harness/tests/real_quiet_survey.rs index a2734ca72a..20e8fa6b2d 100644 --- a/crates/harness/tests/real_quiet_survey.rs +++ b/crates/harness/tests/real_quiet_survey.rs @@ -69,6 +69,7 @@ async fn probe_once(harness: AcpHarness) -> ProbeOutcome { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, }; let mut stream = match harness.run(req, controls).await { Ok(s) => s, diff --git a/crates/harness/tests/windows_native.rs b/crates/harness/tests/windows_native.rs index f1ae70aa36..ea8a6c36bf 100644 --- a/crates/harness/tests/windows_native.rs +++ b/crates/harness/tests/windows_native.rs @@ -216,6 +216,7 @@ fn request(cwd: &Path, prompt: &str, resume: Option<&str>) -> RunRequest { mcp: None, resume: resume.map(str::to_owned), provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/mobile-client/src/session/commands.rs b/crates/mobile-client/src/session/commands.rs index 21cb2f1db0..c4fb1c4036 100644 --- a/crates/mobile-client/src/session/commands.rs +++ b/crates/mobile-client/src/session/commands.rs @@ -221,6 +221,7 @@ impl SessionCore { auto_approve: false, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: paths.to_vec(), worktree: request.worktree.clone(), mcp: None, diff --git a/crates/mobile-client/tests/notify_engine.rs b/crates/mobile-client/tests/notify_engine.rs index b3092143d4..153e048d11 100644 --- a/crates/mobile-client/tests/notify_engine.rs +++ b/crates/mobile-client/tests/notify_engine.rs @@ -329,6 +329,7 @@ fn run_request(prompt: &str) -> roboco_proto::RunRequest { mcp: None, resume: None, provider_fork: None, + resume_fallback_history: None, } } diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index 76e76c886b..e1ea603350 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -238,6 +238,16 @@ pub struct RunRequest { pub auto_approve: bool, /// Harness-native session id to resume, if any. pub resume: Option<String>, + /// The budgeted transcript (ticket 01's builder) delivered in front of + /// the prompt ONLY when the harness refuses the native resume of + /// `resume` and starts a fresh session instead (account failover, + /// orchestration v2 ticket 09: cross-account resumes usually keep working, + /// but a session the harness will not continue must still carry the chat's + /// prior context). Only the engine sets it — a plain resume carries none, + /// and a resumed session that never refuses never sees it. Additive + + /// serde-defaulted for wire compat. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume_fallback_history: Option<String>, /// A native fork to perform instead of a plain resume or fresh start /// (t3-orchestration-v2 ticket 17): the driver forks the source provider /// session at `last_turn` (inclusive) — Codex `thread/fork {threadId, @@ -1058,6 +1068,30 @@ mod tests { assert_eq!(fork.fallback_history, None); } + #[test] + fn run_request_resume_fallback_history_default_and_round_trip() { + // Old-wire JSON without the field parses (additive compat)… + let old = r#"{"prompt":"p","model":null,"reasoning":null,"cwd":".","sandbox":"workspace-write","resume":null}"#; + let req: RunRequest = serde_json::from_str(old).unwrap(); + assert!(req.resume_fallback_history.is_none()); + // …and `None` serializes away (old readers never see it). + let json = serde_json::to_value(&req).unwrap(); + assert!(json.get("resumeFallbackHistory").is_none()); + // The engine attaches the budgeted transcript only to a failover's + // continuation: it rides beside `resume`, camelCased. + let req = RunRequest { + resume: Some("sess-42".into()), + resume_fallback_history: Some("<transcript>…".into()), + ..req + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["resume"], "sess-42"); + assert_eq!(json["resumeFallbackHistory"], "<transcript>…"); + let round: RunRequest = serde_json::from_value(json).unwrap(); + assert_eq!(round.resume, req.resume); + assert_eq!(round.resume_fallback_history, req.resume_fallback_history); + } + #[test] fn turn_ref_event_round_trips_and_must_be_droppable_by_old_consumers() { let event = AgentEvent::TurnRef { diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index 4b4570db9c..2f93609035 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -1073,6 +1073,58 @@ pub struct AgentAccountsSnapshot { pub warnings: Vec<AgentAccountWarning>, } +/// The ranked switch targets for one Limited chat's "Resume on another +/// account" (orchestration v2, ticket 09): the cached harness accounts of +/// the chat's harness, filtered and ranked by the engine's failover rules — +/// switchable, not an API key, not signed out, not still limited, and not +/// the account that hit the limit; lowest usage first, accounts sharing the +/// limited account's org or workspace ranked lower. The first entry is the +/// recommended one. Signed-out accounts are absent (they offer "Sign in +/// again" in the account switcher instead); an empty list leaves only +/// Resume at reset. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct ResumeAccountCandidates { + pub chat_id: String, + pub harness: HarnessId, + /// The account the chat's limit hit (the record's account id, else the + /// harness's active login) — named so the picker can say what it is + /// switching FROM. Never a candidate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limited_account_id: Option<String>, + /// The candidate rows in rank order, best first. Usage windows and + /// limited-until ride the rows themselves. + pub candidates: Vec<AgentAccount>, +} + +/// The synchronous outcome of "Resume on another account" (ticket 09): the +/// account switch, warm-runtime retirement and continuation dispatch the +/// engine performed — or why it could not. The continuation turn's own +/// result arrives through the chat's status; a signed-out failure after the +/// switch is handled by the engine (next candidate, else the previous +/// account restored) before the chat settles. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ResumeOnAccountOutcome { + /// The switch landed and the continuation was dispatched: the chat + /// resumes its harness session under `account_id`. + Switched { + account_id: String, + /// The row's email (or display name) — the "Resumed on …" label. + #[serde(default, skip_serializing_if = "Option::is_none")] + account_email: Option<String>, + }, + /// No healthy candidate: every other account of the harness is limited, + /// signed out, an API key, or unswitchable. Only Resume at reset remains. + NoCandidates, + /// The switch itself failed (the chosen account vanished, the probe + /// failed hard, the activation was refused); the previous account is + /// untouched. + Failed { + reason: String, + }, +} + /// `InstallHarness` / `CancelInstall` request — the explicit, user-requested /// install (or its cancellation) for one harness on the engine this call /// reaches. Catalog probes never install; only this request does. @@ -1724,6 +1776,90 @@ mod tests { ); } + /// The failover candidates and the switch outcome (ticket 09) ride the + /// wire additively: camelCase field names, a tagged outcome, `None` + /// optionals serializing away, and legacy payloads (no limited account) + /// parsing. + #[test] + fn resume_account_candidates_and_outcome_round_trip() { + let account = AgentAccount { + id: "slot-2".into(), + harness: HarnessId::Codex, + email: Some("b@example.com".into()), + plan_label: Some("Plus".into()), + active: false, + usage_windows: vec![AgentUsageWindow { + label: "Week".into(), + used_fraction: 0.2, + resets_at: None, + }], + usage_fetched_at: None, + usage_error: None, + sign_in_state: AgentSignInState::SignedIn, + limited_until: None, + display_name: None, + organization: None, + auth_kind: Some(AgentAuthKind::Oauth), + switchable: true, + saved_at: None, + provider: None, + }; + let candidates = ResumeAccountCandidates { + chat_id: "chat-9".into(), + harness: HarnessId::Codex, + limited_account_id: Some("slot-1".into()), + candidates: vec![account.clone()], + }; + let value = serde_json::to_value(&candidates).unwrap(); + assert_eq!(value["chatId"], "chat-9"); + assert_eq!(value["harness"], "codex"); + assert_eq!(value["limitedAccountId"], "slot-1"); + assert_eq!(value["candidates"][0]["id"], "slot-2"); + assert_eq!( + serde_json::from_value::<ResumeAccountCandidates>(value).unwrap(), + candidates + ); + // No limited account on record: the optional serializes away. + let bare = ResumeAccountCandidates { + limited_account_id: None, + candidates: Vec::new(), + ..candidates + }; + let value = serde_json::to_value(&bare).unwrap(); + assert!(value.get("limitedAccountId").is_none()); + + let switched = ResumeOnAccountOutcome::Switched { + account_id: "slot-2".into(), + account_email: Some("b@example.com".into()), + }; + let value = serde_json::to_value(&switched).unwrap(); + assert_eq!(value["type"], "switched"); + assert_eq!(value["accountId"], "slot-2"); + assert_eq!(value["accountEmail"], "b@example.com"); + assert_eq!( + serde_json::from_value::<ResumeOnAccountOutcome>(value).unwrap(), + switched + ); + assert_eq!( + serde_json::to_value(ResumeOnAccountOutcome::NoCandidates).unwrap(), + serde_json::json!({"type": "noCandidates"}) + ); + let failed = ResumeOnAccountOutcome::Failed { + reason: "the slot vanished".into(), + }; + assert_eq!( + serde_json::to_value(failed.clone()).unwrap(), + serde_json::json!({"type": "failed", "reason": "the slot vanished"}) + ); + assert_eq!( + serde_json::from_value::<ResumeOnAccountOutcome>( + serde_json::to_value(&failed).unwrap() + ) + .unwrap(), + failed + ); + } + #[test] fn agent_origin_is_camel_case_additive_and_defaults_notify_on() { let origin = AgentOrigin { diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index d25f05af58..94725c46d0 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1151,6 +1151,7 @@ impl Tools { auto_approve: false, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: Vec::new(), worktree: None, }; diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index 752398c04e..b3d1e02ff9 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -202,6 +202,21 @@ pub mod methods { pub const LIST_AGENT_ACCOUNTS: &str = "ListAgentAccounts"; pub const ACTIVATE_AGENT_ACCOUNT: &str = "ActivateAgentAccount"; pub const FORGET_AGENT_ACCOUNT: &str = "ForgetAgentAccount"; + /// The ranked switch targets for one Limited chat's "Resume on another + /// account" (t3-orchestration-v2 ticket 09): the cached harness accounts + /// of the chat's harness, best first — switchable, not an API key, not + /// signed out, not still limited, not the account that hit the limit. + /// Empty when only Resume at reset remains. Params `{chatId}` → + /// `ResumeAccountCandidates`. + pub const LIST_RESUME_ACCOUNT_CANDIDATES: &str = "ListResumeAccountCandidates"; + /// **Resume on another account** (t3-orchestration-v2 ticket 09): probe + /// the chosen account (or the best-ranked), activate it, retire the + /// chat's warm runtime, and resume the chat's harness session with the + /// continue prompt — falling back to a fresh session with the budgeted + /// history when the harness refuses. A signed-out failure after the + /// switch moves to the next candidate, else restores the previous + /// account. Params `{chatId, accountId?}` → `ResumeOnAccountOutcome`. + pub const RESUME_ON_ANOTHER_ACCOUNT: &str = "ResumeOnAnotherAccount"; pub const START_AGENT_LOGIN: &str = "StartAgentLogin"; pub const COMPLETE_AGENT_LOGIN: &str = "CompleteAgentLogin"; pub const POLL_AGENT_LOGIN: &str = "PollAgentLogin"; diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index caac10f69f..6b5ad5551f 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8639,6 +8639,7 @@ impl Composer { auto_approve: false, resume: None, provider_fork: None, + resume_fallback_history: None, attachments: attachment_paths, worktree: run_worktree.clone(), mcp: None, diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index d8a4835c44..14f5451519 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -190,6 +190,76 @@ struct ChatMenuState { page: ChatMenuPage, } +/// "Resume on another account" (orchestration v2, ticket 09): the Limited +/// banner's failover picker. The candidates come from the ENGINE's ranking +/// RPC (`ListResumeAccountCandidates`) so desktop and web share one rule; +/// the load re-runs on the TTL and on every popover open (meters move as +/// turns run), the popover offers the rows best-first with the recommended +/// one marked, and a click runs the failover RPC whose reply becomes the +/// outcome line — a failed switch surfaces on the composer's failure chip. +#[derive(Default)] +struct FailoverState { + /// The chat whose candidates are held, when they were loaded, and the + /// ranked rows (the first is the recommendation). + loaded: Option<( + String, + std::time::Instant, + roboco_proto::ResumeAccountCandidates, + )>, + /// The chat whose candidate load is in flight. + loading: Option<String>, + /// The chat whose switch is in flight (the popover's rows disable). + switching: Option<String>, + popup: popover::Popup<()>, + /// The last switch outcome — a warning-toned strip line ("Resumed on + /// …", "no other account", …) that lives until the next switch or the + /// chat's own state supersedes it. + notice: Option<SharedString>, +} + +/// How long a loaded candidate list stays fresh while the banner shows. +const FAILOVER_LIST_TTL: Duration = Duration::from_secs(30); + +/// The strip line for a failover outcome (ticket 09, view-model): +/// `Switched` names the account the chat resumed on, `NoCandidates` says +/// why nothing else was offered, `Failed` surfaces the reason (which also +/// rides the composer's failure chip — see `switch_failover`). Pure. +fn failover_outcome_notice(outcome: &roboco_proto::ResumeOnAccountOutcome) -> SharedString { + use roboco_proto::ResumeOnAccountOutcome as Outcome; + match outcome { + Outcome::Switched { account_email, .. } => { + let who = account_email.as_deref().unwrap_or("another account"); + format!("Resumed on {who} — continuing where the chat left off.").into() + } + Outcome::NoCandidates => "No other account is available right now.".into(), + Outcome::Failed { reason } => reason.clone().into(), + } +} + +/// The reset caption under one candidate row's usage meters (story 49): +/// "session resets in 45m · week resets in 2h 05m" — the same phrasing the +/// banner uses, one entry per shown window that knows its reset. Pure. +fn candidate_reset_caption( + account: &roboco_proto::AgentAccount, + now: DateTime<Utc>, +) -> Option<String> { + let resets: Vec<String> = account + .usage_windows + .iter() + .take(2) + .filter_map(|window| { + window.resets_at.map(|at| { + format!( + "{} {}", + window.label.to_lowercase(), + format_limit_reset(at.timestamp_millis(), now) + ) + }) + }) + .collect(); + (!resets.is_empty()).then(|| resets.join(" · ")) +} + /// The Changes pane's trash button flow: the confirmation the shell shows on /// click, or the failure card after the RPC rejects the discard. #[derive(Debug, Clone)] @@ -1689,6 +1759,10 @@ pub struct Shell { appearance_settings_sub: Option<Subscription>, /// Session-row context menu, including the Copy submenu. chat_menu: popover::Popup<ChatMenuState>, + /// "Resume on another account" (orchestration v2, ticket 09): the + /// Limited banner's account failover — the engine-ranked candidates + /// for the banner's chat, the picker popover, and the switch outcome. + failover: FailoverState, /// The chat title being edited in place on its row. chat_rename: Option<ChatRename>, /// Chat id awaiting delete confirmation. @@ -2103,6 +2177,7 @@ impl Shell { files_settings_sub: None, appearance_settings_sub: None, chat_menu: popover::Popup::default(), + failover: FailoverState::default(), chat_rename: None, delete_confirm: None, discard_working_tree: None, @@ -4337,6 +4412,287 @@ impl Shell { } } + // ── Resume on another account (orchestration v2, ticket 09) ───────────── + + /// Load or refresh the banner's candidate list through the engine's + /// ranking RPC. Cheap enough to call from the strip's render: a chat + /// change or an expired TTL re-loads, everything else is a no-op. A + /// load failure clears the cache so the next pass retries — the banner + /// keeps offering plain Resume either way. + fn track_failover(&mut self, chat_id: &str, cx: &mut Context<Self>) { + let fresh = self.failover.loaded.as_ref().is_some_and(|(chat, at, _)| { + chat == chat_id && at.elapsed() < FAILOVER_LIST_TTL + }); + if fresh || self.failover.loading.as_deref() == Some(chat_id) { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + self.failover.loading = Some(chat_id.to_string()); + let chat = chat_id.to_string(); + cx.spawn(async move |this, cx| { + let result = engine + .call( + methods::LIST_RESUME_ACCOUNT_CANDIDATES, + serde_json::json!({ "chatId": chat }), + ) + .await + .and_then(|value| { + serde_json::from_value::<roboco_proto::ResumeAccountCandidates>(value) + .map_err(|e| roboco_rpc::RpcError::Failed(e.to_string())) + }); + this.update(cx, |shell, cx| { + shell.failover.loading = None; + match result { + Ok(candidates) => { + shell.failover.loaded = + Some((chat, std::time::Instant::now(), candidates)); + } + Err(err) => { + shell.failover.loaded = None; + tracing::debug!(error = %err, "failover candidates failed to load"); + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The candidate rows for the banner's chat, when they are loaded and + /// non-empty (an empty list leaves only Resume at reset — the chip + /// hides). `None` also while a load is in flight. + fn failover_candidates(&self, chat_id: &str) -> Option<&roboco_proto::ResumeAccountCandidates> { + let (chat, _, candidates) = self.failover.loaded.as_ref()?; + (chat == chat_id && !candidates.candidates.is_empty()).then_some(candidates) + } + + /// Open (or close) the failover picker. Opening re-ranks: usage meters + /// move as turns run, so the list re-loads on every open. + fn toggle_failover(&mut self, cx: &mut Context<Self>) { + if self.failover.popup.take_press_was_open() + || self.failover.popup.is_open() + { + self.close_failover(cx); + return; + } + if let Some(chat) = self.state.read(cx).selected_chat.clone() { + self.failover.loaded = self + .failover + .loaded + .take_if(|(loaded, _, _)| *loaded != chat); + self.failover.popup.open(()); + self.track_failover(&chat, cx); + cx.notify(); + } + } + + fn close_failover(&mut self, cx: &mut Context<Self>) { + if self.failover.popup.begin_close() { + popover::reap_popup(cx, |shell: &mut Self| &mut shell.failover.popup); + } + cx.notify(); + } + + /// Run the failover for `account_id` (the engine probes it first, then + /// activates, retires the warm runtime and dispatches the + /// continuation). The reply becomes the strip's outcome line; a FAILED + /// outcome also rides the composer's failure chip so it survives the + /// banner disappearing. + fn switch_failover(&mut self, account_id: &str, cx: &mut Context<Self>) { + let Some(chat) = self.state.read(cx).selected_chat.clone() else { + return; + }; + if self.failover.switching.is_some() { + return; + } + let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() + else { + return; + }; + self.failover.switching = Some(chat.clone()); + self.failover.notice = None; + let params = serde_json::json!({ "chatId": chat, "accountId": account_id }); + cx.spawn(async move |this, cx| { + let result = engine + .call(methods::RESUME_ON_ANOTHER_ACCOUNT, params) + .await + .and_then(|value| { + serde_json::from_value::<roboco_proto::ResumeOnAccountOutcome>(value) + .map_err(|e| roboco_rpc::RpcError::Failed(e.to_string())) + }); + this.update(cx, |shell, cx| { + shell.failover.switching = None; + shell.close_failover(cx); + let composer = shell.composer.clone(); + let notice = match &result { + Ok(outcome) => failover_outcome_notice(outcome), + Err(err) => format!("Could not switch account: {err}").into(), + }; + shell.failover.notice = Some(notice); + // A FAILED switch also rides the composer's failure chip: it + // survives the banner disappearing (the chip is chat-scoped + // and dismissable) while the strip line is transient. + let failure: Option<SharedString> = match &result { + Ok(roboco_proto::ResumeOnAccountOutcome::Failed { reason }) => { + Some(format!("Could not switch account: {reason}").into()) + } + Err(err) => Some(format!("Could not switch account: {err}").into()), + _ => None, + }; + if let Some(failure) = failure { + composer.update(cx, |composer, cx| { + composer.show_error(failure, cx); + }); + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// The failover picker popover: the ranked rows (usage meters with + /// their reset times, the recommended one first) and the all-chats + /// warning the spec asks the switch to state. + fn failover_card(&mut self, cx: &mut Context<Self>) -> gpui::Div { + let theme = Theme::of(cx).for_popup(); + let now = Utc::now(); + let chat = self.state.read(cx).selected_chat.clone(); + let switching = self.failover.switching.is_some(); + let candidates = chat + .as_deref() + .and_then(|chat| self.failover_candidates(chat)) + .cloned(); + let loading = self.failover.loading.is_some(); + let harness = candidates + .as_ref() + .map(|list| list.harness) + .unwrap_or(roboco_proto::HarnessId::ClaudeCode); + let title: SharedString = match harness { + roboco_proto::HarnessId::Codex => "Resume on another Codex account".into(), + _ => "Resume on another Claude account".into(), + }; + popover::popover_card(&theme) + .w(px(400.0)) + .flex() + .flex_col() + .child(popover::menu_heading(&theme, &title)) + .children(candidates.map(|list| { + list.candidates + .iter() + .enumerate() + .map(|(ix, account)| { + let email: SharedString = account + .email + .clone() + .or_else(|| account.display_name.clone()) + .unwrap_or_else(|| "Unknown account".into()) + .into(); + let mut meta: Vec<gpui::AnyElement> = account + .plan_label + .iter() + .map(|plan| { + div() + .child(SharedString::from(plan.clone())) + .into_any_element() + }) + .collect(); + if ix == 0 { + meta.push( + div() + .text_color(theme.accent) + .child(SharedString::from("Recommended")) + .into_any_element(), + ); + } + let switch_to = account.id.clone(); + popover::menu_row(&theme, false, format!("failover-row-{ix}")) + .id(("failover-row", ix)) + .when(switching, |row| row.opacity(0.6)) + .on_click(cx.listener(move |shell, _, _, cx| { + if shell.failover.switching.is_none() { + shell.switch_failover(&switch_to, cx); + } + })) + .child( + div() + .flex_1() + .min_w_0() + .child( + div() + .truncate() + .text_size(px(12.5)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(email), + ) + .when(!meta.is_empty(), |el| { + el.child(crate::settings::widgets::meta_line( + &theme, meta, + )) + }), + ) + .child( + div() + .flex_none() + .flex() + .flex_col() + .gap(px(2.0)) + .children( + account + .usage_windows + .iter() + .take(2) + .map(|window| { + crate::settings::accounts::render_usage_meter( + window, &theme, + ) + }), + ) + // The windows' reset times (story 49): the + // same "resets in …" phrasing the banner + // uses, one per shown meter. + .children(candidate_reset_caption(account, now).map(|caption| { + div() + .text_size(px(10.5)) + .text_color(theme.text_muted) + .child(SharedString::from(caption)) + })), + ) + }) + .collect::<Vec<_>>() + })) + .when(loading, |el| { + el.child( + div() + .px(px(8.0)) + .py(px(4.0)) + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child(SharedString::from("Checking accounts…")), + ) + }) + // The all-chats warning (spec story 53): switching is per + // HARNESS, not per chat. + .child( + div() + .mx(px(8.0)) + .my(px(6.0)) + .px(px(8.0)) + .py(px(6.0)) + .rounded(px(6.0)) + .bg(theme.wash(0.06)) + .text_size(px(11.5)) + .text_color(theme.text_muted) + .child(SharedString::from( + "Switching applies to every chat on this harness, not just this one.", + )), + ) + } + fn open_chat_copy_menu(&mut self, cx: &mut Context<Self>) { if let Some(menu) = self.chat_menu.open_mut() { menu.page = ChatMenuPage::Copy; @@ -9496,6 +9852,23 @@ impl Shell { fn render_status_strip(&mut self, composer_width: f32, cx: &mut Context<Self>) -> AnyElement { let theme = Theme::of(cx).clone(); let now = Utc::now(); + // "Resume on another account" (ticket 09): keep the banner's + // candidate list warm BEFORE the state borrow — a no-op unless the + // selected chat reads as Limited on a switchable harness. + if let Some(chat) = self.state.read(cx).selected_chat.clone() { + let limited = self.state.read(cx).session_for(&chat).is_some_and(|s| { + s.status == roboco_proto::SessionStatus::Errored + && s.limit.as_ref().is_some_and(|limit| { + matches!( + limit.harness, + roboco_proto::HarnessId::ClaudeCode | roboco_proto::HarnessId::Codex + ) + }) + }); + if limited { + self.track_failover(&chat, cx); + } + } let state = self.state.read(cx); // Keep notices aligned with the current composer width, including @@ -9563,6 +9936,30 @@ impl Shell { } None => "Usage limit reached".into(), }; + // "Resume on another account" (ticket 09) — only for the + // harnesses whose logins switch. An EMPTY candidate list + // (every other account limited, signed out, an API key or + // in use) hides the chip: only Resume (and, with ticket 07, + // Resume at reset) remains. + let has_candidates = limit.as_ref().is_some_and(|limit| { + matches!( + limit.harness, + roboco_proto::HarnessId::ClaudeCode | roboco_proto::HarnessId::Codex + ) + }) && self.failover_candidates(&chat_id).is_some(); + let notice = self.failover.notice.clone(); + let popup_open = self.failover.popup.is_open(); + let failover_menu = popup_open.then(|| { + self.failover_card(cx) + .on_mouse_down_out(cx.listener(|this, _, _, cx| { + this.close_failover(cx); + })) + .into_any_element() + }); + let press_notes_trigger = cx.listener(|this, _, _, _| { + this.failover.popup.note_trigger_press(); + }); + let toggle = cx.listener(|this, _, _, cx| this.toggle_failover(cx)); strip .text_color(theme.warning) .child(div() @@ -9598,6 +9995,46 @@ impl Shell { )) .child(SharedString::from("Resume")), ) + .when(has_candidates, |el| { + el.child( + div() + .id("limited-failover") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_mouse_down(gpui::MouseButton::Left, press_notes_trigger) + .on_click(toggle) + .tooltip(crate::settings::widgets::text_tooltip( + "Continue on another account of this harness", + )) + .child(SharedString::from("Resume on another account")) + .when_some(failover_menu, |chip, menu| { + chip.child(popover::anchored_menu_below_end( + "limited-failover-menu", + menu, + self.failover.popup.closing_since(), + )) + }), + ) + }) + .when_some(notice, |el, notice| { + el.child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_color(theme.warning_muted) + .child(notice), + ) + }) .into_any_element() } Indicator::None if sending => strip @@ -11657,6 +12094,84 @@ mod tests { } } + /// The failover outcome lines (ticket 09, checklist 6): the switch names + /// the account it resumed on, no-candidates explains itself, a failure + /// surfaces its reason verbatim (the failure chip prefixes it). + #[test] + fn failover_outcome_notice_names_the_switch_and_its_failures() { + use roboco_proto::ResumeOnAccountOutcome as Outcome; + let switched = Outcome::Switched { + account_id: "slot-carol".into(), + account_email: Some("carol@example.com".into()), + }; + assert_eq!( + failover_outcome_notice(&switched).as_ref(), + "Resumed on carol@example.com — continuing where the chat left off." + ); + // No email on the row: still names what happened. + let anonymous = Outcome::Switched { + account_id: "slot-x".into(), + account_email: None, + }; + assert_eq!( + failover_outcome_notice(&anonymous).as_ref(), + "Resumed on another account — continuing where the chat left off." + ); + assert_eq!( + failover_outcome_notice(&Outcome::NoCandidates).as_ref(), + "No other account is available right now." + ); + assert_eq!( + failover_outcome_notice(&Outcome::Failed { + reason: "the slot vanished".into() + }) + .as_ref(), + "the slot vanished" + ); + } + + /// The candidate rows' reset caption (story 49): the shown windows' + /// reset times in the banner's own phrasing, one per meter that knows + /// its reset; nothing when the meters carry no resets. + #[test] + fn candidate_reset_caption_lists_the_windows_reset_times() { + let now = Utc::now(); + let account = roboco_proto::AgentAccount { + usage_windows: vec![ + roboco_proto::AgentUsageWindow { + label: "Session".into(), + used_fraction: 0.4, + resets_at: Some(now + chrono::TimeDelta::minutes(45)), + }, + roboco_proto::AgentUsageWindow { + label: "Week".into(), + used_fraction: 0.1, + resets_at: Some(now + chrono::TimeDelta::hours(2)), + }, + roboco_proto::AgentUsageWindow { + label: "Month".into(), + used_fraction: 0.0, + resets_at: None, + }, + ], + ..serde_json::from_value(serde_json::json!({ + "id": "slot-1", "harness": "claude-code", "email": null, + "planLabel": null, "active": false, "switchable": true, + })) + .unwrap() + }; + assert_eq!( + candidate_reset_caption(&account, now).as_deref(), + Some("session resets in 45m · week resets in 2h") + ); + // No resets known: no caption — the meters still show. + let bare = roboco_proto::AgentAccount { + usage_windows: vec![], + ..account + }; + assert_eq!(candidate_reset_caption(&bare, now), None); + } + #[test] fn github_star_banner_links_to_the_roboco_repository() { // Rebrand guard (upstream d92d56a2 links at zeronsh): the banner diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index bdf8b4c032..80a5d7a908 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -83,6 +83,14 @@ const LEGACY_NOTES: &[(&str, &str)] = &[ "AgentAccountWarning", "Legacy wire name (ADR 0005): the canonical term is \"Harness account\" — see CONTEXT.md.", ), + ( + "ResumeAccountCandidates", + "Legacy wire name (ADR 0005): the canonical term is \"Harness account\" — see CONTEXT.md.", + ), + ( + "ResumeOnAccountOutcome", + "Legacy wire name (ADR 0005): the canonical term is \"Harness account\" — see CONTEXT.md.", + ), ( "AgentLoginStart", "Legacy wire name (ADR 0005): the canonical term is \"Harness account\" — see CONTEXT.md.", @@ -192,6 +200,8 @@ fn export_all(cfg: &Config) -> Result<()> { roboco_proto::AgentAuthKind, roboco_proto::AgentSignInState, roboco_proto::AgentAccountsSnapshot, + roboco_proto::ResumeAccountCandidates, + roboco_proto::ResumeOnAccountOutcome, roboco_proto::AgentAccountWarning, roboco_proto::AgentLoginStart, roboco_proto::AgentLoginMode, diff --git a/web/packages/proto/src/generated/ResumeAccountCandidates.ts b/web/packages/proto/src/generated/ResumeAccountCandidates.ts new file mode 100644 index 0000000000..c496fbf259 --- /dev/null +++ b/web/packages/proto/src/generated/ResumeAccountCandidates.ts @@ -0,0 +1,29 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +// Legacy wire name (ADR 0005): the canonical term is "Harness account" — see CONTEXT.md. + +import type { AgentAccount } from "./AgentAccount"; +import type { HarnessId } from "./HarnessId"; + +/** + * The ranked switch targets for one Limited chat's "Resume on another + * account" (orchestration v2, ticket 09): the cached harness accounts of + * the chat's harness, filtered and ranked by the engine's failover rules — + * switchable, not an API key, not signed out, not still limited, and not + * the account that hit the limit; lowest usage first, accounts sharing the + * limited account's org or workspace ranked lower. The first entry is the + * recommended one. Signed-out accounts are absent (they offer "Sign in + * again" in the account switcher instead); an empty list leaves only + * Resume at reset. + */ +export type ResumeAccountCandidates = { chatId: string, harness: HarnessId, +/** + * The account the chat's limit hit (the record's account id, else the + * harness's active login) — named so the picker can say what it is + * switching FROM. Never a candidate. + */ +limitedAccountId?: string | null, +/** + * The candidate rows in rank order, best first. Usage windows and + * limited-until ride the rows themselves. + */ +candidates: Array<AgentAccount>, }; diff --git a/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts b/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts new file mode 100644 index 0000000000..41b0119ef8 --- /dev/null +++ b/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts @@ -0,0 +1,16 @@ +// DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +// Legacy wire name (ADR 0005): the canonical term is "Harness account" — see CONTEXT.md. + +/** + * The synchronous outcome of "Resume on another account" (ticket 09): the + * account switch, warm-runtime retirement and continuation dispatch the + * engine performed — or why it could not. The continuation turn's own + * result arrives through the chat's status; a signed-out failure after the + * switch is handled by the engine (next candidate, else the previous + * account restored) before the chat settles. + */ +export type ResumeOnAccountOutcome = { "type": "switched", accountId: string, +/** + * The row's email (or display name) — the "Resumed on …" label. + */ +accountEmail?: string | null, } | { "type": "noCandidates" } | { "type": "failed", reason: string }; diff --git a/web/packages/proto/src/generated/RunRequest.ts b/web/packages/proto/src/generated/RunRequest.ts index 0e4a1ae9b1..03d305fa74 100644 --- a/web/packages/proto/src/generated/RunRequest.ts +++ b/web/packages/proto/src/generated/RunRequest.ts @@ -23,6 +23,17 @@ modelOptions: Record<string, unknown>, cwd: string, sandbox: SandboxLevel, autoA * Harness-native session id to resume, if any. */ resume: string | null, +/** + * The budgeted transcript (ticket 01's builder) delivered in front of + * the prompt ONLY when the harness refuses the native resume of + * `resume` and starts a fresh session instead (account failover, + * orchestration v2 ticket 09: cross-account resumes usually keep working, + * but a session the harness will not continue must still carry the chat's + * prior context). Only the engine sets it — a plain resume carries none, + * and a resumed session that never refuses never sees it. Additive + + * serde-defaulted for wire compat. + */ +resumeFallbackHistory?: string | null, /** * A native fork to perform instead of a plain resume or fresh start * (t3-orchestration-v2 ticket 17): the driver forks the source provider diff --git a/web/packages/proto/src/generated/index.ts b/web/packages/proto/src/generated/index.ts index 3a2e3cdb97..6229d1c923 100644 --- a/web/packages/proto/src/generated/index.ts +++ b/web/packages/proto/src/generated/index.ts @@ -97,6 +97,8 @@ export * from "./RemoteAccessStatus"; export * from "./RenewQueueEditOutcome"; export * from "./Repo"; export * from "./RepoRef"; +export * from "./ResumeAccountCandidates"; +export * from "./ResumeOnAccountOutcome"; export * from "./RunRequest"; export * from "./SandboxLevel"; export * from "./SearchWorkspaceFilesRequest"; From e8636c42b500fb75aaba30fcbfbeca8f056f3297 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 00:24:43 +0700 Subject: [PATCH 40/96] fix(engine): settle the resume-at-reset compile errors --- crates/engine/src/engine_settings.rs | 9 +++++++++ crates/engine/src/lib.rs | 4 +--- crates/engine/src/scheduled_resumes.rs | 7 +++++-- crates/engine/src/sessions.rs | 16 ++++++++-------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs index a3a4fffcaa..cd660ef190 100644 --- a/crates/engine/src/engine_settings.rs +++ b/crates/engine/src/engine_settings.rs @@ -45,6 +45,15 @@ impl EngineSettingsStore { } } + /// Point an existing store at `path` and load what is persisted there + /// (engine assembly; the store lives on the shared sessions engine, so + /// it is wired in place rather than replaced). + pub(crate) fn attach(&self, path: PathBuf) { + let settings = load_file(&path).unwrap_or_default(); + *lock(&self.settings) = settings; + *lock(&self.path) = Some(path); + } + pub(crate) fn get(&self) -> EngineSettings { *lock(&self.settings) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 834b09aee7..6606b31b22 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -245,9 +245,7 @@ impl EngineCore { // Resume-at-reset store both live under the engine data dir; the // continuations' timers arm after `recover_stale` (firing needs the // wired doc host) — orchestration v2, ticket 07. - sessions.set_engine_settings_store(crate::engine_settings::EngineSettingsStore::open( - Some(data_dir.join("engine-settings.json")), - )); + sessions.set_engine_settings_path(data_dir.join("engine-settings.json")); sessions.set_scheduled_resumes_path(data_dir.join("scheduled-resumes.json")); match sessions.recover_stale() { Ok(0) => {} diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 3fe197d6da..175f07ef74 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -26,6 +26,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use roboco_proto::ScheduledResume; +use serde::{Deserialize, Serialize}; use crate::now_ms; @@ -230,8 +231,10 @@ impl ScheduledResumes { fn load_file(path: &Path) -> ScheduledResumeFile { std::fs::read(path) - .map_err(Into::into) - .and_then(|bytes| serde_json::from_slice(&bytes)) + .map_err(|err| err.to_string()) + .and_then(|bytes| { + serde_json::from_slice::<ScheduledResumeFile>(&bytes).map_err(|err| err.to_string()) + }) .unwrap_or_else(|err| { // Missing file = fresh engine; broken file = keep going with // nothing armed (never refuse the engine over one feature). diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 4274629dcc..26a4d229eb 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -345,10 +345,11 @@ impl SessionsEngine { self.inner.scheduled_resumes.set_path(path); } - /// Wire the engine settings store (engine assembly; a bare engine keeps - /// the in-memory default — auto-resume off). - pub fn set_engine_settings_store(&self, store: crate::engine_settings::EngineSettingsStore) { - self.inner.engine_settings = store; + /// Persist the engine settings under `path`, loading anything already + /// there (engine assembly; a bare engine keeps the in-memory default — + /// auto-resume off). + pub fn set_engine_settings_path(&self, path: std::path::PathBuf) { + self.inner.engine_settings.attach(path); } /// The engine's settings (see [`crate::engine_settings::EngineSettings`]). @@ -1592,7 +1593,7 @@ impl SessionsEngine { // fleet of overdue continuations re-dispatches spaced out, not // as one boot burst of CLI spawns. let delay = std::time::Duration::from_millis(ix as u64 * RESUME_STAGGER_MS); - self.spawn_resume_timer(chat_id, fire_at, disarmed, delay); + self.spawn_resume_timer(chat_id.clone(), fire_at, disarmed, delay); tracing::info!( chat = %chat_id, fire_at, @@ -1659,9 +1660,8 @@ impl SessionsEngine { } // Clock skew and a reset time minted on a lagging client clamp // to "due now": never a panic, never a negative sleep. - let target = std::time::SystemTime::UNIX_EPOCH - + std::time::Duration::from_millis(fire_at.max(0) as u64); - tokio::time::sleep_until(tokio::time::Instant::from_std(target)).await; + let wait_ms = fire_at.saturating_sub(now_ms()).max(0) as u64; + tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await; if disarmed.load(std::sync::atomic::Ordering::Acquire) { return; } From e7d4d72067810f91befd6bee2fa29c1a9808692d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 00:48:10 +0700 Subject: [PATCH 41/96] feat(ui): offer Sign in again on the failover picker's signed-out rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 09's spec reading, sharpened on review: a signed-out harness account is never a switch target (the ranking excludes it), but the Limited banner's failover picker LIST it as a non-selectable row with a "Sign in again" action — stories 47 and 50, "they show 'Sign in again' instead". - Wire (additive, ADR 0005): ResumeAccountCandidates.signedOut carries the harness's OTHER signed-out rows beside the ranked candidates; hand-written TS in the wiregen layout. - Engine: AgentAccounts::failover_signed_out collects the dead logins (cached, no network, never the live login); candidates() rides them on the reply so desktop and web share one list. - Desktop: the picker renders a shared row model (failover_rows — candidates first, the recommended one marked, signed-out rows after, never clickable) whose signed-out rows carry the "Signed out" mark and a nested "Sign in again" button. The button drives the SAME login flow ticket 08's account ring drives (AccountUsage::sign_in_again via the new Composer::sign_in_account), so the flow's progress shows in the ring's popover right below the banner. The banner's chip now shows when the list offers anything to act on — a healthy candidate OR a row to sign back in; both empty still leaves only Resume (and Resume at reset, ticket 07). - The post-merge literal fixes ride in the merge commit: 07/25's new RunRequest literals (resume_at_reset.rs, continue_on_harness.rs) got resume_fallback_history; the Limited banner keeps ALL actions from tickets 07 (Resume at reset/Cancel), 25 (Continue on another harness) and 09 (Resume, Resume on another account, the outcome line). Tests (not run locally per the verification policy; CI is the signal): proto round-trip for the additive signedOut list; the engine candidates test asserts dave rides signed_out while erin/frank never surface; the bare-store case asserts neither list; a shell view-model test pins the picker's row model (candidates ranked + recommended, signed-out rows after, empty lists render nothing). --- crates/engine/src/account_failover.rs | 21 +- crates/engine/src/agent_accounts.rs | 23 ++ crates/engine/src/sessions.rs | 7 +- crates/engine/tests/account_failover.rs | 14 + crates/proto/src/entities.rs | 43 ++- crates/ui/src/account_usage.rs | 4 +- crates/ui/src/composer.rs | 16 + crates/ui/src/shell.rs | 344 +++++++++++++----- .../src/generated/ResumeAccountCandidates.ts | 15 +- 9 files changed, 374 insertions(+), 113 deletions(-) diff --git a/crates/engine/src/account_failover.rs b/crates/engine/src/account_failover.rs index 2b1dca9757..2324cd9902 100644 --- a/crates/engine/src/account_failover.rs +++ b/crates/engine/src/account_failover.rs @@ -92,28 +92,35 @@ pub(crate) async fn candidates( harness: HarnessId::ClaudeCode, limited_account_id: None, candidates: Vec::new(), + signed_out: Vec::new(), }); }; - let (limited_account_id, candidates) = match engine.agent_accounts() { + let (limited_account_id, candidates, signed_out) = match engine.agent_accounts() { Some(accounts) => { let limited = session .as_ref() .and_then(|row| row.limit.as_ref()) .and_then(|limit| limit.account_id.clone()); - let rows = if failover_supported(harness) { - accounts.failover_candidates(harness, limited.as_deref()).await + if failover_supported(harness) { + ( + limited, + accounts.failover_candidates(harness, limited.as_deref()).await, + // The dead logins the picker offers "Sign in again" + // for (stories 47/50) — listed, never switched to. + accounts.failover_signed_out(harness).await, + ) } else { - Vec::new() - }; - (limited, rows) + (limited, Vec::new(), Vec::new()) + } } - None => (None, Vec::new()), + None => (None, Vec::new(), Vec::new()), }; Ok(ResumeAccountCandidates { chat_id: chat_id.to_string(), harness, limited_account_id, candidates, + signed_out, }) } diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 6b9ef0b2dc..c727006118 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -1009,6 +1009,29 @@ impl AgentAccounts { /// limit record's account, else the harness's active login when the /// record carried none). See [`rank_failover_candidates`] for the /// exclusion and ranking rules. + /// The harness's OTHER accounts that are signed out (ticket 09's + /// picker): never switch targets — the ranking excludes them — but the + /// picker lists them with a "Sign in again" action (the existing login + /// flow ticket 08 drives) so a dead login can be revived in place + /// (spec stories 47 and 50). Cached rows, no network; the live login is + /// never listed (it is the account the picker says it switches FROM). + pub async fn failover_signed_out(&self, harness: HarnessId) -> Vec<AgentAccount> { + self.list(false) + .await + .map(|snapshot| { + snapshot + .accounts + .into_iter() + .filter(|row| { + row.harness == harness + && !row.active + && row.sign_in_state == AgentSignInState::SignedOut + }) + .collect() + }) + .unwrap_or_default() + } + pub async fn failover_candidates( &self, harness: HarnessId, diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 727387b01c..d0a84b1399 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -362,9 +362,10 @@ impl SessionsEngine { /// The ranked switch targets for one Limited chat's "Resume on another /// account" (orchestration v2, ticket 09): the cached harness accounts - /// of the chat's harness, best first. Empty when every other account is - /// limited, signed out, an API key, or unswitchable — then only Resume - /// at reset is offered. + /// of the chat's harness, best first, plus the harness's signed-out + /// rows (listed with a "Sign in again" offer — stories 47/50). Both + /// lists empty when every other account is limited, an API key, or + /// unswitchable — then only Resume at reset is offered. pub async fn resume_account_candidates( &self, chat_id: &str, diff --git a/crates/engine/tests/account_failover.rs b/crates/engine/tests/account_failover.rs index b9044c6f40..d92b7dbb73 100644 --- a/crates/engine/tests/account_failover.rs +++ b/crates/engine/tests/account_failover.rs @@ -724,6 +724,16 @@ async fn candidates_exclude_dead_rows_and_rank_by_usage_then_org() { let carol = ranked.candidates.first().unwrap(); assert_eq!(carol.usage_windows.len(), 1); assert_eq!(carol.usage_windows[0].used_fraction, 0.05); + // The signed-out accounts (stories 47/50) ride their own list — dave + // is excluded as a candidate but offered for "Sign in again"; the + // still-limited erin and the API-key frank are neither (reviving them + // means waiting or a new API key, not a sign-in). + let signed_out: Vec<&str> = ranked + .signed_out + .iter() + .map(|row| row.id.as_str()) + .collect(); + assert_eq!(signed_out, vec!["slot-dave"]); core.shutdown().await; } @@ -920,6 +930,10 @@ async fn with_no_healthy_candidate_only_resume_at_reset_remains() { let ranked = candidates(&client, LIMITED).await; assert!(ranked.candidates.is_empty(), "no candidates offered"); + assert!( + ranked.signed_out.is_empty(), + "no signed-out rows either — nothing to sign back in" + ); let outcome = resume_on_another_account(&client, LIMITED, None).await; assert_eq!(outcome, ResumeOnAccountOutcome::NoCandidates); // Nothing switched, nothing dispatched: the live login is still the diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index b7c95df835..a81a39c743 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -1097,9 +1097,9 @@ pub struct AgentAccountsSnapshot { /// switchable, not an API key, not signed out, not still limited, and not /// the account that hit the limit; lowest usage first, accounts sharing the /// limited account's org or workspace ranked lower. The first entry is the -/// recommended one. Signed-out accounts are absent (they offer "Sign in -/// again" in the account switcher instead); an empty list leaves only -/// Resume at reset. +/// recommended one. Signed-out accounts ride `signed_out` — the picker +/// lists them with a "Sign in again" action instead of a switch (stories +/// 47/50). Both lists empty leaves only Resume (and Resume at reset). #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeAccountCandidates { @@ -1113,6 +1113,12 @@ pub struct ResumeAccountCandidates { /// The candidate rows in rank order, best first. Usage windows and /// limited-until ride the rows themselves. pub candidates: Vec<AgentAccount>, + /// The harness's OTHER accounts that are signed out (ticket 08's mark): + /// never switch targets, but the picker lists them with a "Sign in + /// again" action (the existing login flow) so a dead login can be + /// revived in place — stories 47 and 50. Additive + serde-defaulted. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signed_out: Vec<AgentAccount>, } /// The synchronous outcome of "Resume on another account" (ticket 09): the @@ -1827,6 +1833,7 @@ mod tests { harness: HarnessId::Codex, limited_account_id: Some("slot-1".into()), candidates: vec![account.clone()], + signed_out: Vec::new(), }; let value = serde_json::to_value(&candidates).unwrap(); assert_eq!(value["chatId"], "chat-9"); @@ -1841,11 +1848,39 @@ mod tests { let bare = ResumeAccountCandidates { limited_account_id: None, candidates: Vec::new(), - ..candidates + ..candidates.clone() }; let value = serde_json::to_value(&bare).unwrap(); assert!(value.get("limitedAccountId").is_none()); + // The signed-out rows the picker offers "Sign in again" for ride + // their own additive list, camelCased, serializing away when empty. + let mut signed_out = account.clone(); + signed_out.id = "slot-3".into(); + signed_out.sign_in_state = AgentSignInState::SignedOut; + let with_signed_out = ResumeAccountCandidates { + signed_out: vec![signed_out.clone()], + ..candidates.clone() + }; + let value = serde_json::to_value(&with_signed_out).unwrap(); + assert_eq!(value["signedOut"][0]["id"], "slot-3"); + assert_eq!(value["signedOut"][0]["signInState"], "signed-out"); + assert_eq!( + serde_json::from_value::<ResumeAccountCandidates>(value).unwrap(), + with_signed_out + ); + // An empty signed-out list is omitted, not null — older payloads + // parse with nothing to sign in. + let value = serde_json::to_value(&candidates).unwrap(); + assert!(value.get("signedOut").is_none()); + let legacy = serde_json::json!({ + "chatId": "chat-9", + "harness": "codex", + "candidates": [], + }); + let legacy: ResumeAccountCandidates = serde_json::from_value(legacy).unwrap(); + assert!(legacy.signed_out.is_empty()); + let switched = ResumeOnAccountOutcome::Switched { account_id: "slot-2".into(), account_email: Some("b@example.com".into()), diff --git a/crates/ui/src/account_usage.rs b/crates/ui/src/account_usage.rs index d08da48584..6340710fc1 100644 --- a/crates/ui/src/account_usage.rs +++ b/crates/ui/src/account_usage.rs @@ -302,7 +302,9 @@ impl AccountUsage { /// Start the existing login flow for a signed-out account's provider /// group — the same `StartAgentLogin` Settings → Accounts runs. The /// browser opens at once; the card shows the flow until it lands. - fn sign_in_again(&mut self, account: &AgentAccount, cx: &mut Context<Self>) { + /// `pub(crate)` so the Limited banner's failover picker (ticket 09) + /// drives the same flow through the composer's account ring. + pub(crate) fn sign_in_again(&mut self, account: &AgentAccount, cx: &mut Context<Self>) { let Some(engine) = crate::request_routing::device_target(self.state.read(cx), self.target.as_deref()).ok() else { diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index ef987353c6..1a5ea9734d 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -8840,6 +8840,22 @@ impl Composer { self.interrupt_chat(chat_id, cx); } + /// Start the "Sign in again" flow for one harness account from outside + /// the footer's account ring — the Limited banner's failover picker + /// (ticket 09, stories 47/50) lists signed-out accounts with this + /// action. The SAME login flow ticket 08's switcher drives, on the SAME + /// `AccountUsage` entity, so the ring's popover shows the progress right + /// where the user already looks (browser flow, paste-code fallback and + /// all). + pub(crate) fn sign_in_account( + &mut self, + account: &roboco_proto::AgentAccount, + cx: &mut Context<Self>, + ) { + self.account_usage + .update(cx, |usage, cx| usage.sign_in_again(account, cx)); + } + /// **Resume** on a Limited chat (orchestration v2): send the fixed /// continue prompt as a NORMAL run — same send path, same guards, same /// queue semantics as typing it — never a special engine mode. The run diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 7297d254e9..51cfe40907 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -222,6 +222,45 @@ struct FailoverState { /// How long a loaded candidate list stays fresh while the banner shows. const FAILOVER_LIST_TTL: Duration = Duration::from_secs(30); +/// One row of the failover picker (ticket 09): a switchable candidate, or +/// a signed-out account offered "Sign in again" instead (stories 47/50 — +/// a dead login is listed, never switched to). +#[derive(Clone, Debug, PartialEq)] +enum FailoverRow { + /// A ranked switch target; `recommended` marks the best one. + Candidate { + account: roboco_proto::AgentAccount, + recommended: bool, + }, + /// A dead login the picker can revive in place (the existing login + /// flow, on the composer's account ring). + SignedOut { + account: roboco_proto::AgentAccount, + }, +} + +/// The picker's row model: the ranked candidates (best first, the first +/// recommended) followed by the signed-out accounts. Pure. +fn failover_rows(list: &roboco_proto::ResumeAccountCandidates) -> Vec<FailoverRow> { + let mut rows: Vec<FailoverRow> = list + .candidates + .iter() + .enumerate() + .map(|(ix, account)| FailoverRow::Candidate { + account: account.clone(), + recommended: ix == 0, + }) + .collect(); + rows.extend( + list.signed_out + .iter() + .map(|account| FailoverRow::SignedOut { + account: account.clone(), + }), + ); + rows +} + /// The strip line for a failover outcome (ticket 09, view-model): /// `Switched` names the account the chat resumed on, `NoCandidates` says /// why nothing else was offered, `Failed` surfaces the reason (which also @@ -4478,12 +4517,16 @@ impl Shell { .detach(); } - /// The candidate rows for the banner's chat, when they are loaded and - /// non-empty (an empty list leaves only Resume at reset — the chip - /// hides). `None` also while a load is in flight. - fn failover_candidates(&self, chat_id: &str) -> Option<&roboco_proto::ResumeAccountCandidates> { - let (chat, _, candidates) = self.failover.loaded.as_ref()?; - (chat == chat_id && !candidates.candidates.is_empty()).then_some(candidates) + /// The banner's candidate list for `chat_id`, when it is loaded and + /// offers something: healthy candidates, signed-out rows to sign back + /// in (stories 47/50), or both. `None` while a load is in flight, or + /// when both lists are empty — then only Resume (and Resume at reset) + /// remain and the chip hides. + fn failover_list(&self, chat_id: &str) -> Option<&roboco_proto::ResumeAccountCandidates> { + let (chat, _, list) = self.failover.loaded.as_ref()?; + (chat == chat_id + && (!list.candidates.is_empty() || !list.signed_out.is_empty())) + .then_some(list) } /// Open (or close) the failover picker. Opening re-ranks: usage meters @@ -4571,23 +4614,25 @@ impl Shell { .detach(); } - /// The failover picker popover: the ranked rows (usage meters with - /// their reset times, the recommended one first) and the all-chats - /// warning the spec asks the switch to state. + /// The failover picker popover: the ranked candidate rows (usage meters + /// with their reset times, the recommended one first), the signed-out + /// accounts with a "Sign in again" action (stories 47/50), and the + /// all-chats warning the spec asks the switch to state. fn failover_card(&mut self, cx: &mut Context<Self>) -> gpui::Div { let theme = Theme::of(cx).for_popup(); let now = Utc::now(); let chat = self.state.read(cx).selected_chat.clone(); let switching = self.failover.switching.is_some(); - let candidates = chat + let list = chat .as_deref() - .and_then(|chat| self.failover_candidates(chat)) + .and_then(|chat| self.failover_list(chat)) .cloned(); let loading = self.failover.loading.is_some(); - let harness = candidates + let harness = list .as_ref() .map(|list| list.harness) .unwrap_or(roboco_proto::HarnessId::ClaudeCode); + let rows = list.as_ref().map(failover_rows).unwrap_or_default(); let title: SharedString = match harness { roboco_proto::HarnessId::Codex => "Resume on another Codex account".into(), _ => "Resume on another Claude account".into(), @@ -4597,89 +4642,138 @@ impl Shell { .flex() .flex_col() .child(popover::menu_heading(&theme, &title)) - .children(candidates.map(|list| { - list.candidates + .children(rows.into_iter().enumerate().map(|(ix, row)| { + let (account, recommended, signed_out) = match row { + FailoverRow::Candidate { + account, + recommended, + } => (account, recommended, false), + FailoverRow::SignedOut { account } => (account, false, true), + }; + let email: SharedString = account + .email + .clone() + .or_else(|| account.display_name.clone()) + .unwrap_or_else(|| "Unknown account".into()) + .into(); + let mut meta: Vec<gpui::AnyElement> = account + .plan_label .iter() - .enumerate() - .map(|(ix, account)| { - let email: SharedString = account - .email - .clone() - .or_else(|| account.display_name.clone()) - .unwrap_or_else(|| "Unknown account".into()) - .into(); - let mut meta: Vec<gpui::AnyElement> = account - .plan_label - .iter() - .map(|plan| { - div() - .child(SharedString::from(plan.clone())) - .into_any_element() - }) - .collect(); - if ix == 0 { - meta.push( - div() - .text_color(theme.accent) - .child(SharedString::from("Recommended")) - .into_any_element(), - ); - } - let switch_to = account.id.clone(); - popover::menu_row(&theme, false, format!("failover-row-{ix}")) - .id(("failover-row", ix)) - .when(switching, |row| row.opacity(0.6)) - .on_click(cx.listener(move |shell, _, _, cx| { - if shell.failover.switching.is_none() { - shell.switch_failover(&switch_to, cx); - } - })) + .map(|plan| { + div() + .child(SharedString::from(plan.clone())) + .into_any_element() + }) + .collect(); + if recommended { + meta.push( + div() + .text_color(theme.accent) + .child(SharedString::from("Recommended")) + .into_any_element(), + ); + } + if signed_out { + // The mark that keeps a dead login from reading healthy + // (ticket 08) — the row never switches, it signs in. + meta.push( + div() + .text_color(theme.danger) + .child(SharedString::from("Signed out")) + .into_any_element(), + ); + } + let switch_to = account.id.clone(); + let sign_in = account.clone(); + popover::menu_row(&theme, false, format!("failover-row-{ix}")) + .id(("failover-row", ix)) + .when(switching, |row| row.opacity(0.6)) + // A signed-out row is listed, never selected. + .when(signed_out, |row| row.cursor_default()) + .when(!signed_out, |row| { + row.on_click(cx.listener(move |shell, _, _, cx| { + if shell.failover.switching.is_none() { + shell.switch_failover(&switch_to, cx); + } + })) + }) + .child( + div() + .flex_1() + .min_w_0() .child( div() - .flex_1() - .min_w_0() - .child( - div() - .truncate() - .text_size(px(12.5)) - .font_weight(gpui::FontWeight::MEDIUM) - .child(email), - ) - .when(!meta.is_empty(), |el| { - el.child(crate::settings::widgets::meta_line( - &theme, meta, - )) + .truncate() + .text_size(px(12.5)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(email), + ) + .when(!meta.is_empty(), |el| { + el.child(crate::settings::widgets::meta_line( + &theme, meta, + )) + }), + ) + .child( + div() + .flex_none() + .flex() + .flex_col() + .items_end() + .gap(px(2.0)) + .children( + account + .usage_windows + .iter() + .take(2) + .map(|window| { + crate::settings::accounts::render_usage_meter( + window, &theme, + ) }), ) - .child( - div() - .flex_none() - .flex() - .flex_col() - .gap(px(2.0)) - .children( - account - .usage_windows - .iter() - .take(2) - .map(|window| { - crate::settings::accounts::render_usage_meter( - window, &theme, - ) - }), - ) - // The windows' reset times (story 49): the - // same "resets in …" phrasing the banner - // uses, one per shown meter. - .children(candidate_reset_caption(account, now).map(|caption| { - div() - .text_size(px(10.5)) - .text_color(theme.text_muted) - .child(SharedString::from(caption)) - })), + // The windows' reset times (story 49): the + // same "resets in …" phrasing the banner + // uses, one per shown meter. + .children( + candidate_reset_caption(account, now).map(|caption| { + div() + .text_size(px(10.5)) + .text_color(theme.text_muted) + .child(SharedString::from(caption)) + }), ) - }) - .collect::<Vec<_>>() + // The signed-out row's own action (story 47): the + // same login flow the account ring drives, so + // the flow's progress shows right below the + // picker where the user already looks. + .when(signed_out, |el| { + el.child( + div() + .id(("failover-sign-in", ix)) + .mt(px(2.0)) + .px(px(8.0)) + .py(px(3.0)) + .rounded(px(6.0)) + .bg(theme.accent.opacity(0.14)) + .text_size(px(11.5)) + .text_color(theme.accent) + .cursor_pointer() + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.composer.update(cx, |composer, cx| { + composer.sign_in_account(&sign_in, cx); + }); + })) + .child(SharedString::from("Sign in again")), + ) + }), + ) })) .when(loading, |el| { el.child( @@ -10007,16 +10101,17 @@ impl Shell { (None, _) => "Usage limit reached".into(), }; // "Resume on another account" (ticket 09) — only for the - // harnesses whose logins switch. An EMPTY candidate list - // (every other account limited, signed out, an API key or - // in use) hides the chip: only Resume (and, with ticket 07, - // Resume at reset) remains. + // harnesses whose logins switch. The chip hides when the + // engine's list offers nothing: no healthy candidate AND no + // signed-out row to sign back in (every other account + // limited, an API key, or in use) — then only Resume (and, + // with ticket 07, Resume at reset) remains. let has_candidates = limit.as_ref().is_some_and(|limit| { matches!( limit.harness, roboco_proto::HarnessId::ClaudeCode | roboco_proto::HarnessId::Codex ) - }) && self.failover_candidates(&chat_id).is_some(); + }) && self.failover_list(&chat_id).is_some(); let notice = self.failover.notice.clone(); let popup_open = self.failover.popup.is_open(); let failover_menu = popup_open.then(|| { @@ -12255,6 +12350,67 @@ mod tests { } } + /// The failover picker's row model (ticket 09, stories 47/50): the + /// ranked candidates lead (the first recommended), the signed-out + /// accounts follow as Sign-in-again rows — listed, never switched to — + /// and a list offering nothing renders nothing. + #[test] + fn failover_rows_list_candidates_first_then_signed_out_accounts() { + fn account(id: &str, state: roboco_proto::AgentSignInState) -> roboco_proto::AgentAccount { + let mut row: roboco_proto::AgentAccount = serde_json::from_value(serde_json::json!({ + "id": id, "harness": "claude-code", + "email": format!("{id}@example.com"), + "planLabel": null, "active": false, "switchable": true, + })) + .unwrap(); + row.sign_in_state = state; + row + } + let list = roboco_proto::ResumeAccountCandidates { + chat_id: "chat-9".into(), + harness: roboco_proto::HarnessId::ClaudeCode, + limited_account_id: Some("slot-alice".into()), + candidates: vec![ + account("slot-carol", roboco_proto::AgentSignInState::SignedIn), + account("slot-bob", roboco_proto::AgentSignInState::SignedIn), + ], + signed_out: vec![ + account("slot-dave", roboco_proto::AgentSignInState::SignedOut), + account("slot-erin", roboco_proto::AgentSignInState::SignedOut), + ], + }; + let rows = failover_rows(&list); + assert_eq!(rows.len(), 4); + // Candidates first, rank order, the best one recommended. + assert!(matches!( + &rows[0], + FailoverRow::Candidate { account, recommended } + if account.id == "slot-carol" && *recommended + )); + assert!(matches!( + &rows[1], + FailoverRow::Candidate { account, recommended } + if account.id == "slot-bob" && !*recommended + )); + // The signed-out accounts follow — never candidates, never the + // limited account itself. + assert!(matches!( + &rows[2], + FailoverRow::SignedOut { account } if account.id == "slot-dave" + )); + assert!(matches!( + &rows[3], + FailoverRow::SignedOut { account } if account.id == "slot-erin" + )); + // A list offering nothing renders nothing. + let empty = roboco_proto::ResumeAccountCandidates { + candidates: Vec::new(), + signed_out: Vec::new(), + ..list + }; + assert!(failover_rows(&empty).is_empty()); + } + /// The failover outcome lines (ticket 09, checklist 6): the switch names /// the account it resumed on, no-candidates explains itself, a failure /// surfaces its reason verbatim (the failure chip prefixes it). diff --git a/web/packages/proto/src/generated/ResumeAccountCandidates.ts b/web/packages/proto/src/generated/ResumeAccountCandidates.ts index c496fbf259..96d98dd97a 100644 --- a/web/packages/proto/src/generated/ResumeAccountCandidates.ts +++ b/web/packages/proto/src/generated/ResumeAccountCandidates.ts @@ -11,9 +11,9 @@ import type { HarnessId } from "./HarnessId"; * switchable, not an API key, not signed out, not still limited, and not * the account that hit the limit; lowest usage first, accounts sharing the * limited account's org or workspace ranked lower. The first entry is the - * recommended one. Signed-out accounts are absent (they offer "Sign in - * again" in the account switcher instead); an empty list leaves only - * Resume at reset. + * recommended one. Signed-out accounts ride `signed_out` — the picker + * lists them with a "Sign in again" action instead of a switch (stories + * 47/50). Both lists empty leaves only Resume (and Resume at reset). */ export type ResumeAccountCandidates = { chatId: string, harness: HarnessId, /** @@ -26,4 +26,11 @@ limitedAccountId?: string | null, * The candidate rows in rank order, best first. Usage windows and * limited-until ride the rows themselves. */ -candidates: Array<AgentAccount>, }; +candidates: Array<AgentAccount>, +/** + * The harness's OTHER accounts that are signed out (ticket 08's mark): + * never switch targets, but the picker lists them with a "Sign in + * again" action (the existing login flow) so a dead login can be + * revived in place — stories 47 and 50. Additive + serde-defaulted. + */ +signedOut?: Array<AgentAccount>, }; From 8bd1c9b2653a5bb844c26098b71cf36a27ed09b4 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 00:51:13 +0700 Subject: [PATCH 42/96] fix(engine): never replay a consumed steer in a boot auto-resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's orchestrator chat (ff441279) acknowledged the same mid-run steer twice after an engine restart: boot recovery re-dispatches the transcript's last user message as the revival prompt, and for a steer the harness had already consumed that re-delivers it — the model sees the instruction again and answers it again. Cause: consumption state lived only in the in-memory routed-steer ledger (retired at the Steered boundary), so nothing at boot could tell a consumed steer from one still sitting in a dead mailbox. Fix: the Steered boundary now records the consumed message id durably beside the run journal ({chat}.steers, like the .resume attempts ledger) before the boundary is even journaled; a cleanly completed turn clears it. recover_stale consults it: a consumed steer is never re-delivered — the revival continues the crashed turn with the engine's continue prompt under a deterministic id (its structured origin rides along, so a parent's revived turn still notifies when it ends). Everything else about the revival — budget, freshness window, staggering, the resuming note, the resumed harness session — is unchanged; an accepted-but-unconsumed steer is still delivered, exactly once. Ticket: t3-orchestration-v2 #04 (spec story 68). --- crates/engine/src/run_journal.rs | 93 +++++- crates/engine/src/sessions.rs | 55 +++- crates/engine/tests/restart_resume.rs | 431 +++++++++++++++++++++++++- 3 files changed, 568 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/run_journal.rs b/crates/engine/src/run_journal.rs index 3a9b89cad4..faf9f8cc34 100644 --- a/crates/engine/src/run_journal.rs +++ b/crates/engine/src/run_journal.rs @@ -96,6 +96,50 @@ impl RunJournal { let _ = std::fs::remove_file(self.attempts_path(chat_id)); } + /// Durable record that the harness CONFIRMED consuming a steer (the + /// `Steered` boundary that retires its at-least-once ledger entry). + /// Boot auto-resume reads it so a chat revived after an engine restart + /// re-delivers only input its harness had not yet consumed: a steer + /// replayed as the revival prompt is a message the model answers twice + /// (orchestration v2, ticket 04 — restart correctness). + /// + /// One message id per line, bounded by the current turn (a cleanly + /// completed turn clears it, like the resume budget). A torn trailing + /// write can only ever read as some OTHER id, never a full match, so it + /// is tolerated like a torn journal line. + pub fn note_steer_consumed(&self, chat_id: &str, message_id: &str) { + let path = self.steers_path(chat_id); + let result = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| file.write_all(format!("{message_id}\n").as_bytes())); + if let Err(err) = result { + tracing::warn!(chat = %chat_id, error = %err, "consumed-steer record write failed"); + } + } + + /// Whether the harness had confirmed consuming `message_id` as a steer + /// before it was recorded — the read a boot auto-resume consults to tell + /// a consumed steer (never re-delivered) from an accepted-but-undelivered + /// one (still delivered after the resume). + pub fn steer_consumed(&self, chat_id: &str, message_id: &str) -> bool { + std::fs::read_to_string(self.steers_path(chat_id)) + .map(|lines| lines.lines().any(|line| line.trim() == message_id)) + .unwrap_or(false) + } + + /// A cleanly completed turn closes the consumed-steer record: everything + /// the harness consumed before it is settled history no revival ever + /// re-examines, so the file stays bounded by one turn's steers. + pub fn clear_consumed_steers(&self, chat_id: &str) { + let _ = std::fs::remove_file(self.steers_path(chat_id)); + } + + fn steers_path(&self, chat_id: &str) -> PathBuf { + self.dir.join(format!("{}.steers", sanitize_id(chat_id))) + } + /// Append one event; returns its journal seq. pub fn append(&self, chat_id: &str, event: &AgentEvent) -> Result<u64, JournalError> { let mut files = self.lock(); @@ -206,6 +250,9 @@ impl RunJournal { if path.exists() { std::fs::remove_file(path)?; } + // The chat's own recovery sidecars go with its journal. + let _ = std::fs::remove_file(self.attempts_path(chat_id)); + let _ = std::fs::remove_file(self.steers_path(chat_id)); Ok(()) } } @@ -387,7 +434,9 @@ impl Iterator for RevEvents { /// Journal (`.jsonl`) and resume-budget (`.resume`) paths for `chat_id` under an /// arbitrary journals directory — profile import copies these files between -/// profiles without opening a `RunJournal`. +/// profiles without opening a `RunJournal`. The consumed-steer record +/// (`.steers`, restart correctness) belongs in that copy too: a journal +/// imported without it replays a steer its harness had already consumed. pub fn journal_paths(dir: &Path, chat_id: &str) -> (PathBuf, PathBuf) { let stem = sanitize_id(chat_id); ( @@ -597,6 +646,48 @@ mod tests { assert_eq!(scan_tail(&missing).unwrap(), (1, false)); } + #[test] + fn consumed_steer_records_survive_and_clear_per_turn() { + let dir = tempfile::tempdir().unwrap(); + let journal = RunJournal::open(dir.path()).unwrap(); + assert!(!journal.steer_consumed("chat-1", "m-1"), "no record yet"); + journal.note_steer_consumed("chat-1", "m-1"); + journal.note_steer_consumed("chat-1", "m-2"); + assert!(journal.steer_consumed("chat-1", "m-1")); + assert!(journal.steer_consumed("chat-1", "m-2")); + assert!(!journal.steer_consumed("chat-1", "m-3")); + // Per-chat files, like the journal itself. + assert!(!journal.steer_consumed("chat-2", "m-1")); + // A cleanly completed turn closes the record (bounded by one turn). + journal.clear_consumed_steers("chat-1"); + assert!(!journal.steer_consumed("chat-1", "m-1")); + assert!(!journal.steer_consumed("chat-1", "m-2")); + // The record survives a reopen — the boot auto-resume read. + journal.note_steer_consumed("chat-1", "m-1"); + drop(journal); + let journal = RunJournal::open(dir.path()).unwrap(); + assert!(journal.steer_consumed("chat-1", "m-1")); + } + + #[test] + fn a_torn_consumed_steer_line_never_reads_as_a_match() { + let dir = tempfile::tempdir().unwrap(); + let journal = RunJournal::open(dir.path()).unwrap(); + journal.note_steer_consumed("chat-1", "m-full-id"); + // Crash mid-write of a second record: a truncated id is not the id — + // only a fully written id (even without its newline) can match. + let mut file = OpenOptions::new() + .append(true) + .open(dir.path().join("chat-1.steers")) + .unwrap(); + file.write_all(b"m-full-id-trunc").unwrap(); + drop(file); + let journal = RunJournal::open(dir.path()).unwrap(); + assert!(journal.steer_consumed("chat-1", "m-full-id")); + assert!(!journal.steer_consumed("chat-1", "m-full-id-trunc")); + assert!(!journal.steer_consumed("chat-1", "m-full")); + } + #[test] fn stale_sessions_unchanged_by_tail_reads() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 4274629dcc..3ab76adb86 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -1363,9 +1363,7 @@ impl SessionsEngine { self.inner .remember_harness_session(&chat_id, &session_id, &cwd); } - // The revival prompt: the last user message (idempotent re-dispatch - // under the SAME id — `write_user_message` dedupes by id, so the - // transcript never shows a duplicate). Its structured origin + // The revival prompt: the last user message. Its structured origin // rides along, so a parent-started turn that is revived stays // notifiable for its completion notice (orchestration v2). let prompt = handle.doc().read_entries().ok().and_then(|entries| { @@ -1384,6 +1382,33 @@ impl SessionsEngine { }) }) }); + // Restart correctness (orchestration v2, ticket 04): a steer the + // harness had already confirmed consuming before the crash is + // NOT re-delivered — the model answered it once, and a replay + // makes it answer twice (the user's orchestrator chat + // acknowledged the same steer again after a restart). The durable + // consumed-steer record tells; the revival then continues the + // crashed turn with the engine's continue prompt — the same one + // Resume and Resume-at-reset send — under a deterministic id + // (`write_user_message` dedupes by id, so a crash between the + // revival's dispatch and its persistence re-lands as one entry). + // Everything else about the revival is unchanged: budget, + // staggering, the "— resuming" note, the resumed harness session, + // and the consumed steer's own structured origin, so a parent's + // revived turn still notifies when it ends. + let steer_consumed = prompt.as_ref().is_some_and(|(user_id, _, _)| { + self.inner.journal.steer_consumed(&chat_id, user_id) + }); + let prompt = match prompt { + Some((user_id, _text, origin)) if steer_consumed => Some(( + format!("auto-resume-continue:{chat_id}:{user_id}"), + roboco_proto::RESUME_CONTINUE_PROMPT.to_string(), + origin, + )), + // Idempotent re-dispatch under the SAME id — `write_user_message` + // dedupes by id, so the transcript never shows a duplicate. + other => other, + }; let attempts = self.inner.journal.resume_attempts(&chat_id); let fresh = handle .doc() @@ -3988,6 +4013,20 @@ async fn drive_run( .. } = &event { + // The boundary confirms delivery of the oldest accepted steer — + // retire its at-least-once ledger entry FIRST, and record the + // consumption durably before the boundary is even journaled: the + // harness has already consumed this input, so a crash from here + // on must leave boot auto-resume able to tell — or the revival + // would replay the steer and the model would answer it twice + // (restart correctness, orchestration v2 ticket 04). + let confirmed = lock(&inner.runs) + .get(&chat_id) + .filter(|h| h.run_id == run_id) + .and_then(|h| lock(&h.routed_steers).pop_front()); + if let Some(steer) = &confirmed { + inner.journal.note_steer_consumed(&chat_id, &steer.message_id); + } inner.publish(&chat_id, &event); // A steer boundary means a real prompt owns the turn again — its // Done will come; the short self-continued window stands down. @@ -4017,12 +4056,6 @@ async fn drive_run( // steer boundary restarts it (matches the parked-resume path and // the composer's optimistic overlay, which already reads 0:00). inner.set_status(&chat_id, SessionStatus::Working, true); - // The boundary confirms delivery of the oldest accepted steer — - // retire its at-least-once ledger entry. - let confirmed = lock(&inner.runs) - .get(&chat_id) - .filter(|h| h.run_id == run_id) - .and_then(|h| lock(&h.routed_steers).pop_front()); // Consumed: a steer carrying the fork history delivered it to // this runtime's provider session. if confirmed.is_some_and(|steer| steer.fork_history) @@ -4161,6 +4194,10 @@ async fn drive_run( // A cleanly completed turn resets the auto-resume revival // budget: only consecutive crash-revive-crash cycles spend it. inner.journal.clear_resume_attempts(&chat_id); + // …and closes the consumed-steer record with it: the record + // only ever speaks for the turn a crash interrupts, so it + // stays bounded by one turn's steers. + inner.journal.clear_consumed_steers(&chat_id); } // Exchange completed on an untitled chat → name it (fire-and-forget; // interrupted/errored turns never trigger naming). diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index 919977fa96..969fb42c8f 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -11,7 +11,11 @@ //! - a startup crash retries once with the resume kept, and a helper that is //! down hard never tombstones the stored session id; //! - a steer with no live run after a restart dispatches as a new turn that -//! still resumes the prior conversation. +//! still resumes the prior conversation; +//! - a chat auto-resumed at boot re-delivers only input its harness had not +//! yet consumed — a consumed steer never replays (it revives the turn with +//! the continue prompt instead), while an accepted-but-undelivered steer +//! is still delivered, exactly once. use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -1178,3 +1182,428 @@ async fn a_crash_recovers_the_conversation_through_a_symlinked_folder() { ); core.shutdown().await; } + +// ── Restart correctness: no steer replay after a restart (ticket 04) ──────── +// +// The user's incident (orchestrator chat ff441279, 2026-10-15): a parent +// steered the working orchestrator mid-run, the harness consumed the steer, +// the engine restarted, and boot auto-resume re-sent the steer as the +// revival prompt — the model saw the same instruction twice and answered it +// twice ("Acknowledged again (my session restarted)"). A chat auto-resumed +// at boot must re-deliver only input its harness had not yet consumed. + +/// The steer the orchestrator's parent sent mid-run. +const STEER_TEXT: &str = "actually, use glm-5.3 everywhere"; + +/// Manufacture the on-disk state a kill -9 leaves behind when a run was +/// steered mid-turn: the starting message and its partial reply, the steer +/// (its user entry was written at acceptance), and — when `consumed` — the +/// reply that streamed after the harness confirmed consuming the steer. +/// +/// `consumed = true` also journals the `Steered` boundary and records the +/// steer's consumption durably, exactly the way the live engine does when +/// the harness confirms delivery: that record is what lets boot recovery +/// tell a consumed steer from one still sitting in a dead mailbox. +fn manufacture_crashed_steer_turn(dir: &std::path::Path, consumed: bool) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + fn entry( + id: &str, + role: MessageRole, + text: &str, + created_at: i64, + status: Option<MessageStatus>, + ) -> SessionMessageEntry { + SessionMessageEntry { + origin_chat_id: None, + id: id.into(), + role, + parts: vec![MessagePart::Text { + id: "t0".into(), + text: text.into(), + }], + created_at, + device_id: "dev-crash".into(), + status, + continuation_of: None, + provider_turn: None, + duration_ms: None, + } + }; + { + let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); + let doc = SessionDoc::init(CHAT).unwrap(); + doc.push_message(entry( + "msg-user-1", + MessageRole::User, + "long task", + now - 60_000, + Some(MessageStatus::Complete), + )) + .unwrap(); + doc.push_message(entry( + "msg-assistant-1", + MessageRole::Assistant, + "partial…", + now - 30_000, + Some(MessageStatus::Streaming), + )) + .unwrap(); + doc.push_message(entry( + "msg-user-2", + MessageRole::User, + STEER_TEXT, + now - 20_000, + Some(MessageStatus::Complete), + )) + .unwrap(); + if consumed { + // The harness consumed the steer and the model kept working — + // the reply after the steer is what was streaming at the kill. + doc.push_message(entry( + "msg-assistant-2", + MessageRole::Assistant, + "after the steer…", + now - 10_000, + Some(MessageStatus::Streaming), + )) + .unwrap(); + } + store + .save_snapshot(CHAT, &doc.export_snapshot().unwrap()) + .unwrap(); + + let journal = RunJournal::open(dir.join("orgs/dev-org/dev-user/journals")).unwrap(); + journal + .append( + CHAT, + &AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "hs-crash".into(), + assistant_message_id: "msg-assistant-1".into(), + }, + ) + .unwrap(); + journal + .append( + CHAT, + &AgentEvent::TextDelta { + text: "partial…".into(), + }, + ) + .unwrap(); + if consumed { + journal + .append( + CHAT, + &AgentEvent::Steered { + assistant_message_id: Some("msg-assistant-1".into()), + next_assistant_message_id: Some("msg-assistant-2".into()), + }, + ) + .unwrap(); + journal + .append( + CHAT, + &AgentEvent::TextDelta { + text: "after the steer…".into(), + }, + ) + .unwrap(); + // The durable consumed-steer record the boundary writes — one + // message id per line, written directly (see + // `consumed_steers_path`) so this state compiles on the pre-fix + // engine too. + std::fs::write(consumed_steers_path(dir), "msg-user-2\n").unwrap(); + } + } +} + +/// The durable consumed-steer record's file, as the engine lays it out +/// beside the chat's journal. The tests write and read it directly (not +/// through `RunJournal`) so the reproduction below also compiles — and goes +/// red — against the pre-fix engine, which has no record at all and re-sends +/// the consumed steer as its revival prompt. +fn consumed_steers_path(dir: &std::path::Path) -> std::path::PathBuf { + dir.join("orgs/dev-org/dev-user/journals") + .join(format!("{CHAT}.steers")) +} + +fn user_texts(core: &EngineCore) -> Vec<String> { + entries_now(core) + .iter() + .filter(|e| e.role == MessageRole::User) + .filter_map(|e| { + e.parts.iter().find_map(|p| match p { + MessagePart::Text { text, .. } => Some(text.clone()), + _ => None, + }) + }) + .collect() +} + +/// The replay, reproduced: the steer was consumed before the kill, so the +/// boot auto-resume must NOT re-send it — the revival continues the crashed +/// turn with the engine's continue prompt instead, on the same harness +/// conversation, with every other revival behaviour intact. +#[tokio::test] +async fn an_auto_resumed_chat_never_replays_a_consumed_steer() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("device-id"), "dev-crash").unwrap(); + manufacture_crashed_steer_turn(&dir, true); + + let requests: RequestLog = Arc::new(Mutex::new(Vec::new())); + let core = assemble( + &dir, + RecordingHarness { + requests: requests.clone(), + session_id: "hs-after-crash".into(), + fail_starts: Default::default(), + }, + ); + pre_title(&core); + + wait_for( + || complete_assistant_count(&core) == 1, + "auto-resumed turn to complete", + ) + .await; + + // THE fix: the consumed steer never reaches the harness again. + let recorded = requests.lock().unwrap().clone(); + assert!( + !recorded.iter().any(|r| r.prompt == STEER_TEXT), + "a steer the harness already consumed must not be re-delivered: {recorded:#?}" + ); + // The revival continues the crashed turn with the engine's continue + // prompt, on the crashed run's own harness conversation. + let revived = recorded + .iter() + .find(|r| r.prompt == roboco_proto::RESUME_CONTINUE_PROMPT) + .expect("the revival continues the turn with the continue prompt"); + assert_eq!(revived.resume.as_deref(), Some("hs-crash")); + + // The transcript: the steer appears exactly once, and the engine's + // continue prompt is its own entry — never a duplicate of the steer. + assert_eq!( + user_texts(&core), + vec![ + "long task".to_string(), + STEER_TEXT.to_string(), + roboco_proto::RESUME_CONTINUE_PROMPT.to_string(), + ] + ); + // The crash note still says the run is being picked back up. + let entries = entries_now(&core); + let aborted = entries + .iter() + .find(|e| e.status == Some(MessageStatus::Aborted)) + .expect("crashed entries stay, stamped aborted"); + assert!(aborted.parts.iter().any(|p| matches!( + p, + MessagePart::Error { message, .. } + if message.contains("engine restart") && message.contains("resuming") + ))); + core.shutdown().await; +} + +/// A steer that was accepted but NOT yet consumed when the engine died is +/// still delivered after the resume — exactly once, under its own message +/// id, so the transcript never shows a duplicate. +#[tokio::test] +async fn a_steer_accepted_but_not_consumed_is_delivered_once_after_resume() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("device-id"), "dev-crash").unwrap(); + // No `Steered` boundary, no consumption record: the steer sat accepted + // in the dead run's mailbox and the harness never saw it. + manufacture_crashed_steer_turn(&dir, false); + + let requests: RequestLog = Arc::new(Mutex::new(Vec::new())); + let core = assemble( + &dir, + RecordingHarness { + requests: requests.clone(), + session_id: "hs-after-crash".into(), + fail_starts: Default::default(), + }, + ); + pre_title(&core); + + wait_for( + || complete_assistant_count(&core) == 1, + "the steer's turn to be delivered and complete", + ) + .await; + + // The steer IS the revival prompt — delivered exactly once, on the + // crashed run's harness conversation. + let recorded = requests.lock().unwrap().clone(); + let deliveries = recorded.iter().filter(|r| r.prompt == STEER_TEXT).count(); + assert_eq!( + deliveries, 1, + "an accepted-but-unconsumed steer is delivered exactly once: {recorded:#?}" + ); + let revived = recorded + .iter() + .find(|r| r.prompt == STEER_TEXT) + .expect("the steer's delivery"); + assert_eq!(revived.resume.as_deref(), Some("hs-crash")); + // No duplicate user entry, and no engine continue prompt this time. + assert_eq!( + user_texts(&core), + vec!["long task".to_string(), STEER_TEXT.to_string()] + ); + core.shutdown().await; +} + +/// A steerable harness that keeps turns in flight: the first prompt parks +/// without a Done, and a steer into it confirms consumption (its `Steered` +/// boundary) and keeps working — the exact shape a crash interrupts. A +/// steer containing `release` finishes the turn instead. +struct HoldingHarness; + +#[async_trait] +impl Harness for HoldingHarness { + fn id(&self) -> HarnessId { + HarnessId::Mock + } + fn display_name(&self) -> &str { + "Holding" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::StepBoundary + } + fn deterministic_turn_end(&self) -> bool { + true + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[ReasoningLevel::Medium] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + mut controls: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + let (tx, rx) = tokio::sync::mpsc::channel::<Result<AgentEvent, HarnessError>>(32); + let mut steering = controls.steering; + tokio::spawn(async move { + let send = |event: AgentEvent| { + let tx = tx.clone(); + async move { tx.send(Ok(event)).await.is_ok() } + }; + if !send(AgentEvent::SessionStarted { + harness: HarnessId::Mock, + model: "mock-1".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "hs-holding".into(), + assistant_message_id: "a-1".into(), + }) + .await + { + return; + } + if !send(AgentEvent::TextDelta { + text: format!("on it: {}", request.prompt), + }) + .await + { + return; + } + // Holding: no Done — the turn stays in flight (the crash shape). + while let Some(steer) = steering.recv().await { + let boundary = AgentEvent::Steered { + assistant_message_id: None, + next_assistant_message_id: steer.message_id.map(|id| format!("a-{id}")), + }; + if !send(boundary).await + || !send(AgentEvent::TextDelta { text: format!("on it: {}", steer.prompt) }).await + { + return; + } + if steer.prompt.contains("release") + && !send(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("hs-holding".into()), + }) + .await + { + return; + } + } + }); + Ok(futures::stream::unfold( + rx, + |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) }, + ) + .boxed()) + } +} + +/// The write side of the fix, live: a steer the harness confirms consuming +/// mid-turn is recorded durably right at the boundary — while the engine is +/// still running — so the boot path the tests above exercise can tell. This +/// is the record `an_auto_resumed_chat_never_replays_a_consumed_steer` +/// manufactures. +#[tokio::test] +async fn a_consumed_steer_is_recorded_durably_while_the_engine_runs() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("data"); + std::fs::create_dir_all(&dir).unwrap(); + + let registry = HarnessRegistry::new(); + registry.register(Arc::new(HoldingHarness)); + let core = EngineCore::assemble(&dir, Arc::new(registry), HarnessId::Mock) + .expect("engine core assembles"); + pre_title(&core); + + queue_run(&core, "hold the line", "/tmp", "msg-user-1"); + wait_for(|| entries_now(&core).len() == 2, "the holding turn to stream").await; + + // The steer: accepted into the live run's mailbox, then confirmed + // consumed by the harness's `Steered` boundary. + core.doc_host + .queue_command( + CHAT, + SessionCommandPayload::Steer { + prompt: "hold the detail too".into(), + message_id: Some("msg-user-2".into()), + }, + ) + .expect("queue steer command"); + + // Read the record the engine writes, in the engine's own layout. + let steers = consumed_steers_path(&dir); + wait_for( + || { + std::fs::read_to_string(&steers) + .is_ok_and(|record| record.lines().any(|line| line.trim() == "msg-user-2")) + }, + "the consumed steer to be recorded durably", + ) + .await; + + // The steer is on the transcript once, delivered into the live turn. + assert_eq!( + user_texts(&core), + vec!["hold the line".to_string(), "hold the detail too".to_string()] + ); + core.shutdown().await; +} From fdb637ba0fc5a2f4ce945af250cd856e757ba4ae Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 01:19:26 +0700 Subject: [PATCH 43/96] Completion notices: acknowledgement, stop, and the loop cap (ticket 14) Acknowledgement: an outcome is dropped when the parent observed it after it happened - through wait_for_turn, send_message with wait, read_chat or get_chat. The MCP server reports such observations to the engine for its calling chat (new engine-local AckSideChat RPC, additive). Each side chat carries a monotonic settle serial stamped before its settled status publishes, so an observation covers every outcome at or below the serial it observed - including a record that lands right after the report raced it - and never one for a later turn. Stop and interrupt (T3's interrupt behaviour): stopping or interrupting the parent drops the notices owed to it (ledger rows and the loop cap's parked queue rows) and suppresses outcomes from side chats whose notifiable turns were running at that moment, by clearing their notifiable claim. Side chats keep running; Stop does not cascade. A later message from the parent to a side chat re-arms that chat's next turn. Loop cap: at most 25 consecutive parent turns may be started by notices without a user message in between. Beyond it the notice parks as a visible, paused queue row on the parent and waits for the user: a user-authored message resets the count and thaws the queue, and the row then runs as the parent's next turn. The counter survives engine restarts in the completion-notice ledger (whose file shape gains a wrapping { pending, loops } object; ticket-13 ledgers still load). Engine tests cover each rule (observation drops the pending outcome and covers exactly the settles it saw; stop drops pending notices and the parked row while children keep running; the parent's message re-arms; the 26th notice parks until the user acts); MCP tool tests cover the observation report for every observing tool, and its absences (timed-out wait, human CLI). No builds or tests were run locally; CI on the PR is the signal. --- CONTEXT.md | 2 +- crates/engine/src/completion_notices.rs | 644 ++++++++++++++++++---- crates/engine/src/doc_host.rs | 14 + crates/engine/src/rpc.rs | 20 + crates/engine/src/sessions.rs | 113 +++- crates/engine/tests/completion_notices.rs | 557 ++++++++++++++++++- crates/roboco-mcp/src/roboco.rs | 13 + crates/roboco-mcp/src/tools.rs | 182 +++++- crates/rpc/src/lib.rs | 8 + docs/mcp.md | 7 + 10 files changed, 1442 insertions(+), 118 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 329232ecaa..fd69a336c1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -49,7 +49,7 @@ A chat with a parent chat: forked from it in the UI, opened blank next to it, or _Avoid_: Child chat, subagent (that is a harness-spawned agent inside a run) **Completion notice**: -The engine-authored system message that tells an agent-created side chat's creator how a turn it started ended: finished, failed (one-line reason), stopped, interrupted (lost to an engine restart without auto-resume), or waiting for input. One merged notice per delivery; it points the parent at `read_chat` rather than carrying the side chat's output. +The engine-authored system message that tells an agent-created side chat's creator how a turn it started ended: finished, failed (one-line reason), stopped, interrupted (lost to an engine restart without auto-resume), or waiting for input. One merged notice per delivery; it points the parent at `read_chat` rather than carrying the side chat's output. Outcomes the parent already observed (through `wait_for_turn`, a send with wait, `read_chat` or `get_chat`) owe no notice; stopping the parent drops the notices owed to it; at most 25 consecutive notice-started turns may run without a user message in between — past the cap, notices park in the parent's queue, paused, until the user acts. _Avoid_: Report, callback, acknowledgement **Chat status**: diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 6a2322de06..50e119ae2a 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -25,6 +25,28 @@ //! - **Archived chats are quiet both ways:** an archived side chat records //! nothing for its parent, and an archived parent is owed nothing. //! +//! Ticket 14's rules on top: +//! +//! - **Acknowledgement:** an outcome is dropped when the parent observed it +//! after it happened — through `wait_for_turn`, `send_message` with wait, +//! `read_chat` or `get_chat`. The MCP server reports such observations to +//! the engine ([`CompletionNotices::ack`]); each side chat carries a +//! monotonic settle serial advanced before its settled status publishes, +//! so an observation covers every outcome at or below the serial it +//! observed — including a record that lands right after the report raced +//! it — and never one for a later turn. +//! - **Stop and interrupt:** stopping or interrupting the parent drops the +//! notices owed to it and suppresses outcomes from side chats whose +//! notifiable turns were running at that moment ([`stop_parent`] — T3's +//! interrupt behaviour). Side chats keep running; Stop does not cascade. +//! A later message from the parent to a side chat re-arms its next turn +//! (`mark_notifiable` in `sessions.rs`). +//! - **Loop cap:** at most [`NOTICE_LOOP_CAP`] consecutive parent turns may +//! be started by notices without a user message in between. Beyond it, +//! the notice parks as a visible, PAUSED queue row on the parent and +//! waits for the user: a user-authored message resets the count and +//! thaws the queue, and the row then runs as the parent's next turn. +//! //! The notice message is a SYSTEM entry — never a user message — carrying a //! [`roboco_doc::MessagePart::CompletionNotice`] marker so clients can render //! it distinctly (ticket 15), with the same text as a plain part so older @@ -38,7 +60,8 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; use serde::{Deserialize, Serialize}; use roboco_doc::{ - MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, SessionMessageEntry, + MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, QueuedMessage, + SessionMessageEntry, }; use crate::doc_host::DocHost; @@ -54,6 +77,19 @@ pub(crate) const NOTICE_DETAIL_MAX: usize = 160; /// own interruption note ("Run interrupted by engine restart"). pub(crate) const INTERRUPTED_REASON: &str = "engine restart"; +/// At most this many consecutive parent turns may be started by notices +/// without a user message in between (ticket 14; the spec's default 25). +/// Beyond it a notice parks in the parent's queue, paused, until the user +/// acts — a runaway orchestration loop stops and waits for them. +pub(crate) const NOTICE_LOOP_CAP: u32 = 25; + +/// The id prefix the engine stamps on its own notice artifacts: the system +/// notice entry a delivery writes, and the paused queue row the loop cap +/// parks (ticket 14). The turn-start bookkeeping keys on it — a turn whose +/// starting message id carries it is notice-started, not user-started +/// (see [`CompletionNotices::notice_turn_started`]). +pub(crate) const NOTICE_ID_PREFIX: &str = "notice:"; + /// The engine's recorded turn-end state for one notifiable side-chat turn. #[derive(Debug, Clone, PartialEq)] pub(crate) enum Outcome { @@ -108,6 +144,14 @@ pub(crate) struct PendingOutcome { /// The side chat whose turn ended. pub(crate) side_chat_id: String, pub(crate) outcome: Outcome, + /// The side chat's settle serial this outcome belongs to (ticket 14): + /// the monotonic turn counter stamped by [`CompletionNotices::settle`] + /// at the turn's settle. An observation of the side chat covers + /// outcomes at or below its watermark. A reloaded (restarted) outcome + /// reads as 0, which any later observation covers — that outcome is for + /// a turn that settled before the restart, so a read after it has seen + /// it. + pub(crate) settle: u64, } /// One pending outcome as it rests on disk. The wire row's own shape @@ -138,15 +182,58 @@ impl From<StoredOutcome> for PendingOutcome { Self { side_chat_id: stored.side_chat_id, outcome: Outcome::from_row(stored.outcome, stored.detail), + // A restarted outcome's serial is unknown — 0 reads as "settled + // before the restart", which any later observation of the side + // chat covers (see `PendingOutcome::settle`). + settle: 0, } } } +/// The ledger on disk (ticket 14): the pending outcomes plus the loop-cap +/// counters, both of which must survive a restart. Written as +/// `{ pending, loops }`; a ticket-13 ledger (a bare map of parent → +/// outcome rows) still reads — its pending set loads, its loop counts are +/// empty. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredLedger { + #[serde(default)] + pending: HashMap<String, Vec<StoredOutcome>>, + /// parent → consecutive notice-started turns at the last write. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + loops: HashMap<String, u32>, +} + +/// The notice bookkeeping under one lock: pending outcomes (keyed by +/// parent), the observation watermarks (keyed by parent + side chat), and +/// the loop-cap counters (keyed by parent). One mutex makes every mutation +/// below atomic against a concurrent record, take or acknowledgement — +/// and each persists the whole ledger under the lock. +#[derive(Debug, Default)] +struct NoticeState { + pending: HashMap<String, Vec<PendingOutcome>>, + /// (parent, side chat) → the side chat's settle serial when the parent + /// last observed it (ticket 14): outcomes at or below the watermark owe + /// no notice, including a record that lands right after the observation. + acks: HashMap<(String, String), u64>, + /// parent → consecutive notice-started turns since the last + /// user-authored message (ticket 14's loop cap). + loops: HashMap<String, u32>, +} + /// Outcome records owed to parents, keyed by parent chat id. #[derive(Debug, Default)] pub(crate) struct CompletionNotices { - pending: Mutex<HashMap<String, Vec<PendingOutcome>>>, - /// Where `pending` is mirrored (ticket 13): an outcome is a result the + state: Mutex<NoticeState>, + /// side chat → turns settled so far (ticket 14): the monotonic serial + /// outcomes and observations compare on. In-memory by design — a + /// restart's boot recovery re-stamps serials for the outcomes it + /// records, and a lost watermark costs at most one redundant notice. + /// Never taken together with [`Self::state`] except by `ack`, which + /// takes this one first (the one ordering rule to keep deadlock-free). + settles: Mutex<HashMap<String, u64>>, + /// Where the ledger is mirrored (ticket 13): an outcome is a result the /// parent has not received yet, and losing one means a child's work /// silently never reaches its creator. `None` until a store path is /// wired — bare `SessionsEngine` tests run in-memory, like the @@ -156,18 +243,36 @@ pub(crate) struct CompletionNotices { impl CompletionNotices { pub(crate) fn record(&self, parent: &str, outcome: PendingOutcome) { - let mut pending = lock(&self.pending); - pending.entry(parent.to_string()).or_default().push(outcome); - self.persist(&pending); + let mut state = lock(&self.state); + // The parent observed this settle already (ticket 14): the + // acknowledgement raced the record — most tightly through + // `wait_for_turn`, which resolves off the same status flip that + // triggered the record. Dropped, not delivered. + if state + .acks + .get(&(parent.to_string(), outcome.side_chat_id.clone())) + .is_some_and(|watermark| outcome.settle <= *watermark) + { + return; + } + state + .pending + .entry(parent.to_string()) + .or_default() + .push(outcome); + self.persist(&state); } /// Take everything owed to `parent` for a delivery. `None` when the /// parent is owed nothing. pub(crate) fn take_for_delivery(&self, parent: &str) -> Option<Vec<PendingOutcome>> { - let mut pending = lock(&self.pending); - let outcomes = pending.remove(parent).filter(|outcomes| !outcomes.is_empty()); + let mut state = lock(&self.state); + let outcomes = state + .pending + .remove(parent) + .filter(|outcomes| !outcomes.is_empty()); if outcomes.is_some() { - self.persist(&pending); + self.persist(&state); } outcomes } @@ -179,26 +284,101 @@ impl CompletionNotices { if outcomes.is_empty() { return; } - let mut pending = lock(&self.pending); - let owed = pending.entry(parent.to_string()).or_default(); + let mut state = lock(&self.state); + let owed = state.pending.entry(parent.to_string()).or_default(); for outcome in outcomes.into_iter().rev() { owed.insert(0, outcome); } - self.persist(&pending); + self.persist(&state); } /// Archived and deleted parents are owed nothing (ticket 13): the /// pending records go, and the ledger forgets them. pub(crate) fn drop_for(&self, parent: &str) { - let mut pending = lock(&self.pending); - if pending.remove(parent).is_some() { - self.persist(&pending); + let mut state = lock(&self.state); + if state.pending.remove(parent).is_some() { + self.persist(&state); } } /// The parents owed outcomes right now — the boot delivery's iteration. pub(crate) fn parents(&self) -> Vec<String> { - lock(&self.pending).keys().cloned().collect() + lock(&self.state).pending.keys().cloned().collect() + } + + /// Advance and return the chat's settle serial: one per turn that came + /// to rest (completed, errored, stopped, or parked awaiting input). + /// Called from the run loop at each settle, BEFORE the settled status + /// publishes, so anything that observes the chat through that status + /// sees a serial at least as high as everything it observed. + pub(crate) fn settle(&self, chat_id: &str) -> u64 { + let mut settles = lock(&self.settles); + let serial = settles.entry(chat_id.to_string()).or_insert(0); + *serial += 1; + *serial + } + + /// The parent observed the side chat after its last outcome happened + /// (ticket 14) — through `wait_for_turn`, `send_message` with wait, + /// `read_chat` or `get_chat`; the MCP server reports it. Outcomes for + /// settles at or below the observed serial are dropped (they were + /// seen), and the watermark stands so a record that lands right after + /// this — the settle raced the report — is dropped too. Outcomes for + /// LATER turns owe their notice. Returns the watermark for the log. + pub(crate) fn ack(&self, parent: &str, side_chat: &str) -> u64 { + let watermark = lock(&self.settles).get(side_chat).copied().unwrap_or(0); + let mut state = lock(&self.state); + if let Some(owed) = state.pending.get_mut(parent) { + owed.retain(|outcome| { + !(outcome.side_chat_id == side_chat && outcome.settle <= watermark) + }); + if owed.is_empty() { + state.pending.remove(parent); + } + } + state + .acks + .insert((parent.to_string(), side_chat.to_string()), watermark); + self.persist(&state); + watermark + } + + /// A notice turn started for `parent` (ticket 14's loop cap): the + /// consecutive count of notice-started turns grows. Called from the + /// dispatch path whenever the starting message id carries the engine's + /// notice prefix — a delivered notice entry or a released parked row. + pub(crate) fn notice_turn_started(&self, parent: &str) { + let mut state = lock(&self.state); + *state.loops.entry(parent.to_string()).or_insert(0) += 1; + self.persist(&state); + } + + /// Whether `parent` hit the loop cap: the next notice must not start a + /// turn — it parks in the queue instead, until a user message arrives. + pub(crate) fn loop_cap_reached(&self, parent: &str) -> bool { + lock(&self.state) + .loops + .get(parent) + .copied() + .unwrap_or(0) + .ge(&NOTICE_LOOP_CAP) + } + + /// The parent's current consecutive notice-turn count — the cap's own + /// number, for logs and tests. + pub(crate) fn loop_turns(&self, parent: &str) -> u32 { + lock(&self.state).loops.get(parent).copied().unwrap_or(0) + } + + /// A user-authored message reached `parent`: the streak of consecutive + /// notice-started turns is broken, so the cap resets (ticket 14). A + /// Stop counts as user intervention for the same reason — see + /// [`stop_parent`]. + pub(crate) fn note_user_message(&self, parent: &str) { + let mut state = lock(&self.state); + if state.loops.remove(parent).is_some() { + self.persist(&state); + } } /// Wire the durable ledger and load what a previous engine run left: @@ -223,49 +403,85 @@ impl CompletionNotices { return; } }; - let stored: HashMap<String, Vec<StoredOutcome>> = match serde_json::from_slice(&bytes) { - Ok(stored) => stored, + let malformed = |err: serde_json::Error| { + tracing::warn!( + path = %path.display(), + error = %err, + "completion-notice ledger malformed; starting empty" + ); + }; + let value: serde_json::Value = match serde_json::from_slice(&bytes) { + Ok(value) => value, Err(err) => { - tracing::warn!( - path = %path.display(), - error = %err, - "completion-notice ledger malformed; starting empty" - ); + malformed(err); return; } }; - let mut pending = lock(&self.pending); - for (parent, rows) in stored { - pending + // Ticket 14 wrapped the ledger as `{ pending, loops }`; a ticket-13 + // ledger was a bare map of parent → outcome rows. Both load — the + // legacy shape's loop counts are simply empty (a fresh cap). + let stored: StoredLedger = if value.get("pending").is_some() { + match serde_json::from_value(value) { + Ok(stored) => stored, + Err(err) => { + malformed(err); + return; + } + } + } else { + match serde_json::from_value::<HashMap<String, Vec<StoredOutcome>>>(value) { + Ok(pending) => StoredLedger { + pending, + loops: HashMap::new(), + }, + Err(err) => { + malformed(err); + return; + } + } + }; + let mut state = lock(&self.state); + for (parent, rows) in stored.pending { + state + .pending .entry(parent) .or_default() .extend(rows.into_iter().map(PendingOutcome::from)); } + // A fresh engine's counters are the ledger's: the loop cap must not + // reset just because the engine restarted (outcomes survive, so a + // runaway loop would otherwise get 25 more turns per boot). + state.loops.extend(stored.loops); tracing::info!( - parents = pending.len(), + parents = state.pending.len(), "completion-notice ledger loaded" ); } - /// Mirror `pending` into the ledger. Called under the pending lock, so - /// a record and its write are atomic against other records — two - /// outcomes racing cannot write snapshots that miss each other. Synchronous - /// and best-effort: the map is small (one row per undelivered outcome), - /// writes are rare (a record, a take, a requeue), and a lost write - /// costs at most one notice. - fn persist(&self, pending: &HashMap<String, Vec<PendingOutcome>>) { + /// Mirror the notice state into the ledger. Called under the state + /// lock, so a mutation and its write are atomic against other + /// mutations — two outcomes racing cannot write snapshots that miss + /// each other. Synchronous and best-effort: the map is small (one row + /// per undelivered outcome plus one counter per parent), writes are + /// rare (a record, a take, a requeue, an acknowledgement, a counter + /// tick), and a lost write costs at most one notice. + fn persist(&self, state: &NoticeState) { let Some(path) = lock(&self.store).clone() else { return; }; - let stored: HashMap<&str, Vec<StoredOutcome>> = pending - .iter() - .map(|(parent, outcomes)| { - ( - parent.as_str(), - outcomes.iter().map(StoredOutcome::from).collect(), - ) - }) - .collect(); + let stored = StoredLedger { + pending: state + .pending + .iter() + .map(|(parent, outcomes)| { + ( + parent.clone(), + outcomes.iter().map(StoredOutcome::from).collect(), + ) + }) + .collect(), + loops: state.loops.clone(), + }; let result = (|| -> Result<(), std::io::Error> { let bytes = serde_json::to_vec(&stored) .map_err(|err| std::io::Error::other(err.to_string()))?; @@ -345,6 +561,7 @@ pub(crate) async fn record_and_deliver( engine: &SessionsEngine, side_chat: &str, parent: &str, + settle: u64, outcome: Outcome, ) { // Archived chats are quiet both ways (ticket 13): an archived side chat @@ -358,11 +575,55 @@ pub(crate) async fn record_and_deliver( PendingOutcome { side_chat_id: side_chat.to_string(), outcome, + settle, }, ); deliver(engine, parent).await; } +/// The stopped-parent rule (ticket 14, T3's interrupt behaviour): the +/// pending notices `parent` is owed go — a parent the user stopped must +/// not restart on its own — and side chats whose notifiable turns are +/// running right now stop owing outcomes for them: the claim is cleared, +/// so the record sites (which read it at settle time) record nothing when +/// those turns end. The side chats themselves keep running — Stop does +/// not cascade — and a later message from the parent to one of them +/// re-arms it for that chat's next turn (`mark_notifiable` in +/// `sessions.rs`). A Stop is user intervention for the loop cap too, so +/// the notice-turn streak resets and the cap's parked rows go with the +/// rest of the pending notices. +pub(crate) fn stop_parent(engine: &SessionsEngine, parent: &str) { + engine.notices().drop_for(parent); + engine.notices().note_user_message(parent); + engine.suppress_notifiable_runs(parent); + drop_parked_notices(engine, parent); +} + +/// Remove the loop cap's parked notice rows from the parent's queue (the +/// stopped-parent rule, ticket 14): a parked row IS a pending notice — the +/// visible form the cap left it waiting in — so a Stop drops it like any +/// other. The Stop's own queue pause keeps the drain away while the rows +/// go; a user's next message thaws an empty queue. +fn drop_parked_notices(engine: &SessionsEngine, parent: &str) { + let Some(host) = engine.host() else { + return; + }; + let Ok(handle) = host.open(parent) else { + return; + }; + let Ok(rows) = handle.doc().read_queue() else { + return; + }; + let parked: Vec<String> = rows + .into_iter() + .filter(|row| row.id.starts_with(NOTICE_ID_PREFIX)) + .map(|row| row.id) + .collect(); + for id in parked { + let _ = handle.doc().remove_queued(&id); + } +} + /// Deliver the parent's pending outcomes as one engine-authored notice that /// starts a parent turn. Called when an outcome lands — an idle parent is /// woken immediately — and whenever a parent's turn ends (ticket 13's @@ -397,6 +658,15 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { }; let sides: Vec<NoticeSide> = outcomes.iter().map(|o| side_row(&host, o)).collect(); let text = notice_text(&sides); + // The loop cap (ticket 14): the 26th consecutive notice-started turn + // does not start. The notice parks as a visible, PAUSED queue row on + // the parent and waits for the user — a user-authored message resets + // the count and thaws the queue, and the row then runs as the parent's + // next turn (counting as notice-started itself, via its id prefix). + if engine.notices().loop_cap_reached(parent) { + park_notice(engine, parent, &host, &text, outcomes); + return; + } // The notice turn runs on the parent's own configuration: its chat row // when there is one, else the last live request. `dispatch` re-derives // the harness session and stamps the Roboco MCP server (so the parent @@ -449,6 +719,57 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { } } +/// Park a capped notice as a visible, paused queue row on the parent (the +/// loop cap's waiting state, ticket 14). The queue pauses BEFORE the row +/// lands, so the doc-change drain finds it frozen and cannot send the row +/// straight out; a recovered queue starts paused too, so the row keeps +/// waiting across a restart. The row's id carries the notice prefix — the +/// turn it eventually starts still counts as notice-started — and its +/// text is the notice itself, so the queue shows exactly what waits. The +/// outcomes are consumed into the row (a durable carrier of its own: +/// queue rows live on the chat doc); only a row that cannot be written +/// puts them back into the pending set. +fn park_notice( + engine: &SessionsEngine, + parent: &str, + host: &DocHost, + text: &str, + outcomes: Vec<PendingOutcome>, +) { + let row = QueuedMessage { + id: format!("{NOTICE_ID_PREFIX}{}", new_id()), + text: text.to_owned(), + attachments: Vec::new(), + hold_for_turn_end: false, + issued_by: host.device_id().to_owned(), + issued_at: now_ms(), + edited_at: None, + delivery_gate: None, + // Engine-authored, not user-typed: the id prefix (not the origin) + // carries that for the loop-cap bookkeeping. + origin_chat_id: None, + }; + let parked = host.open(parent).ok().and_then(|handle| { + // Pause first: the row's own write wakes the drain, which must + // already see a frozen queue. + handle.pause_queue(); + handle.doc().push_queued(&row).ok() + }); + if parked.is_none() { + tracing::warn!( + parent, + "completion notice held: the capped notice row could not be parked" + ); + engine.notices().requeue(parent, outcomes); + return; + } + tracing::info!( + parent, + turns = engine.notices().loop_turns(parent), + "completion notice parked: notice loop cap reached" + ); +} + fn side_row(host: &DocHost, outcome: &PendingOutcome) -> NoticeSide { let title = host .workspace() @@ -526,7 +847,7 @@ fn short_id(id: &str) -> &str { /// layouts stay readable. fn notice_entry(device_id: &str, sides: &[NoticeSide], text: &str) -> SessionMessageEntry { SessionMessageEntry { - id: format!("notice:{}", new_id()), + id: format!("{NOTICE_ID_PREFIX}{}", new_id()), role: MessageRole::System, parts: vec![ MessagePart::CompletionNotice { @@ -564,6 +885,24 @@ mod tests { } } + /// A pending outcome for a turn settled before the engine booted + /// (the reloaded shape, settle 0). + fn pending(side_chat: &str, outcome: Outcome) -> PendingOutcome { + PendingOutcome { + side_chat_id: side_chat.into(), + outcome, + settle: 0, + } + } + + fn pending_at(side_chat: &str, settle: u64, outcome: Outcome) -> PendingOutcome { + PendingOutcome { + side_chat_id: side_chat.into(), + outcome, + settle, + } + } + #[test] fn one_line_takes_the_first_line_trimmed_and_capped() { assert_eq!(one_line(None), None); @@ -651,20 +990,8 @@ mod tests { fn pending_outcomes_record_take_and_requeue() { let notices = CompletionNotices::default(); assert!(notices.take_for_delivery("parent").is_none()); - notices.record( - "parent", - PendingOutcome { - side_chat_id: "chat-side-1".into(), - outcome: Outcome::Completed, - }, - ); - notices.record( - "parent", - PendingOutcome { - side_chat_id: "chat-side-2".into(), - outcome: Outcome::Stopped, - }, - ); + notices.record("parent", pending("chat-side-1", Outcome::Completed)); + notices.record("parent", pending("chat-side-2", Outcome::Stopped)); let taken = notices.take_for_delivery("parent").unwrap(); assert_eq!(taken.len(), 2); assert_eq!(taken[0].side_chat_id, "chat-side-1"); @@ -681,21 +1008,15 @@ mod tests { fn drop_for_forgets_a_parent_and_parents_lists_the_owed() { let notices = CompletionNotices::default(); assert!(notices.parents().is_empty()); - notices.record( - "parent-a", - PendingOutcome { - side_chat_id: "chat-side-1".into(), - outcome: Outcome::Completed, - }, - ); + notices.record("parent-a", pending("chat-side-1", Outcome::Completed)); notices.record( "parent-b", - PendingOutcome { - side_chat_id: "chat-side-2".into(), - outcome: Outcome::Interrupted { + pending( + "chat-side-2", + Outcome::Interrupted { reason: Some(INTERRUPTED_REASON.into()), }, - }, + ), ); let mut owed = notices.parents(); owed.sort(); @@ -714,53 +1035,41 @@ mod tests { let first = CompletionNotices::default(); // Before a store is wired, records are in-memory only. - first.record( - "parent", - PendingOutcome { - side_chat_id: "chat-side-ephemeral".into(), - outcome: Outcome::Completed, - }, - ); + first.record("parent", pending("chat-side-ephemeral", Outcome::Completed)); // Wiring the ledger on a fresh engine: no file, no outcomes. let second = CompletionNotices::default(); second.load(&path); assert!(second.take_for_delivery("parent").is_none()); + second.record("parent", pending("chat-side-1234", Outcome::Completed)); second.record( "parent", - PendingOutcome { - side_chat_id: "chat-side-1234".into(), - outcome: Outcome::Completed, - }, - ); - second.record( - "parent", - PendingOutcome { - side_chat_id: "chat-side-5678".into(), - outcome: Outcome::Errored { + pending( + "chat-side-5678", + Outcome::Errored { reason: Some("provider 502: bad gateway".into()), }, - }, + ), ); second.record( "other-parent", - PendingOutcome { - side_chat_id: "chat-side-90ab".into(), - outcome: Outcome::Interrupted { + pending( + "chat-side-90ab", + Outcome::Interrupted { reason: Some(INTERRUPTED_REASON.into()), }, - }, + ), ); // A requeue (a delivery that could not start) is as durable as a // record: the next engine still owes the outcomes. second.requeue( "parent", - vec![PendingOutcome { - side_chat_id: "chat-side-cdef".into(), - outcome: Outcome::WaitingForInput { + vec![pending( + "chat-side-cdef", + Outcome::WaitingForInput { summary: "Should we bump the major version?".into(), }, - }], + )], ); let reloaded = CompletionNotices::default(); @@ -776,10 +1085,7 @@ mod tests { // The requeued outcome keeps its place at the front. assert_eq!(parent.remove(0).side_chat_id, "chat-side-cdef"); assert_eq!(parent[0].side_chat_id, "chat-side-1234"); - assert_eq!( - parent[0].outcome, - Outcome::Completed - ); + assert_eq!(parent[0].outcome, Outcome::Completed); assert_eq!( parent[1].outcome, Outcome::Errored { @@ -808,13 +1114,7 @@ mod tests { notices.load(&path); assert!(notices.parents().is_empty()); // The wired ledger still accepts new records. - notices.record( - "parent", - PendingOutcome { - side_chat_id: "chat-side-1".into(), - outcome: Outcome::Completed, - }, - ); + notices.record("parent", pending("chat-side-1", Outcome::Completed)); assert_eq!(notices.parents(), vec!["parent".to_string()]); } @@ -863,4 +1163,136 @@ mod tests { } ); } + + // ---- ticket 14: acknowledgement, stop, loop cap ----------------------- + + #[test] + fn an_acknowledgement_drops_observed_outcomes_and_covers_a_racing_record() { + let notices = CompletionNotices::default(); + // Turn 1 settles: the parent is busy, so the outcome waits. + let turn_one = notices.settle("chat-side-1"); + notices.record("parent", pending_at("chat-side-1", turn_one, Outcome::Completed)); + assert_eq!(notices.parents(), vec!["parent".to_string()]); + + // The parent observed the chat after the turn ended (wait_for_turn, + // read_chat, get_chat — all arrive as this call): the outcome goes. + let watermark = notices.ack("parent", "chat-side-1"); + assert_eq!(watermark, turn_one); + assert!(notices.parents().is_empty()); + + // The report raced the record — the settle and the status flip it + // observed happened before the observation reached the engine, so a + // record for THAT settle lands after the ack: dropped too. + notices.record("parent", pending_at("chat-side-1", turn_one, Outcome::Completed)); + assert!(notices.parents().is_empty()); + + // A later turn is a different settle: it owes its notice. + let turn_two = notices.settle("chat-side-1"); + assert!(turn_two > turn_one); + notices.record("parent", pending_at("chat-side-1", turn_two, Outcome::Completed)); + let taken = notices.take_for_delivery("parent").unwrap(); + assert_eq!(taken.len(), 1); + assert_eq!(taken[0].settle, turn_two); + } + + #[test] + fn an_ack_covers_only_the_side_chat_it_observed() { + let notices = CompletionNotices::default(); + let settle = notices.settle("chat-side-a"); + notices.settle("chat-side-b"); + notices.record("parent", pending_at("chat-side-a", settle, Outcome::Completed)); + notices.record("parent", pending_at("chat-side-b", settle, Outcome::Completed)); + + // Observing one chat never silences another. + notices.ack("parent", "chat-side-a"); + let owed = notices.take_for_delivery("parent").unwrap(); + assert_eq!(owed.len(), 1); + assert_eq!(owed[0].side_chat_id, "chat-side-b"); + // And only for the parent that observed it. + notices.record("other-parent", pending_at("chat-side-a", settle, Outcome::Completed)); + notices.ack("parent", "chat-side-a"); + assert_eq!(notices.parents(), vec!["other-parent".to_string()]); + } + + #[test] + fn an_ack_of_an_unsettled_chat_reads_watermark_zero() { + let notices = CompletionNotices::default(); + // A side chat that never settled in this engine run: an outcome + // reloaded from a ledger (settle 0) is still covered — its turn + // settled before the restart, so a read after it has seen it. + assert_eq!(notices.ack("parent", "chat-side-1"), 0); + notices.record("parent", pending("chat-side-1", Outcome::Completed)); + assert!(notices.parents().is_empty()); + // But a fresh settle outruns that watermark. + let turn = notices.settle("chat-side-1"); + notices.record("parent", pending_at("chat-side-1", turn, Outcome::Completed)); + assert_eq!(notices.parents(), vec!["parent".to_string()]); + } + + #[test] + fn the_loop_cap_counts_notice_turns_until_a_user_message() { + let notices = CompletionNotices::default(); + assert!(!notices.loop_cap_reached("parent")); + for turn in 1..=NOTICE_LOOP_CAP { + notices.notice_turn_started("parent"); + assert_eq!(notices.loop_turns("parent"), turn); + } + assert!(notices.loop_cap_reached("parent")); + // One more does not panic and stays capped. + notices.notice_turn_started("parent"); + assert_eq!(notices.loop_turns("parent"), NOTICE_LOOP_CAP + 1); + + // A user message resets the streak; the cap lifts. + notices.note_user_message("parent"); + assert_eq!(notices.loop_turns("parent"), 0); + assert!(!notices.loop_cap_reached("parent")); + // Resetting a chat with no streak is a no-op. + notices.note_user_message("other-parent"); + assert_eq!(notices.loop_turns("other-parent"), 0); + + // Another chat's counter is independent. + for _ in 0..NOTICE_LOOP_CAP { + notices.notice_turn_started("other-parent"); + } + assert!(notices.loop_cap_reached("other-parent")); + assert!(!notices.loop_cap_reached("parent")); + } + + #[test] + fn the_loop_counts_survive_a_restart_and_a_legacy_ledger_still_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("completion-notices.json"); + + // Ticket 13's ledger: a bare parent → rows map, no counts. It loads, + // with an empty cap. + std::fs::write( + &path, + serde_json::json!({ + "chat-parent": [ + { "sideChatId": "chat-side-1", "outcome": "completed" } + ] + }) + .to_string(), + ) + .unwrap(); + let legacy = CompletionNotices::default(); + legacy.load(&path); + assert_eq!(legacy.parents(), vec!["chat-parent".to_string()]); + assert_eq!(legacy.loop_turns("chat-parent"), 0); + assert!(legacy.take_for_delivery("chat-parent").is_some()); + + // Ticket 14's ledger carries the counts alongside the pending set. + for _ in 0..NOTICE_LOOP_CAP { + legacy.notice_turn_started("chat-parent"); + } + let reloaded = CompletionNotices::default(); + reloaded.load(&path); + assert!(reloaded.loop_cap_reached("chat-parent")); + assert_eq!(reloaded.loop_turns("chat-parent"), NOTICE_LOOP_CAP); + // A user message still resets it after the reload. + reloaded.note_user_message("chat-parent"); + assert!(!reloaded.loop_cap_reached("chat-parent")); + // A count with no pending outcomes is not a parent. + assert!(reloaded.parents().is_empty()); + } } diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 9d696c7564..98a50a414b 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -1743,6 +1743,13 @@ impl DocHost { let Ok(Some(head)) = handle.doc.read_queue().map(|q| q.into_iter().next()) else { return; }; + // A pause armed while this drain sat between rows (a capped + // completion notice parks its row and freezes the queue in one + // step — ticket 14) holds the head too: nothing dispatches + // while paused, whenever the pause landed. + if handle.queue_paused.load(Ordering::Acquire) { + return; + } match &head.delivery_gate { Some(QueueDeliveryGate::Editing { lease_id, @@ -2773,6 +2780,13 @@ impl DocHost { self.await_earlier_prompts(sessions, handle, &entry.id) .await; *lock(&handle.stopped_by) = Some(entry.id.clone()); + // The user's Stop drops the notices this chat is owed as a + // parent and suppresses outcomes from side chats whose + // notifiable turns are running now (ticket 14). Stopping an + // idle chat still drops them — a stopped parent must not + // restart on its own — which `interrupt_and_pause_queue` + // would skip when there is nothing to stop. + crate::completion_notices::stop_parent(sessions, &handle.chat_id); // Sends before this Stop that are still on their way (a slow // dispatch the wait above gave up on) are cancelled with it. if let Ok(commands) = handle.doc.read_commands() diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 7f855d7d60..bcd4f8671e 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -275,6 +275,17 @@ struct QueueMessageParams { origin_chat_id: Option<String>, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AckSideChatParams { + /// The chat that was observed — the side chat. + chat_id: String, + /// The chat that observed it: the Roboco MCP server's calling chat + /// (an agent parent). Reported after `wait_for_turn`, a send with + /// wait, `read_chat` or `get_chat` (ticket 14). + origin_chat_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct QueuedMessageParams { @@ -1960,6 +1971,15 @@ impl RpcService for EngineRpc { .map_err(|e| RpcError::Failed(e.to_string()))?; RpcReply::value(&serde_json::json!({ "id": id })) } + methods::ACK_SIDE_CHAT => { + // An agent parent observed a chat after its last outcome + // happened (ticket 14, orchestration v2). Engine-local: + // the Roboco MCP server dials this engine's loopback, and + // both chats live here. + let p: AckSideChatParams = parse_params(params)?; + self.sessions.ack_notice(&p.origin_chat_id, &p.chat_id); + RpcReply::value(&serde_json::json!({})) + } methods::UPDATE_QUEUED_MESSAGE => { let p: QueuedMessageParams = parse_params(params)?; let changed = self diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index 4274629dcc..f636ac11aa 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -432,6 +432,52 @@ impl SessionsEngine { .load(std::sync::atomic::Ordering::Acquire) } + /// An observation report from the Roboco MCP server (ticket 14): the + /// chat `parent` — the server's calling chat — just observed `side_chat` + /// after its last outcome happened, through `wait_for_turn`, a send + /// with wait, `read_chat` or `get_chat`. Outcomes the parent already + /// observed owe no notice: they are dropped, and a record that lands + /// right after the report raced it is dropped too. + pub(crate) fn ack_notice(&self, parent: &str, side_chat: &str) { + let watermark = self.inner.notices.ack(parent, side_chat); + tracing::debug!( + parent, + side_chat, + watermark, + "completion-notice observation acknowledged" + ); + } + + /// Clear the notifiable-parent claim on every live run whose current + /// turn owes a notice to `parent` (crate-internal; ticket 14's + /// stopped-parent rule). The side chats keep running; their turns + /// simply stop owing outcomes — a later parent message re-arms the + /// claim through `mark_notifiable`. + pub(crate) fn suppress_notifiable_runs(&self, parent: &str) { + let runs = lock(&self.inner.runs); + for run in runs.values() { + let mut notifiable = lock(&run.notifiable); + if notifiable.as_deref() == Some(parent) { + *notifiable = None; + } + } + } + + /// Loop-cap bookkeeping for a message that just landed (ticket 14): a + /// turn whose starting message id carries the engine's notice prefix + /// counts as notice-started (the consecutive streak grows); a + /// user-authored message — no origin chat, not one of the engine's own + /// artifacts — breaks the streak and resets the count. A message from + /// another chat does neither: the cap is about notice loops, and it + /// waits for the user, not for third-party traffic. + fn note_loop_counter(&self, chat_id: &str, message_id: &str, origin_chat_id: Option<&str>) { + if message_id.starts_with(crate::completion_notices::NOTICE_ID_PREFIX) { + self.inner.notices.notice_turn_started(chat_id); + } else if origin_chat_id.is_none() { + self.inner.notices.note_user_message(chat_id); + } + } + /// The parent a message from `origin` makes a turn of `chat_id` /// notifiable for: only the chat the durable agent-origin row /// (`roboco_proto::AgentOrigin` on the chat) names, and only while it @@ -751,6 +797,10 @@ impl SessionsEngine { now_ms(), origin_chat_id.as_deref(), )?; + // The warm send still counts for the notice loop (ticket + // 14): a notice row routing into a live turn is + // notice-started; a user's message resets the streak. + self.note_loop_counter(chat_id, &user_id, origin_chat_id.as_deref()); // A turn counts from acceptance — a prompt deferred behind an // update must not snapshot a turn that has not started. self.note_turn_start(chat_id, &request.cwd); @@ -815,6 +865,11 @@ impl SessionsEngine { now_ms(), origin_chat_id.as_deref(), )?; + // Ticket 14's loop cap: a notice turn — the starting message is one + // of the engine's notice artifacts (a delivered notice entry or a + // released parked row) — grows the consecutive count; a + // user-authored message resets it. + self.note_loop_counter(chat_id, &user_id, origin_chat_id.as_deref()); // Engine-owned resume (roboco sessions.ts:736 — every dispatch read the // chat's stored harness session): callers always send `resume: None`; @@ -1086,6 +1141,12 @@ impl SessionsEngine { issued_at.min(now_ms()), origin_chat_id.as_deref(), )?; + // A steer is never one of the engine's notice artifacts: a user's + // steer breaks the notice-turn streak (ticket 14); a chat's steer + // leaves the cap alone. + if origin_chat_id.is_none() { + self.inner.notices.note_user_message(chat_id); + } // A routed steer is a turn too. Fired here (not only on the confirmed // path) — a reclaim falls back to dispatch, which just re-snapshots. if let Some(request) = self.last_request(chat_id) { @@ -1134,7 +1195,13 @@ impl SessionsEngine { /// shells, everything the agent runs. The next message resumes the /// conversation in a fresh process. (Sending, steering and switching /// models never come here: they keep the runtime and its work.) + /// + /// Stopping also drops the notices this chat is owed as a parent and + /// suppresses outcomes from side chats whose notifiable turns are + /// running now (ticket 14): a stopped parent must not restart on its + /// own. Side chats keep running — Stop does not cascade. pub async fn interrupt(&self, chat_id: &str) -> Result<bool, EngineError> { + crate::completion_notices::stop_parent(self, chat_id); self.terminate(chat_id).await } @@ -1417,6 +1484,10 @@ impl SessionsEngine { }; self.inner.publish(&chat_id, &done); let stamped = handle.mark_abandoned_streams(note)?.len(); + // Stamp the settle serial before the recovered chat reads idle + // (ticket 14): the interrupted outcome belongs to this settle, + // so an observation of the chat after boot covers it. + let settle = self.inner.notices.settle(&chat_id); self.set_status(&chat_id, SessionStatus::Idle, false); tracing::info!(chat = %chat_id, stamped, will_resume, attempts, "recovered stale session journal"); recovered += 1; @@ -1444,6 +1515,7 @@ impl SessionsEngine { &parent, crate::completion_notices::PendingOutcome { side_chat_id: chat_id.clone(), + settle, outcome: crate::completion_notices::Outcome::Interrupted { reason: Some( crate::completion_notices::INTERRUPTED_REASON.into(), @@ -2883,7 +2955,9 @@ fn run_notifiable_parent(inner: &Inner, chat_id: &str, run_id: &str) -> Option<S } /// Record a completion-notice outcome, fire-and-forget: the side chat's -/// event stream never waits on the parent's notice turn. Engine teardown +/// event stream never waits on the parent's notice turn. `settle` is the +/// side chat's settle serial stamped before its settled status published +/// (ticket 14) — the acknowledgement compares against it. Engine teardown /// records nothing — the runs it kills were not user-visible outcomes; /// a restart's lost runs are covered at boot (interrupted outcomes), /// where the crash state is durable enough to read. @@ -2891,6 +2965,7 @@ fn spawn_notice_record( inner: Arc<Inner>, side_chat: String, parent: String, + settle: u64, outcome: crate::completion_notices::Outcome, ) { if inner @@ -2901,7 +2976,7 @@ fn spawn_notice_record( } let engine = SessionsEngine { inner }; tokio::spawn(async move { - crate::completion_notices::record_and_deliver(&engine, &side_chat, &parent, outcome).await; + crate::completion_notices::record_and_deliver(&engine, &side_chat, &parent, settle, outcome).await; }); } @@ -2933,13 +3008,15 @@ fn spawn_notice_delivery(inner: &Arc<Inner>, chat_id: &str) { /// Record the outcome of a turn that settled INSIDE the run loop (a parked /// persistent session, the quiesce watchdog): only real turn ends record — /// an internal handoff (a pending steer or a sent message owns the -/// continuation) notifies nothing. +/// continuation) notifies nothing. `settle` is the settle serial the +/// caller stamped before the settled status published (ticket 14). fn record_parked_turn( inner: &Arc<Inner>, chat_id: &str, run_id: &str, completed: bool, stopped_in_place: bool, + settle: u64, ) { let outcome = if stopped_in_place { Some(crate::completion_notices::Outcome::Stopped) @@ -2951,7 +3028,7 @@ fn record_parked_turn( if let Some(outcome) = outcome && let Some(parent) = run_notifiable_parent(inner, chat_id, run_id) { - spawn_notice_record(inner.clone(), chat_id.to_string(), parent, outcome); + spawn_notice_record(inner.clone(), chat_id.to_string(), parent, settle, outcome); } } @@ -3176,11 +3253,14 @@ async fn drive_run( ); // Completion notices (orchestration v2): a notifiable turn that // never started is still a turn the parent is owed — it errored. + // The settle serial stamps before the errored status publishes + // (ticket 14): a wait resolving off that flip observes it. + let settle = inner.notices.settle(&chat_id); if let Some(parent) = run_notifiable_parent(&inner, &chat_id, &run_id) { let outcome = crate::completion_notices::Outcome::Errored { reason: crate::completion_notices::one_line(Some(message.as_str())), }; - spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); + spawn_notice_record(inner.clone(), chat_id.clone(), parent, settle, outcome); } inner.remove_run(&chat_id, &run_id); inner.set_status(&chat_id, SessionStatus::Errored, false); @@ -3562,6 +3642,7 @@ async fn drive_run( idle_since = Some(tokio::time::Instant::now()); reap_deferred_until = None; self_continued_turn = false; + let settle = inner.notices.settle(&chat_id); inner.set_status_with_completion( &chat_id, SessionStatus::Idle, false, completed_turn.clone(), ); @@ -3573,6 +3654,7 @@ async fn drive_run( &run_id, completed_turn.is_some(), false, + settle, ); // The watchdog-finalized turn is over: notices that // piled up while the chat worked start its next turn @@ -4066,11 +4148,17 @@ async fn drive_run( let was_awaiting = lock(&inner.statuses) .get(&chat_id) .is_some_and(|s| s.status == SessionStatus::AwaitingInput); + // Stamp the settle serial before the awaiting status + // publishes (ticket 14): a wait resolving at this flip — or + // a read after it — has observed the question this outcome + // reports. Only a NEW awaiting stretch settles; a twin + // question does not. + let settle = (!was_awaiting).then(|| inner.notices.settle(&chat_id)); inner.set_status(&chat_id, SessionStatus::AwaitingInput, false); // A notifiable turn reaching awaiting input owes its parent a // notice with a one-line summary of the question — once per // awaiting stretch, not per twin question. - if !was_awaiting + if let Some(settle) = settle && let Some(parent) = run_notifiable_parent(&inner, &chat_id, &run_id) { let summary = crate::completion_notices::question_summary(questions); @@ -4078,6 +4166,7 @@ async fn drive_run( inner.clone(), chat_id.clone(), parent, + settle, crate::completion_notices::Outcome::WaitingForInput { summary }, ); } @@ -4213,6 +4302,10 @@ async fn drive_run( idle_since = Some(tokio::time::Instant::now()); reap_deferred_until = None; self_continued_turn = false; + // The settle serial stamps before the idle status publishes + // (ticket 14): an observer of the parked turn's completion + // has observed this settle. + let settle = inner.notices.settle(&chat_id); inner.set_status_with_completion( &chat_id, SessionStatus::Idle, @@ -4228,6 +4321,7 @@ async fn drive_run( &run_id, completed_turn.is_some(), stopped_in_place, + settle, ); // The parked turn is over: notices that piled up while the // chat worked start its next turn (ticket 13). @@ -4299,8 +4393,11 @@ async fn drive_run( } // Completion notices (orchestration v2): the run's final turn ended — // read the notifiable parent before the handle goes away, then record - // the outcome the engine's own state observed. + // the outcome the engine's own state observed. The settle serial stamps + // before the final status publishes (ticket 14): a wait resolving off + // that flip — or a read after it — has observed this turn's outcome. let notifiable = run_notifiable_parent(&inner, &chat_id, &run_id); + let final_settle = inner.notices.settle(&chat_id); inner.remove_run(&chat_id, &run_id); let final_turn_completed = final_completed_turn.is_some(); let auto_arm_limit = limit.clone(); @@ -4319,7 +4416,7 @@ async fn drive_run( if let (Some(parent), Some((status, error))) = (notifiable, final_done) { let completed_turn = final_turn_completed; if let Some(outcome) = final_outcome(status, error.as_deref(), completed_turn) { - spawn_notice_record(inner.clone(), chat_id.clone(), parent, outcome); + spawn_notice_record(inner.clone(), chat_id.clone(), parent, final_settle, outcome); } } // A busy parent's notices waited — never steered mid-turn (ticket 13): diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index bd9778cf70..52ab200b27 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -455,13 +455,16 @@ async fn queue_user_steer( /// Rows the durable completion-notice ledger under this engine's data dir /// currently holds — the engine-local surface restart round-trips assert -/// on, like the run journals. +/// on, like the run journals. Ticket 14 wrapped the ledger as +/// `{ pending, loops }`; ticket 13's was a bare parent → rows map. Both +/// shapes read. fn ledger_rows(data_dir: &std::path::Path) -> usize { std::fs::read(data_dir.join("orgs/dev-org/dev-user/completion-notices.json")) .ok() .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok()) .and_then(|ledger| { - ledger.as_object().map(|parents| { + let parents = ledger.get("pending").cloned().unwrap_or(ledger); + parents.as_object().map(|parents| { parents .values() .map(|rows| rows.as_array().map_or(0, Vec::len)) @@ -1510,3 +1513,553 @@ async fn notices_owed_to_a_parent_archived_before_delivery_are_dropped_at_boot() core.shutdown().await; } + +// ---- ticket 14: acknowledgement, stop, loop cap --------------------------- + +/// Report an observation the way the Roboco MCP server does (ticket 14): +/// the parent — the server's calling chat — observed `side` after its last +/// outcome happened, through `wait_for_turn`, a send with wait, `read_chat` +/// or `get_chat`. All four arrive as this one RPC. +async fn acknowledge(client: &roboco_rpc::RpcClient, side: &str, parent: &str) { + client + .call( + roboco_rpc::methods::ACK_SIDE_CHAT, + serde_json::json!({ "chatId": side, "originChatId": parent }), + ) + .await + .expect("report observation"); +} + +/// The user's Stop on a chat (the composer's and the MCP `interrupt_chat` +/// tool's Interrupt command). +fn stop(core: &EngineCore, chat: &str) { + core.doc_host + .queue_command(chat, SessionCommandPayload::Interrupt {}) + .expect("queue interrupt"); +} + +/// The paused queue rows a chat currently shows (WatchQueue's surface). +fn queue_rows(core: &EngineCore, chat: &str) -> Vec<(String, String)> { + core.doc_host + .open(chat) + .expect("chat doc") + .doc() + .read_queue() + .expect("queue") + .into_iter() + .map(|row| (row.id, row.text)) + .collect() +} + +/// A parent that waited with `wait_for_turn` — and got the child's +/// completed result — receives no notice for it: the outcome it observed +/// after it happened is dropped, so the parent's next turn end delivers +/// nothing (the same report covers `read_chat` and `get_chat`: all three +/// arrive as the observation RPC). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_observed_outcome_owes_no_notice() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn: hangs until the user steers it. + Turn::Hang, + // The side chat's turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // The parent works; the child's parent-started turn completes while it + // does, so the outcome is recorded and stays pending. + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 1, + "the child's outcome to be recorded durably", + ) + .await; + + // The parent waits (or reads) — the observation is reported for it. + acknowledge(&client, SIDE, PARENT).await; + + // The parent's turn ends: the pending outcome is gone, so no notice + // turn starts. + queue_user_steer(&client, PARENT, "please wrap up", "m-steer-1").await; + wait_idle(&core, PARENT).await; + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "an outcome the parent observed owes no notice" + ); + assert_eq!( + ledger_rows(dir.path()), + 0, + "the observed outcome is dropped from the ledger" + ); + assert_eq!( + scripted.run_count(), + 2, + "only the parent's and the child's turns ran — no notice turn" + ); + + core.shutdown().await; +} + +/// The observation covers exactly the settles it saw: a LATER turn of the +/// same side chat still owes its notice. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_outcome_after_the_observation_still_notifies() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn. + Turn::Hang, + // The child's first turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The child's second turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's notice turn, for the second outcome only. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "first task", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 1, + "the first outcome to be recorded", + ) + .await; + + // The parent observed the chat after the first turn ended. + acknowledge(&client, SIDE, PARENT).await; + assert_eq!(ledger_rows(dir.path()), 0); + + // A later turn of the same child settles while the parent still works: + // its outcome is past the observation's watermark, so it is owed. + queue_run(&client, SIDE, "second task", "m-run-2", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 1, + "the second outcome to be recorded", + ) + .await; + + // The parent's turn ends: the second outcome reaches it. + queue_user_steer(&client, PARENT, "please wrap up", "m-steer-1").await; + wait_for( + || scripted.prompts_containing("finished"), + "the second outcome's notice to start a parent turn", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1, "one notice, for the second outcome only"); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + assert_eq!(scripted.run_count(), 4); + + core.shutdown().await; +} + +/// Stopping the parent drops the notices owed to it and suppresses +/// outcomes from side chats whose notifiable turns were running at that +/// moment: the children keep running (Stop does not cascade), and their +/// later completions do not wake the stopped parent. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stopping_the_parent_drops_pending_notices_and_suppresses_running_children() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn. + Turn::Hang, + // Child A: completes while the parent works (a pending outcome). + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // Child B: still running when the parent is stopped, steered to its + // completion afterwards. + Turn::Hang, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent(&core, &client).await; + create_side(&core, &client, "chat-side-a", "Implement login", None).await; + create_side(&core, &client, "chat-side-b", "Write the docs", None).await; + + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, "chat-side-a", "please implement login", "m-run-a", Some(PARENT)).await; + wait_idle(&core, "chat-side-a").await; + wait_for( + || ledger_rows(dir.path()) == 1, + "child A's outcome to be recorded durably", + ) + .await; + queue_run(&client, "chat-side-b", "please write the docs", "m-run-b", Some(PARENT)).await; + wait_for( + || { + core.sessions + .session_status("chat-side-b") + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "child B's turn to be running", + ) + .await; + + // The user stops the parent. + stop(&core, PARENT); + wait_idle(&core, PARENT).await; + wait_for( + || ledger_rows(dir.path()) == 0, + "the parent's pending notices to be dropped", + ) + .await; + // Child B keeps running — Stop did not cascade. + assert_eq!( + core.sessions + .session_status("chat-side-b") + .map(|s| s.status), + Some(SessionStatus::Working), + "the side chat keeps running across the parent's Stop" + ); + + // Child B's turn completes later: its outcome was suppressed at the + // Stop, so the stopped parent is never woken. + queue_user_steer(&client, "chat-side-b", "please wrap up", "m-steer-b").await; + wait_idle(&core, "chat-side-b").await; + tokio::time::sleep(Duration::from_millis(300)).await; + + assert!( + notice_entries(&core, PARENT).is_empty(), + "a stopped parent receives no notices, and a running child's later \ + completion does not wake it" + ); + assert_eq!(ledger_rows(dir.path()), 0); + assert_eq!( + scripted.run_count(), + 3, + "the parent's and the two children's turns ran — no notice turn" + ); + + core.shutdown().await; +} + +/// A later message from the parent to that child re-arms notices for its +/// next turn (ticket 14's re-arming rule): the steer joins the running +/// child, arms the turn again, and its completion wakes the parent. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_later_parent_message_re_arms_the_child() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn. + Turn::Hang, + // The child's turn: hangs across the parent's Stop, then steered by + // the re-engaging parent and completed. + Turn::Hang, + // The parent's notice turn. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_for( + || { + core.sessions + .session_status(SIDE) + .is_some_and(|s| s.status == SessionStatus::Working) + }, + "the child's turn to be running", + ) + .await; + + // The user stops the parent; the child's turn keeps running, its + // outcome suppressed. + stop(&core, PARENT); + wait_idle(&core, PARENT).await; + assert_eq!( + core.sessions.session_status(SIDE).map(|s| s.status), + Some(SessionStatus::Working) + ); + + // The parent resumes orchestrating: its message steers the running + // child, re-arming the turn's notifiability. + queue_run(&client, SIDE, "round two, please", "m-run-2", Some(PARENT)).await; + wait_for( + || scripted.prompts_containing("finished"), + "the re-armed turn's completion notice to wake the parent", + ) + .await; + + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1, "the re-armed turn notifies again"); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }] + ); + // The parent's turn, the child's turn (steered in place) and the notice + // turn: the child never restarted. + assert_eq!(scripted.run_count(), 3); + assert!( + scripted + .steered + .lock() + .unwrap() + .iter() + .any(|p| p.contains("round two, please")), + "the parent's message steered the running child" + ); + + core.shutdown().await; +} + +/// The 26th consecutive notice-started turn does not start: the notice +/// waits visibly in the parent's queue, paused, until the user acts. A user +/// message resets the count and thaws the queue, and the parked row then +/// runs as the parent's next turn (counting as notice-started itself). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { + let dir = tempfile::tempdir().unwrap(); + // 27 child turns (25 rounds + the capped 26th + the post-reset 27th) + // + 26 notice turns + the user's message + the parked row's turn — + // every run pops one scripted turn. + let turns: Vec<Turn> = (0..27 + 26 + 1 + 1) + .map(|_| Turn::End { + status: DoneStatus::Completed, + error: None, + }) + .collect(); + let scripted = Scripted::new(turns); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // 25 rounds: the parent orchestrates, the child completes, the notice + // starts the parent's next turn. The parent never sees a user message. + for round in 0..25 { + queue_run( + &client, + SIDE, + &format!("round {round}"), + &format!("m-run-{round}"), + Some(PARENT), + ) + .await; + wait_idle(&core, SIDE).await; + wait_for( + || notice_entries(&core, PARENT).len() == round + 1, + &format!("round {round}'s notice to be written"), + ) + .await; + wait_idle(&core, PARENT).await; + } + assert_eq!(notice_entries(&core, PARENT).len(), 25); + assert_eq!(scripted.run_count(), 50); + + // Round 26: the child completes, but the 26th notice-started turn does + // not start — the notice parks as a paused queue row. + queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || queue_rows(&core, PARENT).len() == 1, + "the capped notice to park in the parent's queue", + ) + .await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + notice_entries(&core, PARENT).len(), + 25, + "no 26th notice entry — the notice did not start a turn" + ); + assert_eq!( + scripted.run_count(), + 51, + "the 26th notice turn never ran (25 child + 25 notice + the 26th child)" + ); + let rows = queue_rows(&core, PARENT); + assert_eq!(rows.len(), 1, "exactly one parked notice row"); + assert!( + rows[0].0.starts_with("notice:"), + "the parked row carries the engine's notice prefix: {}", + rows[0].0 + ); + assert!( + rows[0].1.contains("Implement login"), + "the parked row shows the notice itself: {}", + rows[0].1 + ); + + // The user acts: their message resets the count, thaws the queue, and + // runs as the parent's turn; the parked notice row follows it out. + queue_run(&client, PARENT, "user speaks now", "m-user", None).await; + wait_for( + || scripted.prompts_containing("user speaks now"), + "the user's turn to start", + ) + .await; + wait_idle(&core, PARENT).await; + wait_for( + || scripted.run_count() == 53, + "the parked notice row to run as the parent's next turn", + ) + .await; + // The row's turn is the 53rd run, carrying the notice text as its + // prompt — and it is a USER entry (the row), not a 26th system notice. + assert_eq!(notice_entries(&core, PARENT).len(), 25); + let prompts = scripted.prompts.lock().unwrap(); + assert!( + prompts[52].contains("Implement login") && prompts[52].contains("read_chat"), + "the released row ran the notice's own text: {}", + prompts[52] + ); + drop(prompts); + assert!( + queue_rows(&core, PARENT).is_empty(), + "the parked row was consumed by its turn" + ); + // A new outcome after the user's message can start a notice turn again: + // the count reset with the user's message (plus the row's own turn). + queue_run(&client, SIDE, "round 27", "m-run-27", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || notice_entries(&core, PARENT).len() == 26, + "the count reset lets the next notice start a turn again", + ) + .await; + + core.shutdown().await; +} + +/// A Stop also drops the loop cap's parked notice row (ticket 14: it is a +/// pending notice, the visible form the cap left it waiting in): after the +/// Stop the user's next message runs alone, and nothing restarts the +/// parent on the parked notice's behalf. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stopping_the_parent_drops_the_parked_notice_row() { + let dir = tempfile::tempdir().unwrap(); + // 26 child turns + 25 notice turns + the user's message — the parked + // row's turn never happens. + let turns: Vec<Turn> = (0..26 + 25 + 1) + .map(|_| Turn::End { + status: DoneStatus::Completed, + error: None, + }) + .collect(); + let scripted = Scripted::new(turns); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // Fill the cap: 25 notice-started turns, then the 26th child completion + // parks its notice in the parent's queue. + for round in 0..25 { + queue_run( + &client, + SIDE, + &format!("round {round}"), + &format!("m-run-{round}"), + Some(PARENT), + ) + .await; + wait_idle(&core, SIDE).await; + wait_for( + || notice_entries(&core, PARENT).len() == round + 1, + &format!("round {round}'s notice to be written"), + ) + .await; + wait_idle(&core, PARENT).await; + } + queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || queue_rows(&core, PARENT).len() == 1, + "the capped notice to park in the parent's queue", + ) + .await; + assert_eq!(scripted.run_count(), 51); + + // The user stops the (idle) parent: the parked row goes with the rest + // of its pending notices. + stop(&core, PARENT); + wait_for( + || queue_rows(&core, PARENT).is_empty(), + "the parked notice row to be dropped by the Stop", + ) + .await; + + // The user's next message runs alone: the parked notice's turn never + // starts, and no later outcome wakes the parent unprompted. + queue_run(&client, PARENT, "user speaks now", "m-user", None).await; + wait_for( + || scripted.prompts_containing("user speaks now"), + "the user's turn to start", + ) + .await; + wait_idle(&core, PARENT).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + scripted.run_count(), + 52, + "the user's turn is the last run — the parked row never ran" + ); + assert!(queue_rows(&core, PARENT).is_empty()); + assert_eq!( + notice_entries(&core, PARENT).len(), + 25, + "no new notice entry appeared" + ); + + core.shutdown().await; +} diff --git a/crates/roboco-mcp/src/roboco.rs b/crates/roboco-mcp/src/roboco.rs index d326d34e7c..c8e5419743 100644 --- a/crates/roboco-mcp/src/roboco.rs +++ b/crates/roboco-mcp/src/roboco.rs @@ -343,6 +343,19 @@ impl Roboco { .to_owned()) } + /// Report that the calling chat (`origin_chat_id`) observed `chat_id` + /// after its last outcome happened — through `wait_for_turn`, a send + /// with wait, `read_chat` or `get_chat` (ticket 14). Completion-notice + /// outcomes the parent already observed owe no notice. + pub async fn ack_side_chat(&self, chat_id: &str, origin_chat_id: &str) -> anyhow::Result<()> { + self.call( + methods::ACK_SIDE_CHAT, + json!({ "chatId": chat_id, "originChatId": origin_chat_id }), + ) + .await?; + Ok(()) + } + // ---- resolution -------------------------------------------------------- /// Full id, unique id prefix, or exact (case-insensitive) title. diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 419ef3890d..644394f802 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -669,6 +669,9 @@ impl Tools { self.roboco.sessions(), self.roboco.transcript(&chat.id) )?; + // The caller just read the chat's status, question and transcript — + // an observation (ticket 14): outcomes it already saw owe no notice. + self.acknowledge_observed(&chat.id).await; let rendered = render_entries(&entries, RenderOptions::default()); let mut summary = summarize_chat(&chat, &spaces, &sessions); summary["messageCount"] = json!(rendered.len()); @@ -897,6 +900,9 @@ impl Tools { async fn read_chat(&self, args: ReadChatArgs) -> anyhow::Result<Value> { let chat = self.roboco.resolve_chat(&args.chat).await?; let entries = self.roboco.transcript(&chat.id).await?; + // The caller just read the transcript — an observation (ticket 14): + // outcomes for turns that already settled owe no notice. + self.acknowledge_observed(&chat.id).await; let rendered = render_entries( &entries, RenderOptions { @@ -1065,6 +1071,26 @@ impl Tools { // ---- shared pieces ------------------------------------------------------- + /// Report an observation to the engine for the calling chat, best-effort + /// (ticket 14): `wait_for_turn`, `send_message` with wait, `read_chat` + /// and `get_chat` all show the caller the chat's state after its last + /// outcome happened, so the engine drops the completion-notice outcomes + /// it already saw — a wait that timed out saw nothing. No calling chat + /// (a human at a CLI) reports nothing: the user reading a chat is not + /// the parent agent that would process the notice. + async fn acknowledge_observed(&self, chat_id: &str) { + let Some(origin) = self.roboco.origin().chat_id.clone() else { + return; + }; + if origin == chat_id { + // A chat cannot observe itself into silence — `whoami`'s rule. + return; + } + if let Err(err) = self.roboco.ack_side_chat(chat_id, &origin).await { + tracing::warn!(chat = chat_id, error = %err, "observation report failed"); + } + } + /// Prefix the sender's identity when this server speaks for a chat, so /// the receiving agent (and the human reading that transcript) can tell /// an agent-to-agent message from a typed one. A message to this chat's @@ -1297,6 +1323,13 @@ impl Tools { tokio::time::sleep(Duration::from_millis(100).min(remaining)).await; } let (status, _) = status_of(session.as_ref()); + // The wait resolved on a settle the caller now knows (ticket 14): + // report the observation — a timed-out wait observed nothing. This + // covers `wait_for_turn`, `send_message` with wait, and `create_chat` + // with wait, which all resolve through here. + if outcome != TurnOutcome::TimedOut { + self.acknowledge_observed(&chat.id).await; + } Ok(json!({ "outcome": outcome, "status": status, @@ -1344,6 +1377,9 @@ mod tests { /// and whether it wants completion notices. beta_agent_origin: Option<roboco_proto::AgentOrigin>, catalog_error: Option<&'static str>, + /// When set, `WatchSessions` reports this chat as Working — a wait + /// on it can only time out. + working_session: Option<String>, } fn stream(item: Value) -> RpcReply { @@ -1389,7 +1425,13 @@ mod tests { "createdAt": "2026-09-02T00:00:00Z" } ])), - methods::WATCH_SESSIONS => stream(json!([])), + methods::WATCH_SESSIONS => stream(match &self.working_session { + Some(chat_id) => json!([{ + "chatId": chat_id, "deviceId": "dev-local", + "status": "working", "updatedAt": "2026-09-02T00:00:00Z" + }]), + None => json!([]), + }), methods::LIST_HARNESSES => RpcReply::Value(json!([ { "id": "claude-code", "name": "Claude Code", "supportsSteering": true, "steeringMode": "step-boundary", "reasoningLevels": [], "installed": true, "enabled": true }, @@ -1420,6 +1462,15 @@ mod tests { } RpcReply::Value(json!({ "commandId": "cmd-1", "id": "q-1" })) } + methods::ACK_SIDE_CHAT => { + // Recorded like a write so tests can assert the report + // the engine received (ticket 14). + self.writes + .lock() + .unwrap() + .push((method.to_owned(), params)); + RpcReply::Value(json!({})) + } other => return Err(RpcError::UnknownMethod(other.into())), }) } @@ -1862,6 +1913,135 @@ mod tests { assert_eq!(prompt, "typed"); } + #[tokio::test] + async fn reads_waits_and_gets_report_observations_for_the_calling_chat() { + let world = Arc::new(World::default()); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + // read_chat, get_chat and wait_for_turn all show the caller the + // chat's state after its last outcome happened (ticket 14): each + // reports the observation for the calling chat. + tools + .call("read_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + tools + .call("get_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + tools + .call("wait_for_turn", json!({ "chat": "alpha", "timeout_secs": 2 })) + .await + .unwrap(); + + let writes = world.writes.lock().unwrap(); + let acks: Vec<&Value> = writes + .iter() + .filter(|(method, _)| method == methods::ACK_SIDE_CHAT) + .map(|(_, params)| params) + .collect(); + assert_eq!(acks.len(), 3, "{writes:?}"); + for params in acks { + assert_eq!(params["chatId"], "chat-alpha-1"); + assert_eq!(params["originChatId"], "chat-beta-2"); + } + } + + #[tokio::test] + async fn a_send_that_waits_reports_the_observation_once_it_resolves() { + // send_message with wait: the turn completes (the advancing sessions + // row), the reply comes back — the observation is reported. + let service = Arc::new(DelayedTurn { + world: World::default(), + sent: Mutex::new(None), + reply_delay: Duration::from_millis(100), + never_reply: false, + host_skew_millis: 0, + }); + let tools = Tools::new(Arc::new(Roboco::with_client( + memory_client(service.clone()), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ))); + let result = tools + .call( + "send_message", + json!({ "chat": "Beta", "text": "go", "wait": true, "timeout_secs": 2 }), + ) + .await + .unwrap(); + assert_eq!(result["turn"]["outcome"], "completed", "{result}"); + + let writes = service.world.writes.lock().unwrap(); + let acks: Vec<&Value> = writes + .iter() + .filter(|(method, _)| method == methods::ACK_SIDE_CHAT) + .map(|(_, params)| params) + .collect(); + assert_eq!(acks.len(), 1, "{writes:?}"); + assert_eq!(acks[0]["chatId"], "chat-beta-2"); + assert_eq!(acks[0]["originChatId"], "chat-alpha-1"); + } + + #[tokio::test] + async fn a_human_cli_reports_no_observations() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + tools + .call("read_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + tools + .call("get_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + tools + .call("wait_for_turn", json!({ "chat": "alpha", "timeout_secs": 2 })) + .await + .unwrap(); + // No calling chat: nothing is reported. The user reading a chat is + // not the parent agent that would process the notice. + let writes = world.writes.lock().unwrap(); + assert!( + writes.iter().all(|(method, _)| method != methods::ACK_SIDE_CHAT), + "{writes:?}" + ); + } + + #[tokio::test] + async fn a_timed_out_wait_reports_no_observation() { + // The chat stays working, so the wait can only expire — and a wait + // that timed out observed nothing to acknowledge. + let world = Arc::new(World { + working_session: Some("chat-alpha-1".into()), + ..Default::default() + }); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + let waited = tools + .call("wait_for_turn", json!({ "chat": "alpha", "timeout_secs": 1 })) + .await + .unwrap(); + assert_eq!(waited["turn"]["timedOut"], true, "{waited}"); + let writes = world.writes.lock().unwrap(); + assert!( + writes.iter().all(|(method, _)| method != methods::ACK_SIDE_CHAT), + "{writes:?}" + ); + } + #[tokio::test] async fn create_chat_records_the_origin_as_parent() { let world = Arc::new(World::default()); diff --git a/crates/rpc/src/lib.rs b/crates/rpc/src/lib.rs index a71d6c5cf4..bbe4791d0b 100644 --- a/crates/rpc/src/lib.rs +++ b/crates/rpc/src/lib.rs @@ -116,6 +116,14 @@ pub mod methods { /// Steer this row into the live turn without interrupting it. /// `{ chatId, id }` → `{ sent }`. pub const STEER_QUEUED_MESSAGE_NOW: &str = "SteerQueuedMessageNow"; + /// An agent parent observed a chat after its last outcome happened + /// (ticket 14, orchestration v2): the Roboco MCP server reports it for + /// its calling chat after `wait_for_turn`, `send_message` with wait, + /// `read_chat` or `get_chat`. Params `{ chatId, originChatId }` → `{}`: + /// completion-notice outcomes the parent already observed owe no + /// notice — they are dropped, and a record that lands right after the + /// report raced it is dropped too. + pub const ACK_SIDE_CHAT: &str = "AckSideChat"; /// Pushed edge-connectivity posture (`roboco_proto::Connectivity`): /// current value first, then every change — the connection pill / /// composer-honesty / queued-badge feed. No params; IPC-only. diff --git a/docs/mcp.md b/docs/mcp.md index c0ad2fed7d..29cc19b77e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -157,6 +157,13 @@ Watch streams are the engine's only read surface (there is no one-shot "get transcript" RPC); a snapshot is "subscribe, take the first item, drop" — drop cancels server-side, exactly what the sidebar does on attach. +`wait_for_turn`, `send_message` with `wait`, `read_chat` and `get_chat` all +report the observation to the engine for the calling chat afterwards +(`AckSideChat {chatId, originChatId}`): a side chat's outcome the caller just +saw owes no completion notice. A wait that timed out reports nothing, and a +human at a terminal (no calling chat) never reports — the user reading a chat +is not the parent agent that would process the notice. + `send_message` mode `auto` starts idle (or errored) chats and delivers into working chats without interrupting the run: mid-turn where the harness accepts prompts mid-run (Claude Code, Codex, Cursor, Pi, OpenCode), otherwise held in From 5622ae3c4defb43fadda5aa7b331fe3a4ec58836 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 02:24:19 +0700 Subject: [PATCH 44/96] feat(web): Limited chats, resume actions and account sign-in state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 10 (t3-orchestration-v2): web parity for tickets 06-09, using the same engine RPCs the desktop drives. - The status strip's Limited arm: the window and reset label (proto view::format_limit_reset / format_scheduled_resume ports in lib/view), Resume (the composer's fixed-prompt send through a resumeRef handle), Resume at reset with its armed Cancel (Mutate scheduleResumeAtReset / cancelScheduledResume), and the account failover picker (ListResumeAccountCandidates + ResumeOnAnotherAccount, the recommended row first, signed-out rows offered Sign in again, the all-chats warning). - The sidebar marker: ChatRow.limited (state.rs is_limited port) turns the corner amber — 'Limited' word + dot-limited, send-truth words still winning. - Account sign-in state (ticket 08): the ring popover and Settings -> Accounts mark signed-out rows, show the probe's reason beside stale meters, ask before switching to a dead login, and offer Sign in again (the engine's own login flow, inline via the shared sign-in-again component; the settings page reuses its dialog flow). - A Limited transition chimes attention like any error (proto view::sound_since port). No wire changes: every type the banner reads is already generated (Session.limit / scheduledResume, AgentAccount.signInState, ResumeAccountCandidates, ResumeOnAccountOutcome) — ADR 0005 additive. Tests: lib/limited (RPC wrappers + the pure view-model), the mounted LimitedBanner + StatusStrip arm, the mounted sidebar marker, view derivations, accounts sign-in helpers, the ring's signed-out rows. Not run locally (CI runs vitest + pnpm -r build). --- .../app/src/components/account-usage.tsx | 242 +++++++--- web/packages/app/src/components/chat-list.tsx | 38 +- web/packages/app/src/components/composer.tsx | 53 +++ .../app/src/components/limited-banner.tsx | 363 ++++++++++++++ .../app/src/components/sign-in-again.tsx | 285 +++++++++++ .../app/src/components/transcript.tsx | 43 +- web/packages/app/src/lib/accounts.ts | 56 ++- web/packages/app/src/lib/limited.ts | 208 ++++++++ web/packages/app/src/lib/notifications.ts | 12 +- web/packages/app/src/lib/view.ts | 80 +++- web/packages/app/src/routes/chat-page.tsx | 37 +- .../app/src/routes/settings-accounts.tsx | 78 ++- web/packages/app/src/state/escape.ts | 6 +- web/packages/app/src/styles/app.css | 284 +++++++++++ web/packages/app/tests/account-row.test.ts | 1 + web/packages/app/tests/account-usage.test.ts | 144 +++++- web/packages/app/tests/accounts-view.test.ts | 60 +++ web/packages/app/tests/limited-banner.test.ts | 447 ++++++++++++++++++ web/packages/app/tests/limited-row.test.ts | 272 +++++++++++ web/packages/app/tests/limited.test.ts | 290 ++++++++++++ web/packages/app/tests/notifications.test.ts | 12 + web/packages/app/tests/sidebar-pins.test.ts | 1 + web/packages/app/tests/sidebar-view.test.ts | 1 + web/packages/app/tests/view.test.ts | 83 ++++ web/packages/engine-client/src/methods.ts | 8 + 25 files changed, 3012 insertions(+), 92 deletions(-) create mode 100644 web/packages/app/src/components/limited-banner.tsx create mode 100644 web/packages/app/src/components/sign-in-again.tsx create mode 100644 web/packages/app/src/lib/limited.ts create mode 100644 web/packages/app/tests/limited-banner.test.ts create mode 100644 web/packages/app/tests/limited-row.test.ts create mode 100644 web/packages/app/tests/limited.test.ts diff --git a/web/packages/app/src/components/account-usage.tsx b/web/packages/app/src/components/account-usage.tsx index c2d53a43e7..7e1b66942a 100644 --- a/web/packages/app/src/components/account-usage.tsx +++ b/web/packages/app/src/components/account-usage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EngineClient } from "@roboco/engine-client"; import type { AgentAccount, AgentAccountsSnapshot, HarnessId } from "@roboco/proto"; import { + accountNote, activeAccount, activateAgentAccount, listAgentAccounts, @@ -9,6 +10,8 @@ import { providerAccounts, providerName, reportsUsage, + signedOut, + signedOutSwitchWarning, signsIn, usedFraction, usageLevel, @@ -16,6 +19,8 @@ import { import { PickerCard } from "./ui/PickerCard"; import { MenuHeading, MenuRowNav } from "./ui/MenuRows"; import { useCursorList } from "./ui/CursorList"; +import { SignInAgainCard, useSignInAgainFlow } from "./sign-in-again"; +import { BtnGhost, BtnPrimary, Dialog, DialogBody, DialogCard, DialogTitle } from "./ui/Dialog"; /** * The footer's plan-usage ring — the web peer of the desktop's @@ -127,12 +132,19 @@ export function AccountUsageIndicator({ client, harness, targetDeviceId }: Accou }, [open]); /** Switch optimistically: the rows flip at once and the engine's reply - * replaces them; a refusal restores the list. */ + * replaces them; a refusal restores the list. A signed-out target asks + * first (ticket 08) — the desktop's `confirm_switch` — and the confirm + * runs the same switch (`confirmed` skips the ask). */ + const [confirmSwitch, setConfirmSwitch] = useState<AgentAccount | null>(null); const switchTo = useCallback( - async (account: AgentAccount): Promise<void> => { + async (account: AgentAccount, confirmed = false): Promise<void> => { if (client === null) { return; } + if (!confirmed && signedOut(account)) { + setConfirmSwitch(account); + return; + } const previous = snapshot; if (snapshot !== null) { const optimistic: AgentAccountsSnapshot = { @@ -155,6 +167,17 @@ export function AccountUsageIndicator({ client, harness, targetDeviceId }: Accou [client, snapshot, targetDeviceId], ); + // "Sign in again" on a signed-out row (ticket 08): the engine's own login + // flow, driven inline below the rows — the desktop's `sign_in_footer`. A + // landed sign-in re-probes (the row flips back to signed in). + const signIn = useSignInAgainFlow({ + client, + targetDeviceId, + onLanded: () => { + void load(true); + }, + }); + const rows = useMemo( () => (snapshot !== null && harness !== null ? providerAccounts(snapshot, harness) : []), [snapshot, harness], @@ -176,48 +199,82 @@ export function AccountUsageIndicator({ client, harness, targetDeviceId }: Accou const label = `${Math.round(fraction * 100)}%`; return ( - <PickerCard - open={open} - onOpenChange={setOpen} - placement="anchorAboveEnd" - role="dialog" - ariaLabel={`${providerName(harness)} accounts`} - width={400} - overlaySource="composer-pickers" - trigger={ - <div - className="account-usage" - data-tone={tone} - data-open={open || undefined} - title={`${providerName(harness)} plan usage`} - > - <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"> - {/* Rotated so the arc starts at 12 o'clock, like the desktop's paths. */} - <g transform="rotate(-90 8 8)" fill="none" strokeWidth="1.8"> - <circle cx="8" cy="8" r={RING_RADIUS} className="account-usage-track" /> - <circle - cx="8" - cy="8" - r={RING_RADIUS} - className="account-usage-arc" - strokeDasharray={`${RING_CIRCUMFERENCE * fraction} ${RING_CIRCUMFERENCE}`} - strokeLinecap="butt" - /> - </g> - </svg> - <span>{label}</span> - </div> - } - > - <AccountUsageCard + <> + <PickerCard open={open} - title={`${providerName(harness)} accounts`} - rows={rows} - error={error} - onSwitch={(account) => void switchTo(account)} - /> - </PickerCard> + onOpenChange={setOpen} + placement="anchorAboveEnd" + role="dialog" + ariaLabel={`${providerName(harness)} accounts`} + width={400} + overlaySource="composer-pickers" + trigger={ + <div + className="account-usage" + data-tone={tone} + data-open={open || undefined} + title={`${providerName(harness)} plan usage`} + > + <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"> + {/* Rotated so the arc starts at 12 o'clock, like the desktop's paths. */} + <g transform="rotate(-90 8 8)" fill="none" strokeWidth="1.8"> + <circle cx="8" cy="8" r={RING_RADIUS} className="account-usage-track" /> + <circle + cx="8" + cy="8" + r={RING_RADIUS} + className="account-usage-arc" + strokeDasharray={`${RING_CIRCUMFERENCE * fraction} ${RING_CIRCUMFERENCE}`} + strokeLinecap="butt" + /> + </g> + </svg> + <span>{label}</span> + </div> + } + > + <AccountUsageCard + open={open} + title={`${providerName(harness)} accounts`} + rows={rows} + error={error} + onSwitch={(account) => void switchTo(account)} + onSignIn={signIn.begin} + /> + {signIn.flow !== null && ( + <SignInAgainCard flow={signIn.flow} onCancel={signIn.cancel} onSubmitCode={signIn.submitCode} /> + )} + </PickerCard> + {/* The "switch to a signed-out account" ask (ticket 08) — the same + modal-over-popover the desktop's `render_switch_confirm` draws. */} + {confirmSwitch !== null && ( + <Dialog ariaLabel="Switch to a signed-out account?" onClose={() => setConfirmSwitch(null)}> + <DialogCard> + <DialogTitle>Switch to a signed-out account?</DialogTitle> + <DialogBody>{signedOutSwitchWarning(confirmSwitch)}</DialogBody> + <div className="dialog-actions-row"> + <BtnGhost type="button" onClick={() => setConfirmSwitch(null)}> + Cancel + </BtnGhost> + <BtnPrimary type="button" onClick={confirmSwitchTo}> + Switch anyway + </BtnPrimary> + </div> + </DialogCard> + </Dialog> + )} + </> ); + + /** The confirm dialog's "Switch anyway" — the ask is advice, not a block + * (the desktop's `confirm_switch` take runs the same switch). */ + function confirmSwitchTo(): void { + const account = confirmSwitch; + setConfirmSwitch(null); + if (account !== null) { + void switchTo(account, true); + } + } } const RING_RADIUS = 6; @@ -229,19 +286,24 @@ function AccountUsageCard({ rows, error, onSwitch, + onSignIn, }: { readonly open: boolean; readonly title: string; readonly rows: readonly AgentAccount[]; readonly error: string | null; readonly onSwitch: (account: AgentAccount) => void; + /** "Sign in again" on a signed-out row (ticket 08) — the inline flow. */ + readonly onSignIn: (account: AgentAccount) => void; }) { const { cursor, setCursor, onKeyDown } = useCursorList({ enabled: open, count: rows.length, onActivate: (ix) => { const row = rows[ix]; - if (row !== undefined && !row.active && row.switchable) { + // A signed-out row is listed, never keyboard-switched — its own + // action is the sign-in (stories 47/50). + if (row !== undefined && !row.active && row.switchable && !signedOut(row)) { onSwitch(row); } }, @@ -255,8 +317,76 @@ function AccountUsageCard({ ) : ( <div className="picker-list picker-list-plain"> {rows.map((account, ix) => { - const canSwitch = !account.active && account.switchable; + const isSignedOut = signedOut(account); + const canSwitch = !account.active && account.switchable && !isSignedOut; const label = account.email ?? account.displayName ?? "Unknown account"; + // The probe's reason rides the meta line EVEN WHEN stale meters + // are shown — cached meters used to hide the sign-out (ticket + // 08's display bug; the desktop's `account_note`). + const note = accountNote(account); + const body = ( + <span className="account-usage-row-main"> + <span className="account-usage-row-label">{label}</span> + <span className="account-usage-row-meta"> + {account.planLabel != null && <span className="account-usage-row-plan">{account.planLabel}</span>} + {account.active && <span className="account-usage-row-in-use">In use</span>} + {/* The mark that keeps a dead login from reading healthy. */} + {isSignedOut && <span className="account-usage-row-signed-out">Signed out</span>} + {note !== null && ( + <span + className={`account-usage-row-reason ${ + isSignedOut ? "account-usage-row-reason-signed-out" : "" + }`} + > + {note} + </span> + )} + </span> + {account.usageWindows.length > 0 && ( + <span className="account-usage-meters"> + {account.usageWindows.slice(0, 2).map((window, index) => ( + <AccountUsageMeter key={index} label={window.label} usedFraction={window.usedFraction} /> + ))} + </span> + )} + {/* The signed-out row's own action (ticket 08): the same + login flow the settings page drives, inline here. The + row renders as a div for these — a nested button can + never live inside the row button. */} + {isSignedOut && ( + <button + type="button" + className="failover-row-sign-in" + onClick={(event) => { + // The row's own click asks about the switch; the + // nested button owns this one. + event.stopPropagation(); + onSignIn(account); + }} + > + Sign in again + </button> + )} + </span> + ); + if (isSignedOut) { + // A signed-out row is listed, never switched directly: the + // row's own click asks first (the switch confirm below), and + // its nested action signs in — a div, so the nesting is valid + // (a nested button can never live inside the row button). + return ( + <div + key={account.id} + className="menu-row" + data-rb-row-key={`account-usage-row-${ix}`} + onClick={() => { + onSwitch(account); + }} + > + {body} + </div> + ); + } return ( <MenuRowNav key={account.id} @@ -271,23 +401,7 @@ function AccountUsageCard({ } }} > - <span className="account-usage-row-main"> - <span className="account-usage-row-label">{label}</span> - <span className="account-usage-row-meta"> - {account.planLabel != null && <span className="account-usage-row-plan">{account.planLabel}</span>} - {account.active && <span className="account-usage-row-in-use">In use</span>} - {account.usageError != null && account.usageWindows.length === 0 && ( - <span className="account-usage-row-reason">{account.usageError}</span> - )} - </span> - {account.usageWindows.length > 0 && ( - <span className="account-usage-meters"> - {account.usageWindows.slice(0, 2).map((window, index) => ( - <AccountUsageMeter key={index} label={window.label} usedFraction={window.usedFraction} /> - ))} - </span> - )} - </span> + {body} </MenuRowNav> ); })} @@ -304,8 +418,10 @@ function AccountUsageCard({ /** One compact meter line (`render_usage_meter`): label, a short bar, * percent. The reset moment stays out of this card — the desktop's footer - * card likewise leaves it to the settings page's fuller rows. */ -function AccountUsageMeter({ label, usedFraction: fraction }: { readonly label: string; readonly usedFraction: number }) { + * card likewise leaves it to the settings page's fuller rows (the Limited + * banner's failover picker draws its own caption under the meters). + * Exported for the failover picker's candidate rows (same meters). */ +export function AccountUsageMeter({ label, usedFraction: fraction }: { readonly label: string; readonly usedFraction: number }) { const clamped = Math.min(Math.max(fraction, 0), 1); const level = usageLevel(clamped); return ( diff --git a/web/packages/app/src/components/chat-list.tsx b/web/packages/app/src/components/chat-list.tsx index e56f9cc038..7900d20163 100644 --- a/web/packages/app/src/components/chat-list.tsx +++ b/web/packages/app/src/components/chat-list.tsx @@ -1334,6 +1334,12 @@ export function ChatListRow({ const word = statusWord(row.status); const sendState = row.sendState; const sendWord = sendState === "failed" ? "Failed" : sendState === "queued" ? "Queued" : null; + // LIMITED (orchestration v2, ticket 06): the amber word that replaces the + // plain status word (or the idle time-ago) — `render_chat_row`'s + // `status_label` override, after the send-truth words, before the plain + // status mapping. `row.limited` already folds the send overlay: a send in + // flight is Working, never Limited. + const limitedWord = row.limited ? "Limited" : null; const archived = row.chat.archived; const brand = row.harness === null ? null : harnessBrandIcon(row.harness); const { menu, element } = useChatMenu(row.chat); @@ -1440,13 +1446,18 @@ export function ChatListRow({ {archived ? "Unarchive" : "Archive"} </button>, ) - ) : word === null && sendWord === null ? ( + ) : word === null && sendWord === null && limitedWord === null ? ( <span className="chat-row-time">{row.timeAgo}</span> ) : sendWord !== null ? ( <span className={`chat-row-status status-send-${sendState}`}> <StatusGlyph status={row.status} sendState={sendState} /> {sendWord} </span> + ) : limitedWord !== null ? ( + <span className="chat-row-status status-limited"> + <StatusGlyph status={row.status} sendState={sendState} limited={true} /> + {limitedWord} + </span> ) : ( <span className={`chat-row-status status-${row.status}`}> <StatusGlyph status={row.status} sendState={sendState} /> @@ -1459,10 +1470,12 @@ export function ChatListRow({ <div className="chat-row-title-line"> {compact && ( <span - className={`chat-row-status-compact status-${row.status}`} - aria-label={sendWord ?? word ?? "Idle"} + className={`chat-row-status-compact ${ + limitedWord !== null && sendWord === null ? "status-limited" : `status-${row.status}` + }`} + aria-label={sendWord ?? limitedWord ?? word ?? "Idle"} > - <StatusGlyph status={row.status} sendState={sendState} /> + <StatusGlyph status={row.status} sendState={sendState} limited={sendWord === null && limitedWord !== null} /> </span> )} {monogram} @@ -1577,12 +1590,25 @@ function owningSession( * The corner's glyph slot (`render_chat_row`): Done wears the check, Working * the animated pixel glyph, everything else a compact 6px dot. A queued or * failed send replaces the glyph with its own dot — the honest state is - * the word, never a fake spinner or a stale check. + * the word, never a fake spinner or a stale check. A Limited row's dot is + * the amber `dot-limited` — "waiting out a limit", not the failure red + * (proto `view::dot::LIMITED`). */ -function StatusGlyph({ status, sendState }: { status: ChatRow["status"]; sendState: ChatRow["sendState"] }) { +function StatusGlyph({ + status, + sendState, + limited = false, +}: { + status: ChatRow["status"]; + sendState: ChatRow["sendState"]; + limited?: boolean; +}) { if (sendState !== null) { return <span className={`dot dot-send-${sendState}`} />; } + if (limited) { + return <span className="dot dot-limited" />; + } if (status === "completed") { return <Icon name="check" size={11} />; } diff --git a/web/packages/app/src/components/composer.tsx b/web/packages/app/src/components/composer.tsx index 706dc96998..7849539017 100644 --- a/web/packages/app/src/components/composer.tsx +++ b/web/packages/app/src/components/composer.tsx @@ -97,6 +97,7 @@ import { import { menuStep } from "../lib/picker-search"; import { dockHeight, routeChromeOpacities, type DockFrame } from "../lib/composer-dock"; import { createChat, waitForChatRow } from "../lib/chat-actions"; +import { RESUME_CONTINUE_PROMPT } from "../lib/limited"; import { mintUnsavedSideChat } from "../lib/side-chat-actions"; import { completionTargetChatId, @@ -376,6 +377,14 @@ interface ComposerProps { * pattern; unassigned hosts keep whatever placement they had. */ readonly editFailureRef?: React.MutableRefObject<((message: string) => void) | null>; + /** + * The Limited banner's **Resume** handle (orchestration v2, tickets + * 06/10): assigned the composer's `resume_limited` — the fixed continue + * prompt through the normal send path, the user's draft and staged files + * handed back — so the status strip above the composer can trigger it. + * The `editCommitRef` pattern again. + */ + readonly resumeRef?: React.MutableRefObject<(() => void) | null>; /** * The workspace commands' host surfaces (composer.rs's * `ComposerEvent::WorkspaceCommand` dispatch, web-shaped): the Roboco-local @@ -468,6 +477,7 @@ export function Composer({ onEditCancel, editCommitRef, editFailureRef, + resumeRef, activateLatestQueued, dockFrame = null, liveDockFrame, @@ -2855,6 +2865,49 @@ export function Composer({ [chat.id, chat.cwd, draft, session.client, session.engine.baseUrl, staged, engineSupports, checkReferenceDelivery, onNewThreadLaunched, commentCount], ); + // ── **Resume** on a Limited chat (orchestration v2, tickets 06/10) ──── + // `resume_limited` (composer.rs): send the fixed continue prompt as a + // NORMAL run — same send path, same guards, same queue semantics as + // typing it — never a special engine mode. The run clears the limit + // record when it starts; messages queued on the chat stay behind the + // continuation. A draft the user was typing (and files they staged) is + // theirs, not part of the resume: the send clears the composer, so hand + // the slots straight back — `send` takes its own copies, so restoring + // them after it settles cannot leak into the sent message. + const resumeLimited = useCallback(() => { + const draftText = textRef.current; + const stagedSnapshot = staged; + void send(RESUME_CONTINUE_PROMPT, false).then(() => { + if (draftText.length > 0) { + setText(draftText); + chatDrafts.set(textDraftKey, draftText); + } + if (stagedSnapshot.length > 0) { + setStagedByChat((current) => { + const fresh = current[chat.id] ?? []; + const merged = [ + ...stagedSnapshot.filter((att) => !fresh.some((f) => f.id === att.id)), + ...fresh, + ]; + return { ...current, [chat.id]: merged }; + }); + } + }); + }, [chat.id, staged, send, textDraftKey]); + + // The banner's handle assignment: the strip above the composer holds the + // ref; `send`'s identity churn re-binds it without re-subscribing the + // host (the `editCommitRef` pattern). + useEffect(() => { + if (resumeRef === undefined) { + return; + } + resumeRef.current = resumeLimited; + return () => { + resumeRef.current = null; + }; + }, [resumeRef, resumeLimited]); + // ── The submit dispatch (`on_submit`, composer.rs:5980-6007) ─────────── // `commit_queue_edit` (queue.rs:1430): save the composer into the leased // row — an entirely empty composer (no text, no staged attachments) diff --git a/web/packages/app/src/components/limited-banner.tsx b/web/packages/app/src/components/limited-banner.tsx new file mode 100644 index 0000000000..2ee8457d06 --- /dev/null +++ b/web/packages/app/src/components/limited-banner.tsx @@ -0,0 +1,363 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EngineClient } from "@roboco/engine-client"; +import type { AgentAccount, ResumeAccountCandidates, ScheduledResume, SessionLimit } from "@roboco/proto"; +import { accountLabel } from "../lib/accounts"; +import { + cancelScheduledResume, + candidateResetCaption, + failoverCardTitle, + failoverListOffers, + failoverOutcomeNotice, + failoverRows, + harnessOffersFailover, + limitedBannerLabel, + listResumeAccountCandidates, + resumeAtResetArmable, + resumeOnAnotherAccount, + scheduleResumeAtReset, + FAILOVER_LIST_TTL_MS, +} from "../lib/limited"; +import { AccountUsageMeter } from "./account-usage"; +import { PickerCard } from "./ui/PickerCard"; +import { MenuHeading, MenuRowNav } from "./ui/MenuRows"; +import { SignInAgainCard, useSignInAgainFlow } from "./sign-in-again"; + +/** + * The Limited banner — the web peer of the desktop's status-strip Limited + * arm (shell.rs `render_status_strip`'s `Indicator::Limited` branch). A + * run ended on a harness usage limit: the banner names the window and its + * reset (from the harness's own error, else the account's cached windows), + * and offers the fixed actions: + * + * - **Resume** — the continue prompt as a normal run (the composer's send + * path; queued messages stay behind it); + * - **Resume at reset** (ticket 07) — arms the engine's durable + * continuation; once armed the label shows when it fires and the chip + * becomes Cancel; + * - **Resume on another account** (ticket 09) — the failover picker: the + * engine-ranked candidates with their usage meters and reset times, the + * recommended one first, and the signed-out accounts with a "Sign in + * again" action instead of a switch. + * + * "Continue on another harness" (ticket 25) is ticket 26's web work — the + * desktop's fourth chip lands there, not here. + */ +export function LimitedBanner({ + chatId, + client, + limit, + scheduled, + now, + onResume, +}: { + /** The chat's id as the page knows it (scoped; routing decodes either form). */ + readonly chatId: string; + /** The chat's owning engine client — null disables every action. */ + readonly client: EngineClient | null; + readonly limit: SessionLimit | null; + readonly scheduled: ScheduledResume | null; + readonly now: number; + /** The composer's fixed-prompt send ("Continue where you left off."). */ + readonly onResume: () => void; +}) { + const label = limitedBannerLabel(limit, scheduled, now); + const armable = resumeAtResetArmable(limit, scheduled, now); + + // ── The failover picker state (shell.rs `FailoverState`) ───────────── + const [open, setOpen] = useState(false); + const [list, setList] = useState<ResumeAccountCandidates | null>(null); + const [listFor, setListFor] = useState<string | null>(null); + const [loading, setLoading] = useState(false); + const [switching, setSwitching] = useState(false); + /** The last switch outcome — a warning-toned strip line that lives until + * the next switch or the chat's own state supersedes it. */ + const [notice, setNotice] = useState<string | null>(null); + // The freshness bookkeeping rides refs: `track` stays identity-stable so + // the warm-load effect (keyed on the page's `now` heartbeat) never + // re-binds, and the in-flight guard survives the renders between the + // call and its reply. + const loadedRef = useRef<{ chatId: string; at: number } | null>(null); + const loadingForRef = useRef<string | null>(null); + + const offersFailover = limit !== null && harnessOffersFailover(limit.harness); + const track = useCallback( + (force: boolean): void => { + if (client === null) { + return; + } + const loaded = loadedRef.current; + if ( + !force && + ((loaded !== null && loaded.chatId === chatId && Date.now() - loaded.at < FAILOVER_LIST_TTL_MS) || + loadingForRef.current === chatId) + ) { + return; + } + loadingForRef.current = chatId; + setLoading(true); + void listResumeAccountCandidates(client, chatId) + .then((fresh) => { + loadedRef.current = { chatId, at: Date.now() }; + setList(fresh); + setListFor(chatId); + }) + .catch(() => { + // A load failure clears the cache so the next pass retries — the + // banner keeps offering plain Resume either way. + loadedRef.current = null; + setList(null); + setListFor(null); + }) + .finally(() => { + if (loadingForRef.current === chatId) { + loadingForRef.current = null; + } + setLoading(false); + }); + }, + [chatId, client], + ); + + // Keep the banner's candidate list warm while the chat reads as Limited + // on a switchable harness (desktop's `track_failover` from the strip's + // render — the TTL inside makes the heartbeat's repeated calls cheap). + useEffect(() => { + if (offersFailover) { + track(false); + } + }, [chatId, offersFailover, now, track]); + + // Opening the picker re-ranks: usage meters move as turns run. + useEffect(() => { + if (open) { + track(true); + } + }, [open, track]); + + // A chat change drops the stale list and the outcome line. + useEffect(() => { + if (listFor !== null && listFor !== chatId) { + setList(null); + setListFor(null); + setNotice(null); + } + }, [chatId, listFor]); + + const rows = list !== null && listFor === chatId ? failoverRows(list) : []; + const hasCandidates = list !== null && listFor === chatId && failoverListOffers(list); + + /** Run the failover for `accountId` — the reply becomes the strip's + * outcome line; a failed switch surfaces the reason right there. */ + const switchTo = useCallback( + (accountId: string): void => { + if (client === null || switching) { + return; + } + setSwitching(true); + setNotice(null); + void resumeOnAnotherAccount(client, chatId, accountId) + .then((outcome) => { + setNotice(failoverOutcomeNotice(outcome)); + }) + .catch((cause: unknown) => { + setNotice(`Could not switch account: ${cause instanceof Error ? cause.message : String(cause)}`); + }) + .finally(() => { + setSwitching(false); + setOpen(false); + }); + }, + [chatId, client, switching], + ); + + // "Sign in again" on a signed-out row (stories 47/50): the engine's own + // login flow, driven inline below the rows where the user already looks. + const signIn = useSignInAgainFlow({ + client, + onLanded: () => { + void track(true); + }, + }); + + const chipsDisabled = client === null; + + return ( + <div className="limited-banner"> + <span className="limited-banner-dot" aria-hidden /> + <span className="limited-banner-label">{label}</span> + {armable && ( + <button + type="button" + className="limited-banner-chip" + title="Continue automatically when the limit resets" + disabled={chipsDisabled} + onClick={() => { + if (client !== null) { + void scheduleResumeAtReset(client, chatId); + } + }} + > + Resume at reset + </button> + )} + {scheduled !== null && ( + <button + type="button" + className="limited-banner-chip" + title="Stop the automatic continuation" + disabled={chipsDisabled} + onClick={() => { + if (client !== null) { + void cancelScheduledResume(client, chatId); + } + }} + > + Cancel + </button> + )} + <button + type="button" + className="limited-banner-chip" + title="Send the continue prompt now" + disabled={chipsDisabled} + onClick={onResume} + > + Resume + </button> + {offersFailover && hasCandidates && ( + <PickerCard + open={open} + onOpenChange={setOpen} + placement="anchorAboveEnd" + role="dialog" + ariaLabel={failoverCardTitle(list!.harness)} + width={400} + overlaySource="composer-pickers" + trigger={ + <button + type="button" + className="limited-banner-chip" + title="Continue on another account of this harness" + > + Resume on another account + </button> + } + > + <FailoverCard + list={list!} + rows={rows} + loading={loading} + switching={switching} + now={now} + onSwitch={switchTo} + onSignIn={(account) => signIn.begin(account)} + /> + {signIn.flow !== null && ( + <SignInAgainCard flow={signIn.flow} onCancel={signIn.cancel} onSubmitCode={signIn.submitCode} /> + )} + </PickerCard> + )} + {notice !== null && <span className="limited-banner-notice">{notice}</span>} + </div> + ); +} + +/** + * The failover picker body (shell.rs `failover_card`): the ranked candidate + * rows (usage meters with their reset times, the recommended one first), + * the signed-out accounts with a "Sign in again" action, and the all-chats + * warning the spec asks the switch to state (story 53). + */ +function FailoverCard({ + list, + rows, + loading, + switching, + now, + onSwitch, + onSignIn, +}: { + readonly list: ResumeAccountCandidates; + readonly rows: ReturnType<typeof failoverRows>; + readonly loading: boolean; + readonly switching: boolean; + readonly now: number; + readonly onSwitch: (accountId: string) => void; + readonly onSignIn: (account: AgentAccount) => void; +}) { + return ( + <div className="failover-card"> + <MenuHeading>{failoverCardTitle(list.harness)}</MenuHeading> + <div className="picker-list picker-list-plain" data-switching={switching ? "1" : undefined}> + {rows.map((row, ix) => { + const signedOut = row.kind === "signedOut"; + const account = row.account; + const caption = candidateResetCaption(account, now); + const body = ( + <span className="failover-row-main"> + <span className="failover-row-label">{accountLabel(account)}</span> + <span className="failover-row-meta"> + {account.planLabel != null && <span className="failover-row-plan">{account.planLabel}</span>} + {row.kind === "candidate" && row.recommended && ( + <span className="failover-row-recommended">Recommended</span> + )} + {signedOut && <span className="failover-row-signed-out">Signed out</span>} + </span> + {account.usageWindows.length > 0 && ( + <span className="account-usage-meters"> + {account.usageWindows.slice(0, 2).map((window, index) => ( + <AccountUsageMeter key={index} label={window.label} usedFraction={window.usedFraction} /> + ))} + </span> + )} + {caption !== null && <span className="failover-row-reset">{caption}</span>} + {/* The signed-out row's own action (story 47): the same login + flow the account ring drives. The row is a div for these — + a nested button can never live inside the row button. */} + {signedOut && ( + <button + type="button" + className="failover-row-sign-in" + onClick={() => { + onSignIn(account); + }} + > + Sign in again + </button> + )} + </span> + ); + if (signedOut) { + // A signed-out row is listed, never selected: a static row + // body with its own sign-in action (no row click at all). + return ( + <div key={account.id} className="menu-row menu-row-static" data-rb-row-key={`failover-row-${ix}`}> + {body} + </div> + ); + } + return ( + <MenuRowNav + key={account.id} + fadeKey={`failover-row-${ix}`} + aria-disabled={switching || undefined} + onClick={() => { + if (!switching) { + onSwitch(account.id); + } + }} + > + {body} + </MenuRowNav> + ); + })} + </div> + {loading && <div className="picker-empty-note">Checking accounts…</div>} + {/* The all-chats warning (spec story 53): switching is per HARNESS, + not per chat. */} + <div className="failover-warning"> + Switching applies to every chat on this harness, not just this one. + </div> + </div> + ); +} diff --git a/web/packages/app/src/components/sign-in-again.tsx b/web/packages/app/src/components/sign-in-again.tsx new file mode 100644 index 0000000000..8154b50366 --- /dev/null +++ b/web/packages/app/src/components/sign-in-again.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EngineClient } from "@roboco/engine-client"; +import type { AgentAccount, AgentLoginStart, HarnessId } from "@roboco/proto"; +import { loginOptions, pollAgentLogin, providerName, signInOptionFor, startAgentLogin, completeAgentLogin, cancelAgentLogin } from "../lib/accounts"; + +/** + * The inline "Sign in again" flow — the web peer of the desktop + * `AccountUsage`'s `SignInFlow` (account_usage.rs:57-105) and its + * `sign_in_footer`. The engine's own login (`StartAgentLogin`), driven + * inside the card that offered the action: the browser opens, the row + * reports progress, and a landed sign-in re-probes (the caller reloads its + * list — fresh credentials flip the row back to signed in). + * + * Shared by every surface that lists a signed-out account with the action: + * the composer footer's account ring (the desktop's home for it), the + * Limited banner's failover picker (stories 47/50), and — dialog-shaped — + * Settings → Accounts (its own `LoginDialog`). + */ + +/** The flow's live state (desktop `SignInFlow`): null = idle. */ +export type SignInAgainFlow = + | { kind: "starting"; harness: HarnessId; provider: string | null } + | { kind: "failed"; harness: HarnessId; provider: string | null; error: string } + | { kind: "browser"; harness: HarnessId; provider: string | null; start: AgentLoginStart; message: string | null; error: string | null } + | { kind: "paste-code"; harness: HarnessId; provider: string | null; start: AgentLoginStart; submitting: boolean; error: string | null }; + +export interface UseSignInAgainFlowOptions { + readonly client: EngineClient | null; + /** The device whose accounts are listed (the settings switcher's passthrough). */ + readonly targetDeviceId?: string | null; + /** A landed sign-in: the caller re-probes (the row flips back to signed in). */ + readonly onLanded: () => void; +} + +export interface SignInAgainHandle { + readonly flow: SignInAgainFlow | null; + /** Begin the flow for one account (the row's "Sign in again" press). */ + readonly begin: (account: AgentAccount) => void; + /** Cancel the flow (the engine kills it; the CLI child is reaped). */ + readonly cancel: () => void; + /** Submit the pasted code of a paste-code sign-in. */ + readonly submitCode: (code: string) => void; +} + +/** + * The flow's state machine. The browser wait loop is the lib's + * `pollAgentLogin` (1.5s PollAgentLogin until `done`/`error`), cancelled + * when the flow goes away — a dismissed popover stops polling. + */ +export function useSignInAgainFlow(options: UseSignInAgainFlowOptions): SignInAgainHandle { + const [flow, setFlow] = useState<SignInAgainFlow | null>(null); + const { client, targetDeviceId, onLanded } = options; + // The landed callback is a closure callers rebuild per render; the loop + // must not re-arm on its identity, so it rides a ref. The flow rides one + // too: `cancel` reads the live state without a state-updater side + // effect (StrictMode runs updaters twice). + const onLandedRef = useRef(onLanded); + onLandedRef.current = onLanded; + const flowRef = useRef<SignInAgainFlow | null>(null); + flowRef.current = flow; + + /** Begin: pre-open a blank tab inside the click gesture (popup blockers + * treat a post-await `window.open` as un-gestured — the settings page's + * add-account trick), start the login, route the tab to the page. */ + const begin = useCallback( + (account: AgentAccount) => { + if (client === null || flow !== null) { + return; + } + const provider = signInOptionFor(account); + const tab = window.open("about:blank", "_blank"); + setFlow({ kind: "starting", harness: account.harness, provider }); + void (async () => { + try { + const start = await startAgentLogin(client, account.harness, targetDeviceId, provider); + if (start.cliOpensBrowser) { + // The engine machine's CLI already opened the page — one tab total. + tab?.close(); + } else if (tab !== null) { + tab.location.href = start.url; + tab.opener = null; + } else { + // Hard blocker ate the pre-open — retry the old direct open. + window.open(start.url, "_blank", "noopener,noreferrer"); + } + setFlow( + start.mode === "paste-code" + ? { kind: "paste-code", harness: account.harness, provider, start, submitting: false, error: null } + : { kind: "browser", harness: account.harness, provider, start, message: null, error: null }, + ); + } catch (cause) { + tab?.close(); + setFlow((current) => + current?.kind === "starting" + ? { + kind: "failed", + harness: account.harness, + provider, + error: `Login failed to start: ${cause instanceof Error ? cause.message : String(cause)}`, + } + : current, + ); + } + })(); + }, + [client, flow, targetDeviceId], + ); + + const cancel = useCallback(() => { + const current = flowRef.current; + if ( + current !== null && + (current.kind === "browser" || current.kind === "paste-code") && + client !== null + ) { + // Best-effort; the desktop only debug-logs a failure. + void cancelAgentLogin(client, current.start.loginId, targetDeviceId).catch(() => {}); + } + setFlow(null); + }, [client, targetDeviceId]); + + const submitCode = useCallback( + (code: string) => { + const trimmed = code.trim(); + if (client === null || flow?.kind !== "paste-code" || flow.submitting || trimmed.length === 0) { + return; + } + const loginId = flow.start.loginId; + setFlow({ ...flow, submitting: true, error: null }); + void (async () => { + try { + await completeAgentLogin(client, loginId, trimmed, targetDeviceId); + setFlow(null); + onLandedRef.current(); + } catch (cause) { + setFlow((current) => + current?.kind === "paste-code" + ? { ...current, submitting: false, error: cause instanceof Error ? cause.message : String(cause) } + : current, + ); + } + })(); + }, + [client, flow, targetDeviceId], + ); + + // The browser-flow wait loop, armed only while a browser flow is live. + const browserLoginId = flow?.kind === "browser" ? flow.start.loginId : null; + const activeRef = useRef(true); + useEffect(() => { + activeRef.current = true; + return () => { + activeRef.current = false; + }; + }, []); + useEffect(() => { + if (client === null || browserLoginId === null) { + return; + } + let active = true; + void (async () => { + const poll = await pollAgentLogin( + client, + browserLoginId, + { + isActive: () => active && activeRef.current, + onPending: (message) => { + if (active && message !== null) { + setFlow((current) => (current?.kind === "browser" ? { ...current, message } : current)); + } + }, + }, + targetDeviceId, + ); + if (!active || poll === null) { + return; + } + if (poll.status === "done") { + setFlow(null); + onLandedRef.current(); + } else { + setFlow((current) => + current?.kind === "browser" ? { ...current, error: poll.message ?? "Login failed" } : current, + ); + } + })(); + return () => { + active = false; + }; + }, [client, browserLoginId, targetDeviceId]); + + return { flow, begin, cancel, submitCode }; +} + +/** + * The inline progress card (the desktop's `sign_in_footer`): who is being + * signed in to, the paste-code field when the CLI has no loopback, and its + * failure. Nothing renders while idle. + */ +export function SignInAgainCard({ + flow, + onCancel, + onSubmitCode, +}: { + readonly flow: SignInAgainFlow; + readonly onCancel: () => void; + readonly onSubmitCode: (code: string) => void; +}) { + const [code, setCode] = useState(""); + if (flow.kind === "failed") { + return ( + <div className="sign-in-again-card" role="alert"> + <span className="sign-in-again-who">Signing in to {whoFor(flow.harness, flow.provider)}</span> + <p className="sign-in-again-error">{flow.error}</p> + <div className="sign-in-again-actions"> + <button type="button" className="sign-in-again-cancel" onClick={onCancel}> + Close + </button> + </div> + </div> + ); + } + if (flow.kind === "paste-code") { + return ( + <div className="sign-in-again-card" role="status"> + <span className="sign-in-again-who">Signing in to {whoFor(flow.harness, flow.provider)} — paste the code</span> + <a className="login-dialog-link" href={flow.start.url} target="_blank" rel="noopener noreferrer"> + Reopen the authorization page + </a> + <form + className="sign-in-again-form" + onSubmit={(event) => { + event.preventDefault(); + onSubmitCode(code); + }} + > + <input + className="mono" + type="text" + placeholder="Paste the authorization code" + value={code} + onChange={(event) => setCode(event.target.value)} + autoComplete="off" + spellCheck={false} + aria-label="Authorization code" + /> + <div className="sign-in-again-actions"> + <button type="button" className="sign-in-again-cancel" onClick={onCancel}> + Cancel + </button> + <button + type="submit" + className="sign-in-again-submit" + disabled={flow.submitting || code.trim().length === 0} + > + {flow.submitting ? "Verifying…" : "Add account"} + </button> + </div> + </form> + {flow.error !== null && <p className="sign-in-again-error">{flow.error}</p>} + </div> + ); + } + return ( + <div className="sign-in-again-card" role="status"> + <span className="sign-in-again-who">Signing in to {whoFor(flow.harness, flow.provider)} — finish in your browser</span> + {flow.kind === "browser" && flow.error === null && ( + <p className="sign-in-again-pending"> + <span className="dot dot-working" /> {flow.message ?? "Waiting for the browser…"} + </p> + )} + {flow.kind === "browser" && flow.error !== null && <p className="sign-in-again-error">{flow.error}</p>} + <div className="sign-in-again-actions"> + <button type="button" className="sign-in-again-cancel" onClick={onCancel}> + Cancel + </button> + </div> + </div> + ); +} + +/** Who is being signed in to (the desktop's `parts()` + label lookup). */ +function whoFor(harness: HarnessId, provider: string | null): string { + return loginOptions(harness).find((option) => option.provider === provider)?.label ?? providerName(harness); +} diff --git a/web/packages/app/src/components/transcript.tsx b/web/packages/app/src/components/transcript.tsx index 3a16cd2d14..612f4ecaf0 100644 --- a/web/packages/app/src/components/transcript.tsx +++ b/web/packages/app/src/components/transcript.tsx @@ -17,7 +17,7 @@ import { Icon } from "@roboco/icons"; import { motion } from "@roboco/theme"; import { cubicBezierEval, useBottomClearance } from "../state/layout"; import { DESKTOP_QUERY, useMediaQuery } from "../state/media"; -import type { ContextUsage, FetchToolBlobReply, SessionMessageEntry } from "@roboco/proto"; +import type { ContextUsage, FetchToolBlobReply, ScheduledResume, SessionLimit, SessionMessageEntry } from "@roboco/proto"; import { echoStore, pendingSendStatus, @@ -27,6 +27,7 @@ import { } from "../state/transcript-store"; import { transcriptFoldCache } from "../state/transcript-fold-state"; import { NoticeChip } from "./notice-chip"; +import { LimitedBanner } from "./limited-banner"; import { useUiSettings } from "../state/ui-settings"; import { CODE_BLOCK_LINE_HEIGHT_BASELINE, @@ -2705,13 +2706,35 @@ export function JumpPill({ onClick }: { onClick: () => void }) { * (`shell.rs:6373`): 24px tall, ALWAYS reserved so the composer below never * shifts, aligned with the composer column (max-width 768, centered, * 24px inner gutters, 11px type). The working loader and the awaiting-input - * surface live elsewhere now; what is left is the error word and the sending - * indicator. + * surface live elsewhere now; what is left is the error word, the sending + * indicator, and — orchestration v2 (ticket 06/07/09's web parity) — the + * Limited banner an errored-with-limit row paints instead of the error + * word: window and reset, Resume, Resume at reset, and the account + * failover picker. */ -export function StatusStrip({ status, sending }: { status: ChatIndicator; sending: boolean }) { +export function StatusStrip({ + status, + sending, + limited, +}: { + status: ChatIndicator; + sending: boolean; + /** The Limited detail the banner needs — null unless the chat reads as + * Limited right now (the caller derives it from the raw session row). */ + limited: LimitedStripProps | null; +}) { return ( <div className="status-strip" role="status"> - {status === "errored" ? ( + {limited !== null ? ( + <LimitedBanner + chatId={limited.chatId} + client={limited.client} + limit={limited.limit} + scheduled={limited.scheduled} + now={limited.now} + onResume={limited.onResume} + /> + ) : status === "errored" ? ( <span className="status-strip-error">Run failed</span> ) : sending ? ( <> @@ -2727,3 +2750,13 @@ export function StatusStrip({ status, sending }: { status: ChatIndicator; sendin </div> ); } + +/** The `StatusStrip`'s Limited arm's inputs (see `StatusStrip`). */ +export interface LimitedStripProps { + readonly chatId: string; + readonly client: EngineClient | null; + readonly limit: SessionLimit | null; + readonly scheduled: ScheduledResume | null; + readonly now: number; + readonly onResume: () => void; +} diff --git a/web/packages/app/src/lib/accounts.ts b/web/packages/app/src/lib/accounts.ts index 8ee0a68607..4e66b001ea 100644 --- a/web/packages/app/src/lib/accounts.ts +++ b/web/packages/app/src/lib/accounts.ts @@ -408,17 +408,67 @@ export function accountInitial(account: AgentAccount): string { return first.length > 0 ? first.toUpperCase() : "?"; } -/** Fallback line when a row has no usage windows (meters XOR this line). */ +/** Fallback line when a row has no usage windows (meters XOR this + * line). The probe's reason — or the sign-out itself when a run error + * marked the account with no probe behind it — wins over the bare + * shrug (accounts.rs's `account_note` arm). */ export function usageFallback(account: AgentAccount, refreshing = false): string { if (!account.switchable && switchesAccounts(account.harness)) { return account.harness === "claude-code" ? "Credentials unavailable" : "Couldn't identify this login"; } - if (account.usageError != null) { - return account.usageError; + const note = accountNote(account); + if (note !== null) { + return note; } return refreshing ? "Checking usage…" : "Usage unavailable"; } +// ── Sign-in state (ticket 08, settings/accounts.rs's pure half) ───────── + +/** + * Whether the account is KNOWN to be signed out — a hard rejection (the + * slot's refresh was refused, a revoked key) or a run's signed-out error + * marked it. Ambiguous "session expired" and never-probed accounts are + * `Unknown`, not signed out. Pure. + */ +export function signedOut(account: AgentAccount): boolean { + return account.signInState === "signed-out"; +} + +/** + * The reason line an account row shows (accounts.rs `account_note`): the + * last probe's failure, or — when a run error marked the account signed out + * with no probe behind it — the sign-out itself. `null` when the account + * has nothing to complain about. Shown BESIDE stale meters, never only when + * they are gone (a cached meter used to hide the sign-out — ticket 08's + * display bug). Pure. + */ +export function accountNote(account: AgentAccount): string | null { + return account.usageError ?? (signedOut(account) ? "Signed out — sign in again" : null); +} + +/** + * The warning a switch to a signed-out account asks about first + * (accounts.rs `signed_out_switch_warning`): the account may not work until + * it is signed in again. Pure. + */ +export function signedOutSwitchWarning(account: AgentAccount): string { + const who = account.email ?? account.displayName ?? "This account"; + return `${who} is signed out. Switching to it now may not work — sign in again first, or switch anyway and sign in after.`; +} + +/** + * The model provider a "Sign in again" for `account` signs in to + * (accounts.rs `sign_in_option_for`): its own provider group for + * per-provider agents, the agent's only login otherwise. Pure. + */ +export function signInOptionFor(account: AgentAccount): string | null { + return ( + loginOptions(account.harness).find((option) => option.provider === (account.provider ?? null)) + ?.provider ?? null + ); +} + // ── Add-account login flows ───────────────────────────────────────────── /** Dialog title for a login flow (LoginFlow::title); a per-provider diff --git a/web/packages/app/src/lib/limited.ts b/web/packages/app/src/lib/limited.ts new file mode 100644 index 0000000000..4795d31568 --- /dev/null +++ b/web/packages/app/src/lib/limited.ts @@ -0,0 +1,208 @@ +import { methods } from "@roboco/engine-client"; +import type { + AgentAccount, + HarnessId, + ResumeAccountCandidates, + ResumeOnAccountOutcome, + ScheduledResume, + SessionLimit, +} from "@roboco/proto"; +import { formatLimitReset, formatScheduledResume } from "./view"; + +/** + * Limited chats on the web — the peer of the desktop's banner/failover + * view-model (shell.rs's Limited arm of `render_status_strip` and its + * pure helpers) plus the thin RPC wrappers the actions go through. The + * engine owns every rule (ADR 0006/0007): the wire stays additive (a + * Limited chat is an *errored* row carrying a `limit` record, ADR 0005), + * the ranking and the durable Resume-at-reset schedule live engine-side; + * this module only expresses what the user asked for and how the reply + * reads. + */ + +/** + * The continue prompt the fixed actions send — `roboco_proto::RESUME_CONTINUE_PROMPT` + * ("Continue where you left off."). Not wiregen'd to TS (it is a const, + * not a type), so the web carries its own copy; the Rust side owns the + * engine's run of the same words. + */ +export const RESUME_CONTINUE_PROMPT = "Continue where you left off."; + +/** How long a loaded candidate list stays fresh while the banner shows. */ +export const FAILOVER_LIST_TTL_MS = 30_000; + +/** + * The harnesses whose logins switch — "Resume on another account" is + * offered for these only (ticket 09). Pure. + */ +export function harnessOffersFailover(harness: HarnessId): boolean { + return harness === "claude-code" || harness === "codex"; +} + +/** The failover picker's title for `harness`. Pure. */ +export function failoverCardTitle(harness: HarnessId): string { + return harness === "codex" + ? "Resume on another Codex account" + : "Resume on another Claude account"; +} + +/** + * The Limited banner's label — the window and its reset ("5-hour limit · + * resets in 45m"), or the armed continuation's fire time instead + * ("5-hour limit · resumes in 45m", ticket 07). An unknown reset says so; + * a missing record keeps the honest generic word. Pure. + */ +export function limitedBannerLabel( + limit: SessionLimit | null, + scheduled: ScheduledResume | null, + now: number, +): string { + if (limit === null) { + return "Usage limit reached"; + } + if (scheduled !== null) { + return `${limit.window} limit · ${formatScheduledResume(scheduled.fireAt, now)}`; + } + const reset = + limit.resetsAt != null ? formatLimitReset(limit.resetsAt, now) : "reset time unknown"; + return `${limit.window} limit · ${reset}`; +} + +/** + * Whether "Resume at reset" is offered (ticket 07): nothing armed yet and + * a reset time still ahead to wait for. A due or unknown reset leaves the + * honest offer as plain Resume. Pure. + */ +export function resumeAtResetArmable( + limit: SessionLimit | null, + scheduled: ScheduledResume | null, + now: number, +): boolean { + return scheduled === null && limit?.resetsAt != null && limit.resetsAt > now; +} + +/** One row of the failover picker (ticket 09): a switch target, or a + * signed-out account offered "Sign in again" instead (stories 47/50 — + * a dead login is listed, never switched to). */ +export type FailoverRow = + | { readonly kind: "candidate"; readonly account: AgentAccount; readonly recommended: boolean } + | { readonly kind: "signedOut"; readonly account: AgentAccount }; + +/** + * The picker's row model (shell.rs `failover_rows`): the ranked candidates + * (best first, the first recommended) followed by the signed-out accounts. + * Pure. + */ +export function failoverRows(list: ResumeAccountCandidates): FailoverRow[] { + const rows: FailoverRow[] = list.candidates.map((account, ix) => ({ + kind: "candidate", + account, + recommended: ix === 0, + })); + for (const account of list.signedOut ?? []) { + rows.push({ kind: "signedOut", account }); + } + return rows; +} + +/** + * Whether the loaded candidate list offers anything — healthy candidates, + * signed-out rows to sign back in, or both. `false` hides the chip: only + * Resume (and Resume at reset) remain (shell.rs `failover_list`). Pure. + */ +export function failoverListOffers(list: ResumeAccountCandidates): boolean { + return list.candidates.length > 0 || (list.signedOut?.length ?? 0) > 0; +} + +/** + * The strip line for a failover outcome (shell.rs `failover_outcome_notice`): + * `Switched` names the account the chat resumed on, `NoCandidates` says why + * nothing else was offered, `Failed` surfaces the reason. Pure. + */ +export function failoverOutcomeNotice(outcome: ResumeOnAccountOutcome): string { + switch (outcome.type) { + case "switched": + return `Resumed on ${outcome.accountEmail ?? "another account"} — continuing where the chat left off.`; + case "noCandidates": + return "No other account is available right now."; + case "failed": + return outcome.reason; + } +} + +/** + * The reset caption under one candidate row's usage meters (story 49, + * shell.rs `candidate_reset_caption`): "session resets in 45m · week + * resets in 2h 05m" — one entry per shown window (the first two) that + * knows its reset. Pure. + */ +export function candidateResetCaption(account: AgentAccount, now: number): string | null { + const resets: string[] = []; + for (const window of account.usageWindows.slice(0, 2)) { + if (window.resetsAt == null) { + continue; + } + const at = Date.parse(window.resetsAt); + if (Number.isFinite(at)) { + resets.push(`${window.label.toLowerCase()} ${formatLimitReset(at, now)}`); + } + } + return resets.length > 0 ? resets.join(" · ") : null; +} + +// ── RPC wrappers ──────────────────────────────────────────────────────── +// All four follow the lib/chat-actions.ts shape: functions take the +// minimal caller so tests drive them without a socket; `EngineClient` +// satisfies it. + +export interface LimitedCaller { + call<T>(method: string, params?: unknown): Promise<T>; +} + +/** + * **Resume at reset** (ticket 07): arm the engine's durable continuation — + * at the limit's reset time the engine sends the continue prompt like the + * manual Resume. The engine owns the schedule; this only expresses the + * user's intent. + */ +export async function scheduleResumeAtReset(caller: LimitedCaller, chatId: string): Promise<void> { + await caller.call(methods.MUTATE, { op: "scheduleResumeAtReset", chatId }); +} + +/** + * Cancel a Limited chat's armed Resume-at-reset continuation (the banner's + * Cancel action). A new message, archive or delete cancels it on its own; + * this is the explicit withdrawal. No-op engine-side when nothing is armed. + */ +export async function cancelScheduledResume(caller: LimitedCaller, chatId: string): Promise<void> { + await caller.call(methods.MUTATE, { op: "cancelScheduledResume", chatId }); +} + +/** + * The ranked switch targets for one Limited chat + * (`ListResumeAccountCandidates`): the engine filters and ranks the + * harness's cached accounts and names the one the limit hit. + */ +export async function listResumeAccountCandidates( + caller: LimitedCaller, + chatId: string, +): Promise<ResumeAccountCandidates> { + return caller.call<ResumeAccountCandidates>(methods.LIST_RESUME_ACCOUNT_CANDIDATES, { chatId }); +} + +/** + * **Resume on another account** (ticket 09): the engine probes the account, + * activates it, retires the chat's warm runtime and dispatches the + * continuation. `accountId` omitted takes the engine's recommendation (the + * best-ranked candidate). + */ +export async function resumeOnAnotherAccount( + caller: LimitedCaller, + chatId: string, + accountId?: string, +): Promise<ResumeOnAccountOutcome> { + return caller.call<ResumeOnAccountOutcome>(methods.RESUME_ON_ANOTHER_ACCOUNT, { + chatId, + ...(accountId !== undefined ? { accountId } : {}), + }); +} diff --git a/web/packages/app/src/lib/notifications.ts b/web/packages/app/src/lib/notifications.ts index fd9c82ba83..0f1f2b9584 100644 --- a/web/packages/app/src/lib/notifications.ts +++ b/web/packages/app/src/lib/notifications.ts @@ -54,7 +54,10 @@ export function sessionNotificationState(session: ChatStatus, now: number): Sess * transition from `prev` to `current` earns. Call after saving the new * baseline — suppressed pings must never be replayed later. Error first * (an error never masquerades as a completion), then input, then the - * completion marker — only fresh, only un-masked by a pending send. + * completion marker — only fresh, only un-masked by a pending send. A + * Limited row counts as the error attention state (proto + * `view::sound_since`): a usage limit ended the turn just as loudly as a + * crash did, and neither re-chimes into the other. * * `notify` is the chat's emit gate (shell.rs's side-chat rule): a chat * the caller cannot see as top-level — a side chat (parent linkage) or a @@ -72,7 +75,12 @@ export function soundSince( if (!notify) { return null; } - if (current.indicator === "errored" && prev.indicator !== "errored") { + // A Limited transition chimes attention like any error — and staying in + // either attention state (limited → errored, errored → limited) never + // re-chimes. + const attention = (indicator: Indicator): boolean => + indicator === "errored" || indicator === "limited"; + if (attention(current.indicator) && !attention(prev.indicator)) { return "attention"; } if (current.indicator === "awaitingInput" && prev.indicator !== "awaitingInput") { diff --git a/web/packages/app/src/lib/view.ts b/web/packages/app/src/lib/view.ts index 8f5ef88591..d67831ca0c 100644 --- a/web/packages/app/src/lib/view.ts +++ b/web/packages/app/src/lib/view.ts @@ -134,8 +134,15 @@ export function spaceDeviceTag( export type ChatIndicator = "working" | "awaitingInput" | "errored" | "completed" | "idle"; -/** The live status dot: none | working | awaitingInput | errored. */ -export type Indicator = "none" | "working" | "awaitingInput" | "errored"; +/** + * The live status dot (proto `view::Indicator`): `limited` is the errored + * row with a recorded usage limit (`session.limit`) — the same status row, + * presented as Limited (reset time known, Resume offered). Not a + * `ChatIndicator` variant: the row-level dot stays errored/idle and the + * Limited marker overrides at the row's render, exactly as the desktop's + * `is_limited` does. + */ +export type Indicator = "none" | "working" | "awaitingInput" | "errored" | "limited"; /** One sidebar row, ready to draw: statuses, project line, branch, time. */ export interface ChatRow { @@ -190,6 +197,14 @@ export interface ChatRow { * in-flight send as `working` (state.rs `display_status_for`). */ readonly sendState: "queued" | "failed" | null; + /** + * LIMITED (orchestration v2, ticket 06): the row reads as Limited right + * now — errored with a recorded usage limit, no send pending — so the + * corner shows the amber "Limited" word instead of the plain status + * word/time (the desktop's `is_limited` override; the dot color rides + * `dot-limited`). + */ + readonly limited: boolean; } /** The view options that shape the sidebar's rows before layout (§2.1). */ @@ -255,7 +270,9 @@ export function unseen(chat: Chat): boolean { /** * Staleness-checked indicator: a Working/AwaitingInput session row older * than SESSION_STALE_MS is dead — a crashed backend must never show an - * eternal "Working". Errored is exempt; Idle is none. + * eternal "Working". Errored is exempt; an errored row carrying a usage- + * limit record reads as `limited` (the additive Limited detail, proto + * `view::effective_indicator`); Idle is none. */ export function effectiveIndicator(session: ChatStatus | undefined, now: number): Indicator { if (session === undefined) { @@ -265,7 +282,7 @@ export function effectiveIndicator(session: ChatStatus | undefined, now: number) case "idle": return "none"; case "errored": - return "errored"; + return session.limit != null ? "limited" : "errored"; case "working": case "awaitingInput": { const updated = Date.parse(session.updatedAt); @@ -277,6 +294,49 @@ export function effectiveIndicator(session: ChatStatus | undefined, now: number) } } +/** + * Banner text for a Limited chat's reset time (proto `format_limit_reset`): + * "resets in 45m", "resets in 2h 05m". A reset already past reads as due — + * the honest offer is Resume, not a countdown into the past. Pure. + */ +export function formatLimitReset(resetsAt: number, now: number): string { + return relativeResetText("resets", resetsAt, now); +} + +/** + * Banner text for a chat's armed Resume-at-reset continuation (proto + * `format_scheduled_resume`): "resumes in 45m", "resumes in 2h 05m". A fire + * time already past reads as due — the continuation is running or about to. + * Pure. + */ +export function formatScheduledResume(fireAt: number, now: number): string { + return relativeResetText("resumes", fireAt, now); +} + +/** + * The shared relative countdown behind both Limited banner texts (proto + * `relative_reset_text`) — one wall-clock deadline, one phrasing, + * verb-parameterized only. Mirrors the Rust rounding exactly: the minutes + * bucket truncates (30s → "0m"), hours floor-divide with a zero-padded + * rest, days floor-divide the hours. + */ +function relativeResetText(verb: string, at: number, now: number): string { + const untilMs = at - now; + if (untilMs <= 0) { + return `${verb} now`; + } + const minutes = Math.floor(untilMs / 60_000); + if (minutes < 60) { + return `${verb} in ${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + if (hours < 24) { + return rest === 0 ? `${verb} in ${hours}h` : `${verb} in ${hours}h ${String(rest).padStart(2, "0")}m`; + } + return `${verb} in ${Math.floor(hours / 24)}d`; +} + /** chat_indicator: live states win, then the seen marker decides. */ export function chatIndicator(chat: Chat, live: ChatStatus | undefined): ChatIndicator { switch (live?.status) { @@ -768,9 +828,18 @@ function toChatRow( const degraded = options.degradedFor?.(chat.id) ?? false; const harness = options.showHarness === false ? null : (chat.config?.harness ?? null); + // `is_limited` (state.rs): the row's Limited marker reads the RAW session + // row, gated by the send overlay the same way `indicator_for` is — a + // send in flight is Working, not Limited, and the record itself is + // cleared when a new turn starts so a stale row never marks. + const session = statusByChat.get(chat.id); + const limited = + !sendTruthFor(sends, now, degraded).pending && + session !== undefined && + effectiveIndicator(session, now) === "limited"; return { chat, - status: sendAwareStatus(chat, statusByChat.get(chat.id), now, sends, degraded), + status: sendAwareStatus(chat, session, now, sends, degraded), project, projectKey: space !== undefined && space.repositoryId != null @@ -788,6 +857,7 @@ function toChatRow( changeRequest: options.showPullRequest === false ? null : (options.changeRequests?.get(chat.id) ?? null), sendState: sendStateChip(sends, now, degraded), + limited, }; } diff --git a/web/packages/app/src/routes/chat-page.tsx b/web/packages/app/src/routes/chat-page.tsx index 50a7f523b1..1a6e641cd2 100644 --- a/web/packages/app/src/routes/chat-page.tsx +++ b/web/packages/app/src/routes/chat-page.tsx @@ -12,7 +12,7 @@ import { engineRegistry, engineStatesOf, useFleetRegistry, useFleetSnapshot } fr import { useNow, useWatchSnapshot } from "../state/hooks"; import { useTitlebar } from "../state/chrome"; import { emitShortcut } from "../state/shortcuts"; -import { chatPageRow, type ChatRow } from "../lib/view"; +import { chatPageRow, effectiveIndicator, type ChatRow } from "../lib/view"; import { JumpPill, StatusStrip, TranscriptView, type JumpButtonState } from "../components/transcript"; import { ChatTranscriptOutlet } from "../components/chat-transcript-outlet"; import type { SubagentOpen } from "../components/tool-group"; @@ -507,6 +507,14 @@ export function ConversationPage() { // sidebar toast (ticket 06 — the desktop's placement). const editFailureRef = useRef<((message: string) => void) | null>(null); + // The composer's **Resume** handle (orchestration v2, ticket 06/10) — the + // same ref pattern: the composer assigns its `resume_limited` (the fixed + // continue prompt through the normal send path, the user's draft handed + // back), and the status strip's Limited banner calls it. The banner lives + // above the composer in the bottom stack; the ref is the one channel that + // reaches the send path without re-mounting anything. + const resumeRef = useRef<(() => void) | null>(null); + // If the queue store reports the row disappeared while we were editing // (another device removed/sent it), drop the edit state so the composer // doesn't carry stale text. @@ -1191,6 +1199,15 @@ export function ConversationPage() { useCallback(() => echoStore.forChat(chatId).length > 0, [chatId]), ); + // The chat's RAW status row — the Limited banner's input. The banner + // reads the live session (errored + a recorded limit), not the + // unseen-marker status the row word carries, exactly like the desktop's + // `Indicator::Limited` arm of `render_status_strip`. + const sessionRow = hasSelection + ? snapshot.statuses.rows.find((row) => row.chatId === chatId) ?? null + : null; + const sessionRowLimited = effectiveIndicator(sessionRow ?? undefined, now) === "limited"; + // The session row's `started_at` — the working trailer's timer base // (`session_for(chat_id).started_at` on the desktop). const turnStartedAt = useMemo(() => { @@ -1401,7 +1418,22 @@ export function ConversationPage() { Terminal surfaces ride a separate, independent host. */} <div className="bottom-stack" ref={bottomStackRef}> - <StatusStrip status={row?.status ?? "idle"} sending={sending} /> + <StatusStrip + status={row?.status ?? "idle"} + sending={sending} + limited={ + hasSelection && sessionRowLimited + ? { + chatId, + client: session?.client ?? null, + limit: sessionRow?.limit ?? null, + scheduled: sessionRow?.scheduledResume ?? null, + now, + onResume: () => resumeRef.current?.(), + } + : null + } + /> {targetUnavailable && ( <div className="composer-target-warning" role="status"> {target.projectUnavailable @@ -1442,6 +1474,7 @@ export function ConversationPage() { onEditCancel={onEditCancel} editCommitRef={editCommitRef} editFailureRef={editFailureRef} + resumeRef={resumeRef} activateLatestQueued={activateLatestQueued} dockFrame={dockFrame} liveDockFrame={liveDockFrame} diff --git a/web/packages/app/src/routes/settings-accounts.tsx b/web/packages/app/src/routes/settings-accounts.tsx index 5798b3eb90..8655db34d1 100644 --- a/web/packages/app/src/routes/settings-accounts.tsx +++ b/web/packages/app/src/routes/settings-accounts.tsx @@ -26,6 +26,7 @@ import { Tooltip } from "../components/ui/Tooltip"; import { accountInitial, accountLabel, + accountNote, activateAgentAccount, addOptionLabel, cancelAgentLogin, @@ -43,6 +44,9 @@ import { providerEmptyCopy, providerNote, PROVIDERS, + signedOut, + signedOutSwitchWarning, + signInOptionFor, startAgentLogin, usageColorVar, usageFallback, @@ -96,6 +100,9 @@ function AccountsSettingsPageBody() { const [busyAccount, setBusyAccount] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null); const [login, setLogin] = useState<LoginFlow | null>(null); + /** A signed-out account whose Switch is asking first (ticket 08, the + * desktop's `confirm_switch`). */ + const [confirmSwitch, setConfirmSwitch] = useState<AgentAccount | null>(null); const now = useNow(30_000); // An engine switch remounts this body (SettingsEnginePage's key), so the @@ -125,6 +132,7 @@ function AccountsSettingsPageBody() { setBusyAccount(null); setActionError(null); setLogin(null); + setConfirmSwitch(null); void load("mount"); }, [load]); @@ -161,10 +169,16 @@ function AccountsSettingsPageBody() { }; }, [client, browserLoginId, load, rpcTarget]); - function accountAction(action: "activate" | "forget", account: AgentAccount) { + function accountAction(action: "activate" | "forget", account: AgentAccount, confirmed = false) { if (client === null || busyAccount !== null) { return; } + // A signed-out switch asks first (ticket 08): the account may not work + // until it is signed in again — the desktop's `confirm_switch`. + if (action === "activate" && !confirmed && signedOut(account)) { + setConfirmSwitch(account); + return; + } setBusyAccount(account.id); setActionError(null); void (async () => { @@ -268,6 +282,7 @@ function AccountsSettingsPageBody() { setLogin(null); setBusyAccount(null); setActionError(null); + setConfirmSwitch(null); } const devices = useMemo(() => settingsDeviceSwitcherRows(registry, fleet), [registry, fleet]); @@ -327,6 +342,7 @@ function AccountsSettingsPageBody() { onAdd={(option) => addAccount(provider.harness, option.provider)} onSwitch={(account) => accountAction("activate", account)} onForget={(account) => accountAction("forget", account)} + onSignInAgain={(account) => addAccount(account.harness, signInOptionFor(account))} /> )) )} @@ -339,6 +355,34 @@ function AccountsSettingsPageBody() { {login !== null && ( <LoginDialog flow={login} onCancel={dismissLogin} onSubmitCode={submitCode} /> )} + + {/* The "switch to a signed-out account" ask (ticket 08) — the same + confirm the desktop's `render_switch_confirm` draws. */} + {confirmSwitch !== null && ( + <Dialog ariaLabel="Switch to a signed-out account?" onClose={() => setConfirmSwitch(null)}> + <DialogCard> + <DialogTitle>Switch to a signed-out account?</DialogTitle> + <DialogBody>{signedOutSwitchWarning(confirmSwitch)}</DialogBody> + <div className="dialog-actions-row"> + <BtnGhost type="button" onClick={() => setConfirmSwitch(null)}> + Cancel + </BtnGhost> + <BtnPrimary + type="button" + onClick={() => { + const account = confirmSwitch; + setConfirmSwitch(null); + if (account !== null) { + accountAction("activate", account, true); + } + }} + > + Switch anyway + </BtnPrimary> + </div> + </DialogCard> + </Dialog> + )} </div> ); } @@ -351,6 +395,7 @@ function ProviderSection({ onAdd, onSwitch, onForget, + onSignInAgain, }: { readonly provider: ProviderDescriptor; readonly loadable: Loadable; @@ -360,6 +405,8 @@ function ProviderSection({ readonly onAdd: (option: LoginOption) => void; readonly onSwitch: (account: AgentAccount) => void; readonly onForget: (account: AgentAccount) => void; + /** "Sign in again" on a signed-out row (ticket 08). */ + readonly onSignInAgain: (account: AgentAccount) => void; }) { const loading = loadable.kind === "loading"; const accounts = loadable.kind === "ready" ? providerAccounts(loadable.snapshot, provider.harness) : []; @@ -403,6 +450,7 @@ function ProviderSection({ now={now} onSwitch={() => onSwitch(account)} onForget={() => onForget(account)} + onSignInAgain={() => onSignInAgain(account)} /> )) )} @@ -414,8 +462,10 @@ function ProviderSection({ /** * One provider account row (accounts.rs:1115-1193): the avatar, the email * label and usage meters, badges, and the inactive-only actions — the - * icon-only Forget and the Switch button. Exported for the mounted row - * suite (tests/account-row.test.ts), like its `LoginDialog` sibling. + * icon-only Forget and the Switch button. A signed-out row carries the + * mark and a "Sign in again" action (ticket 08), and its Switch asks + * first. Exported for the mounted row suite (tests/account-row.test.ts), + * like its `LoginDialog` sibling. */ export function AccountRow({ account, @@ -424,6 +474,7 @@ export function AccountRow({ now, onSwitch, onForget, + onSignInAgain, }: { readonly account: AgentAccount; readonly busy: boolean; @@ -431,7 +482,14 @@ export function AccountRow({ readonly now: number; readonly onSwitch: () => void; readonly onForget: () => void; + /** "Sign in again" on a signed-out row (ticket 08) — re-runs the + * provider's login flow for this account's provider group. */ + readonly onSignInAgain: () => void; }) { + const isSignedOut = signedOut(account); + // The reason line rides BELOW the meters, so a cached meter no longer + // hides it (the desktop's display fix, accounts.rs:1385-1404). + const note = accountNote(account); return ( <div className="settings-row settings-account-row"> <span className="account-avatar" aria-hidden> @@ -446,6 +504,9 @@ export function AccountRow({ {account.usageWindows.map((window, index) => ( <UsageMeter key={index} label={window.label} usedFraction={window.usedFraction} resetsAt={window.resetsAt} now={now} /> ))} + {note !== null && ( + <span className={`account-note ${isSignedOut ? "account-note-signed-out" : ""}`}>{note}</span> + )} </span> )} </div> @@ -453,7 +514,18 @@ export function AccountRow({ <span className="account-badges"> {account.active && <span className="badge badge-active">Active</span>} {account.planLabel != null && <span className="badge">{account.planLabel}</span>} + {/* The mark that keeps a dead login from reading healthy (ticket 08). */} + {isSignedOut && <span className="badge badge-signed-out">Signed out</span>} </span> + {/* "Sign in again" on any SIGNED-OUT row, active or not (accounts.rs + :1294-1312); the inactive-only actions follow. */} + {isSignedOut && ( + <span className="account-actions"> + <button type="button" className="btn btn-solid" disabled={busy} onClick={onSignInAgain}> + Sign in again + </button> + </span> + )} {!account.active && ( <span className="account-actions"> {/* The icon-only trash (accounts.rs:1147-1175): muted at rest, diff --git a/web/packages/app/src/state/escape.ts b/web/packages/app/src/state/escape.ts index 084d294dfc..d1e844cc61 100644 --- a/web/packages/app/src/state/escape.ts +++ b/web/packages/app/src/state/escape.ts @@ -28,8 +28,10 @@ export type EscapeOutcome = | { kind: "ignored" } | { kind: "interruptChat"; chatId: string }; -/** The desktop's `Indicator` — the chat's live, staleness-gated status. */ -export type EscapeIndicator = "working" | "awaitingInput" | "errored" | "completed" | "idle" | null; +/** The desktop's `Indicator` — the chat's live, staleness-gated status + * (the full proto enum, `limited` included: a limited chat is NOT live — + * nothing to interrupt — so it falls to `ignored` like errored). */ +export type EscapeIndicator = "working" | "awaitingInput" | "errored" | "limited" | "completed" | "idle" | null; export interface ShellEscapeInput { readonly key: string; diff --git a/web/packages/app/src/styles/app.css b/web/packages/app/src/styles/app.css index bfa58177e8..e32b9968f2 100644 --- a/web/packages/app/src/styles/app.css +++ b/web/packages/app/src/styles/app.css @@ -193,6 +193,20 @@ a:focus-visible, background: color-mix(in srgb, var(--rb-success) 90%, transparent); } +/* + * LIMITED (orchestration v2): amber — "waiting out a limit", not the + * failure red (proto `view::dot::LIMITED`, oklch(0.766, 0.155, 75)); it + * must read differently from Errored at a glance so a limited chat + * stands out in the list. Same mix as the queued-send dot. + */ +.dot-limited { + background: color-mix(in srgb, var(--rb-warning) 80%, transparent); +} + +.status-limited { + color: var(--rb-warning); +} + .dot-idle { background: color-mix(in srgb, var(--rb-text) 14%, transparent); } @@ -9007,6 +9021,236 @@ button.files-save-pill:hover { flex: none; } +/* ── The Limited banner (orchestration v2, the strip's Limited arm) ──── */ + +.limited-banner { + display: flex; + align-items: center; + gap: var(--rb-space-sm); + min-width: 0; + color: var(--rb-warning); +} + +.limited-banner-dot { + width: 6px; + height: 6px; + flex: none; + border-radius: 50%; + background: var(--rb-warning); +} + +.limited-banner-label { + flex: none; +} + +/* + * The banner's action chips (shell.rs's bordered strips): the strip itself + * is pointer-transparent so the transcript scrolls beneath it — every + * interactive element opts back in, and the disabled state keeps the + * layout while the engine is unreachable. + */ +.limited-banner-chip { + flex: none; + display: inline-flex; + align-items: center; + padding: 1px 10px; + border-radius: calc(var(--rb-radius-control) * 0.5); + border: 1px solid color-mix(in srgb, var(--rb-warning) 45%, transparent); + color: color-mix(in srgb, var(--rb-warning) 90%, transparent); + font-size: 11px; + background: transparent; + cursor: pointer; + pointer-events: auto; +} + +.limited-banner-chip:hover:not(:disabled) { + background: color-mix(in srgb, var(--rb-warning) 12%, transparent); +} + +.limited-banner-chip:disabled { + opacity: 0.5; + cursor: default; +} + +.limited-banner-notice { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: color-mix(in srgb, var(--rb-warning) 75%, transparent); +} + +/* + * The failover picker (`failover_card`): the account ring card's geometry + * with the candidate rows' own caption and nested sign-in action. + */ +.failover-card { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 4px; +} + +/* While a switch runs, the rows read inert (the desktop's 0.6 opacity). */ +.failover-card [data-switching] .menu-row { + opacity: 0.6; +} + +.failover-card .menu-row-static { + cursor: default; +} + +.failover-card .menu-row-static:hover { + background: transparent; + color: color-mix(in srgb, var(--rb-text) 90%, transparent); +} + +.failover-row-main { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.failover-row-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + font-weight: 500; + text-align: left; +} + +.failover-row-meta { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 11px; + color: var(--rb-text-faint); +} + +.failover-row-plan { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.failover-row-recommended { + color: var(--rb-accent); +} + +.failover-row-signed-out { + color: var(--rb-danger); +} + +.failover-row-reset { + font-size: 10.5px; + color: var(--rb-text-muted); +} + +/* The signed-out row's own action (story 47): the accent wash chip, + nested inside an inert row so its click stops there. */ +.failover-row-sign-in { + align-self: flex-start; + margin-top: 2px; + padding: 3px 8px; + border: none; + border-radius: 6px; + background: color-mix(in srgb, var(--rb-accent) 14%, transparent); + color: var(--rb-accent); + font-size: 11.5px; + cursor: pointer; +} + +.failover-warning { + margin: 6px 8px; + padding: 6px 8px; + border-radius: 6px; + background: rgb(var(--rb-wash) / 0.06); + font-size: 11.5px; + color: var(--rb-text-muted); +} + +/* + * The inline "Sign in again" progress (the desktop's `sign_in_footer`): + * a compact wash card under whichever rows offered the action — the + * browser wait, the paste-code field, and the failure all in place. + */ +.sign-in-again-card { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 8px; + padding: 6px 8px; + border-radius: 6px; + background: rgb(var(--rb-wash) / 0.06); + font-size: 11.5px; +} + +.sign-in-again-who { + color: var(--rb-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sign-in-again-form { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sign-in-again-form input { + width: 100%; +} + +.sign-in-again-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.sign-in-again-cancel { + padding: 3px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rb-text-muted); + font-size: 11.5px; + cursor: pointer; +} + +.sign-in-again-submit { + padding: 3px 8px; + border: none; + border-radius: 6px; + background: color-mix(in srgb, var(--rb-accent) 14%, transparent); + color: var(--rb-accent); + font-size: 11.5px; + cursor: pointer; +} + +.sign-in-again-submit:disabled { + opacity: 0.5; + cursor: default; +} + +.sign-in-again-pending { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + color: var(--rb-text-muted); +} + +.sign-in-again-error { + margin: 0; + color: var(--rb-danger); +} + /* ── MessageRail (rail.rs::render_rail, :411-572) ───────────────────── */ /* @@ -9509,6 +9753,19 @@ button.files-save-pill:hover { color: var(--rb-accent); } +/* + * The signed-out mark (ticket 08): danger-toned word + reason line — a dead + * login never reads healthy beside green meters, and the reason shows + * BESIDE stale meters (the accountNote fix), never only when they're gone. + */ +.account-usage-row-signed-out { + color: var(--rb-danger); +} + +.account-usage-row-reason-signed-out { + color: var(--rb-danger-muted); +} + /* * One compact meter line (`render_usage_meter`): 52px label, an 88px bar, * 34px right-aligned percent. The bar's fill keeps the level color at the @@ -12119,6 +12376,33 @@ button.files-save-pill:hover { color: color-mix(in srgb, var(--rb-success-muted) 90%, transparent); } +/* + * The signed-out mark (ticket 08, `badge_signed_out`): the danger-tinged + * pill that keeps a dead login from reading healthy beside green meters. + */ +.badge-signed-out { + border-color: transparent; + background: color-mix(in srgb, var(--rb-danger) 12%, transparent); + color: color-mix(in srgb, var(--rb-danger-muted) 90%, transparent); +} + +/* + * The reason line under an account's meters (ticket 08's display fix — a + * cached meter no longer hides the sign-out); danger-muted when the row is + * signed out, the fallback line's tone otherwise. + */ +.account-note { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + color: color-mix(in srgb, var(--rb-text-muted) 60%, transparent); +} + +.account-note-signed-out { + color: color-mix(in srgb, var(--rb-danger-muted) 85%, transparent); +} + /* Account rows. */ .settings-account-row { align-items: stretch; diff --git a/web/packages/app/tests/account-row.test.ts b/web/packages/app/tests/account-row.test.ts index b449c74a0b..4d131efb47 100644 --- a/web/packages/app/tests/account-row.test.ts +++ b/web/packages/app/tests/account-row.test.ts @@ -542,6 +542,7 @@ describe("AccountRow (settings) — the Forget affordance (wpn-09)", () => { onForget: () => { settingsForgetCalls.push(account); }, + onSignInAgain: () => {}, }), ); }); diff --git a/web/packages/app/tests/account-usage.test.ts b/web/packages/app/tests/account-usage.test.ts index c46fa0223e..edf32850e7 100644 --- a/web/packages/app/tests/account-usage.test.ts +++ b/web/packages/app/tests/account-usage.test.ts @@ -34,17 +34,23 @@ const h = vi.hoisted(() => { /** The reply the next ActivateAgentAccount serves (defaults to the snapshot). */ let activateReply: AgentAccountsSnapshot | null = null; + /** The reply the next StartAgentLogin serves (the inline sign-in flow). */ + let loginReply: Record<string, unknown> | null = null; + const client = { async call<T>(method: string, params: Record<string, unknown>): Promise<T> { calls.push({ method, params }); if (method === "ActivateAgentAccount" && activateReply !== null) { return activateReply as T; } + if (method === "StartAgentLogin" && loginReply !== null) { + return loginReply as T; + } return snapshot as T; }, } as unknown as EngineClient; - return { calls, client, setSnapshot(next: AgentAccountsSnapshot): void { snapshot = next; }, setActivateReply(next: AgentAccountsSnapshot | null): void { activateReply = next; }, reset(): void { calls.length = 0; activateReply = null; } }; + return { calls, client, setSnapshot(next: AgentAccountsSnapshot): void { snapshot = next; }, setActivateReply(next: AgentAccountsSnapshot | null): void { activateReply = next; }, setLoginReply(next: Record<string, unknown> | null): void { loginReply = next; }, reset(): void { calls.length = 0; activateReply = null; loginReply = null; } }; }); // ── jsdom gaps the mounted popovers hit (account-row.test.ts's set) ───────── @@ -75,9 +81,13 @@ beforeAll(() => { return 0; }) as typeof requestAnimationFrame; } + // The sign-in flow pre-opens a tab inside the click gesture; jsdom's + // window.open is a no-op stub, so pin it to a quiet null. + vi.spyOn(window, "open").mockReturnValue(null); }); afterAll(() => { + vi.restoreAllMocks(); delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; }); @@ -289,3 +299,135 @@ describe("AccountUsageIndicator — the accounts card", () => { expect(Array.from(refreshed!.querySelectorAll(".account-usage-row-in-use")).length).toBe(1); }); }); + +// ── The signed-out rows (ticket 08) ────────────────────────────────────── + +describe("AccountUsageIndicator — the signed-out rows (ticket 08)", () => { + it("marks a signed-out account and keeps its reason beside the meters", async () => { + h.setSnapshot({ + accounts: [ + account({ + id: "codex-dead", + harness: "codex", + active: false, + switchable: true, + email: "dead@example.com", + signInState: "signed-out", + // Stale cached meters: they used to hide the sign-out. + usageWindows: [{ label: "5h", usedFraction: 0.3, resetsAt: null }], + }), + account({ + id: "codex-active", + harness: "codex", + active: true, + switchable: true, + usageWindows: [{ label: "5h", usedFraction: 0.5, resetsAt: null }], + }), + ], + warnings: [], + }); + const handle = mountIndicator("codex"); + await flushLoads(); + press(handle.ring()!); + + const card = handle.card()!; + const deadRow = Array.from(card.querySelectorAll<HTMLElement>(".menu-row")).find((row) => + row.textContent!.includes("dead@example.com"), + )!; + // The mark, the reason (beside the meters, not only when they are + // gone — the display fix), and the sign-in action. + expect(deadRow.querySelector(".account-usage-row-signed-out")!.textContent).toBe("Signed out"); + expect(deadRow.querySelector(".account-usage-row-reason")!.textContent).toBe( + "Signed out — sign in again", + ); + expect(deadRow.querySelector(".account-usage-meters")).not.toBeNull(); + expect(deadRow.querySelector<HTMLButtonElement>(".failover-row-sign-in")!.textContent).toBe( + "Sign in again", + ); + }); + + it("offers Sign in again — the engine's own login flow (story 47)", async () => { + h.setSnapshot({ + accounts: [ + account({ + id: "codex-dead", + harness: "codex", + active: false, + switchable: true, + email: "dead@example.com", + signInState: "signed-out", + usageWindows: [{ label: "5h", usedFraction: 0.9, resetsAt: null }], + }), + ], + warnings: [], + }); + h.setLoginReply({ + loginId: "login-1", + url: "https://example.com/auth", + mode: "paste-code", + cliOpensBrowser: false, + callbackPort: null, + }); + const handle = mountIndicator("codex"); + await flushLoads(); + press(handle.ring()!); + + const card = handle.card()!; + press(card.querySelector<HTMLButtonElement>(".failover-row-sign-in")!); + await flushLoads(); + + // The engine's login started for the harness (the account's own + // provider group — null for Codex's single login). + expect(h.calls.find((call) => call.method === "StartAgentLogin")?.params).toMatchObject({ + harness: "codex", + }); + // The paste-code fallback renders inline, exactly where the ring is. + const flow = handle.card()!.querySelector<HTMLElement>(".sign-in-again-card"); + expect(flow).not.toBeNull(); + expect(flow!.textContent).toContain("Signing in to Codex — paste the code"); + }); + + it("a row click asks before switching to a signed-out account; Switch anyway activates", async () => { + const dead = account({ + id: "codex-dead", + harness: "codex", + active: false, + switchable: true, + email: "dead@example.com", + signInState: "signed-out", + usageWindows: [{ label: "5h", usedFraction: 0.3, resetsAt: null }], + }); + h.setSnapshot({ accounts: [dead], warnings: [] }); + h.setActivateReply({ accounts: [{ ...dead, active: true }], warnings: [] }); + const handle = mountIndicator("codex"); + await flushLoads(); + press(handle.ring()!); + + // The row's own click (a div for signed-out rows) asks first — no + // ActivateAgentAccount yet. + const deadRow = Array.from(handle.card()!.querySelectorAll<HTMLElement>(".menu-row")).find( + (row) => row.textContent!.includes("dead@example.com"), + )!; + press(deadRow); + await flushLoads(); + expect(h.calls.find((call) => call.method === "ActivateAgentAccount")).toBeUndefined(); + + // The confirm dialog (the desktop's `render_switch_confirm`) portals + // to the body: the warning, Cancel, and Switch anyway. + const dialog = document.querySelector<HTMLElement>( + '.rb-dialog-card[aria-label="Switch to a signed-out account?"]', + ); + expect(dialog).not.toBeNull(); + expect(dialog!.textContent).toContain("Switch to a signed-out account?"); + expect(dialog!.textContent).toContain("dead@example.com is signed out"); + + const confirm = Array.from(dialog!.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Switch anyway", + )!; + press(confirm); + await flushLoads(); + expect(h.calls.find((call) => call.method === "ActivateAgentAccount")?.params).toMatchObject({ + accountId: "codex-dead", + }); + }); +}); diff --git a/web/packages/app/tests/accounts-view.test.ts b/web/packages/app/tests/accounts-view.test.ts index 4793ed2c50..fa9fc36f50 100644 --- a/web/packages/app/tests/accounts-view.test.ts +++ b/web/packages/app/tests/accounts-view.test.ts @@ -4,6 +4,7 @@ import type { AgentAccount, AgentAccountsSnapshot, AgentLoginPoll, HarnessId } f import { accountInitial, accountLabel, + accountNote, activateAgentAccount, addOptionLabel, activeAccount, @@ -25,6 +26,9 @@ import { providerNote, PROVIDERS, reportsUsage, + signInOptionFor, + signedOut, + signedOutSwitchWarning, signsIn, startAgentLogin, switchesAccounts, @@ -407,3 +411,59 @@ describe("signsIn / reportsUsage (the ring's harness gates)", () => { expect(reportsUsage("codex")).toBe(true); }); }); + +// ── Sign-in state (ticket 08, settings/accounts.rs's pure half) ──────────── + +describe("signedOut / accountNote / usageFallback (ticket 08)", () => { + it("only the hard SignedOut state marks — unknown and legacy rows stay quiet", () => { + expect(signedOut(account({ signInState: "signed-out" }))).toBe(true); + expect(signedOut(account({ signInState: "unknown" }))).toBe(false); + expect(signedOut(account({ signInState: "signed-in" }))).toBe(false); + // An older engine's payload without the additive field defaults to + // unknown, never signed out. + const legacy = account({}) as AgentAccount & { signInState?: unknown }; + delete legacy.signInState; + expect(signedOut(legacy)).toBe(false); + }); + + it("accountNote: the probe's reason, else the sign-out itself, else nothing", () => { + expect(accountNote(account({ usageError: "Rate limited — retrying in 2m" }))).toBe( + "Rate limited — retrying in 2m", + ); + // A run error marked the account signed out with no probe behind it. + expect(accountNote(account({ signInState: "signed-out" }))).toBe("Signed out — sign in again"); + expect(accountNote(account({}))).toBeNull(); + }); + + it("usageFallback carries the sign-out when there are no meters to hide it", () => { + expect(usageFallback(account({ signInState: "signed-out", switchable: true }))).toBe( + "Signed out — sign in again", + ); + }); +}); + +describe("signedOutSwitchWarning (ticket 08)", () => { + it("names the account and what switching means", () => { + expect(signedOutSwitchWarning(account({ email: "ada@example.com" }))).toBe( + "ada@example.com is signed out. Switching to it now may not work — sign in again first, or switch anyway and sign in after.", + ); + expect(signedOutSwitchWarning(account({ email: null, displayName: "Ada" }))).toContain( + "Ada is signed out", + ); + expect(signedOutSwitchWarning(account({ email: null, displayName: null }))).toContain( + "This account is signed out", + ); + }); +}); + +describe("signInOptionFor (the Sign in again provider key)", () => { + it("resolves the account's own provider group for per-provider agents", () => { + expect(signInOptionFor(account({ harness: "opencode", provider: "openai" }))).toBe("openai"); + expect(signInOptionFor(account({ harness: "opencode", provider: "github-copilot" }))).toBe("github-copilot"); + expect(signInOptionFor(account({ harness: "pi", provider: "openai-codex" }))).toBe("openai-codex"); + expect(signInOptionFor(account({ harness: "hermes", provider: "nous" }))).toBe("nous"); + // Single-login agents: the agent's only login. + expect(signInOptionFor(account({ harness: "claude-code", provider: null }))).toBeNull(); + expect(signInOptionFor(account({ harness: "codex", provider: null }))).toBeNull(); + }); +}); diff --git a/web/packages/app/tests/limited-banner.test.ts b/web/packages/app/tests/limited-banner.test.ts new file mode 100644 index 0000000000..a5df927728 --- /dev/null +++ b/web/packages/app/tests/limited-banner.test.ts @@ -0,0 +1,447 @@ +// @vitest-environment jsdom + +/** + * The Limited banner, mounted (orchestration v2, ticket 10): the strip's + * Limited arm — the window and reset label, the three fixed actions + * (Resume, Resume at reset + its armed Cancel, the account failover + * picker) — driven against a recording engine client double. The banner is + * the web peer of the desktop's status-strip Limited branch (shell.rs), so + * these mirror its GPUI state tests: the RPC the engine sees is the + * assertion. The mounted-suite idiom (Base UI runs for real in jsdom, its + * portals into document.body and all); no JSX (createElement). + */ + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { EngineClient } from "@roboco/engine-client"; +import type { AgentAccount, ResumeAccountCandidates, ScheduledResume, SessionLimit } from "@roboco/proto"; +import { LimitedBanner } from "../src/components/limited-banner"; +import { StatusStrip } from "../src/components/transcript"; + +const NOW = Date.now(); + +// ── A recording engine client ────────────────────────────────────────────── + +const h = vi.hoisted(() => { + const calls: Array<{ method: string; params: Record<string, unknown> }> = []; + + /** The next ListResumeAccountCandidates reply. */ + let candidatesReply: ResumeAccountCandidates | null = null; + /** The next ResumeOnAnotherAccount reply. */ + let resumeReply: Record<string, unknown> = { type: "switched", accountId: "a2", accountEmail: "bea@example.com" }; + + const client = { + async call<T>(method: string, params: Record<string, unknown>): Promise<T> { + calls.push({ method, params }); + switch (method) { + case "ListResumeAccountCandidates": + return (candidatesReply ?? { chatId: "c", harness: "claude-code", candidates: [], signedOut: [] }) as T; + case "ResumeOnAnotherAccount": + return resumeReply as T; + case "StartAgentLogin": + return { + loginId: "login-1", + url: "https://example.com/auth", + mode: "browser", + cliOpensBrowser: true, + callbackPort: null, + } as T; + default: + return {} as T; + } + }, + } as unknown as EngineClient; + + return { + calls, + client, + setCandidates(next: ResumeAccountCandidates | null): void { + candidatesReply = next; + }, + setResumeReply(next: Record<string, unknown>): void { + resumeReply = next; + }, + reset(): void { + calls.length = 0; + candidatesReply = null; + resumeReply = { type: "switched", accountId: "a2", accountEmail: "bea@example.com" }; + }, + }; +}); + +// ── jsdom gaps the mounted popovers hit (account-usage.test.ts's set) ─────── + +beforeAll(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + if (typeof Element.prototype.scrollIntoView !== "function") { + Element.prototype.scrollIntoView = () => {}; + } + if (typeof globalThis.requestAnimationFrame !== "function") { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 0; + }) as typeof requestAnimationFrame; + } + // The sign-in flow pre-opens a tab inside the click gesture; jsdom's + // window.open is a no-op stub, so pin it to a quiet null. + vi.spyOn(window, "open").mockReturnValue(null); +}); + +afterAll(() => { + vi.restoreAllMocks(); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +// ── The mounted banner harness ───────────────────────────────────────────── + +const mounted: Array<() => void> = []; + +afterEach(() => { + while (mounted.length > 0) { + mounted.pop()!(); + } + document.body.replaceChildren(); + h.reset(); +}); + +interface MountedBanner { + banner(): HTMLDivElement | null; + /** The failover picker's chip (the trigger), when the list offers it. */ + failoverChip(): HTMLButtonElement | null; + /** The failover card, wherever it portals (open only). */ + card(): HTMLElement | null; + pressChip(label: string): void; + unmount(): void; +} + +function mountBanner( + limit: SessionLimit | null, + scheduled: ScheduledResume | null = null, + onResume: () => void = () => {}, +): MountedBanner { + const host = document.createElement("div"); + document.body.appendChild(host); + const root: Root = createRoot(host); + act(() => { + root.render( + createElement(LimitedBanner, { + chatId: "chat-1", + client: h.client, + limit, + scheduled, + now: NOW, + onResume, + }), + ); + }); + const unmount = (): void => { + act(() => { + root.unmount(); + }); + host.remove(); + }; + mounted.push(unmount); + const banner = (): HTMLDivElement | null => document.querySelector<HTMLDivElement>(".limited-banner"); + return { + banner, + failoverChip: () => + Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( + (chip) => chip.textContent === "Resume on another account", + ) ?? null, + card: () => document.querySelector<HTMLElement>(".failover-card"), + pressChip(label: string): void { + const chip = Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( + (candidate) => candidate.textContent === label, + ); + if (chip === undefined) { + throw new Error(`the "${label}" chip did not render`); + } + press(chip); + }, + unmount, + }; +} + +/** A real press pair on `target`: pointerdown (marks the press) then click. */ +function press(target: HTMLElement): void { + act(() => { + target.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + }); +} + +/** Flush the async loads and replies. */ +async function flush(): Promise<void> { + await act(async () => {}); +} + +function claudeLimit(fields: Partial<SessionLimit> = {}): SessionLimit { + return { harness: "claude-code", window: "5-hour", ...fields }; +} + +function account(id: string, fields: Partial<AgentAccount> = {}): AgentAccount { + return { + id, + harness: "claude-code", + email: `${id}@example.com`, + planLabel: null, + active: false, + usageWindows: [], + signInState: "unknown", + switchable: true, + ...fields, + }; +} + +// ── The label and the fixed actions ──────────────────────────────────────── + +describe("LimitedBanner — the label", () => { + it("names the window and its reset", async () => { + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 45 * 60_000 })); + await flush(); + expect(handle.banner()!.querySelector(".limited-banner-label")!.textContent).toBe( + "5-hour limit · resets in 45m", + ); + expect(handle.banner()!.querySelector(".limited-banner-dot")).not.toBeNull(); + }); + + it("an armed continuation names when it fires instead (ticket 07)", async () => { + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 3 * 3600_000 }), { fireAt: NOW + 45 * 60_000 }); + await flush(); + expect(handle.banner()!.querySelector(".limited-banner-label")!.textContent).toBe( + "5-hour limit · resumes in 45m", + ); + }); +}); + +describe("LimitedBanner — Resume at reset (ticket 07)", () => { + it("arms the engine's continuation; the armed state offers Cancel", async () => { + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60 * 60_000 })); + await flush(); + handle.pressChip("Resume at reset"); + expect(h.calls).toEqual([ + { method: "Mutate", params: { op: "scheduleResumeAtReset", chatId: "chat-1" } }, + ]); + handle.unmount(); + + const armed = mountBanner(claudeLimit({ resetsAt: NOW + 3 * 3600_000 }), { fireAt: NOW + 45 * 60_000 }); + await flush(); + // Drop the armed banner's warm-load call — the Cancel press is the + // assertion, not the candidate list. + h.reset(); + armed.pressChip("Cancel"); + expect(h.calls).toEqual([ + { method: "Mutate", params: { op: "cancelScheduledResume", chatId: "chat-1" } }, + ]); + }); + + it("a due or unknown reset offers no Resume at reset — plain Resume only", async () => { + const due = mountBanner(claudeLimit({ resetsAt: NOW - 60_000 })); + await flush(); + expect(() => due.pressChip("Resume at reset")).toThrow(); + expect(due.banner()!.textContent).toContain("resets now"); + }); +}); + +describe("LimitedBanner — Resume", () => { + it("fires the composer's fixed-prompt send (the page's resumeRef)", async () => { + const resumed: string[] = []; + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 }), null, () => resumed.push("sent")); + await flush(); + handle.pressChip("Resume"); + expect(resumed).toEqual(["sent"]); + // The prompt itself is the composer's send path — no RPC from here. + expect(h.calls.filter((call) => call.method === "Mutate")).toEqual([]); + }); +}); + +// ── The failover picker (ticket 09) ──────────────────────────────────────── + +describe("LimitedBanner — Resume on another account", () => { + it("keeps the candidate list warm while the chat is Limited (the load)", async () => { + h.setCandidates({ + chatId: "chat-1", + harness: "claude-code", + limitedAccountId: "a1", + candidates: [account("a2")], + signedOut: [], + }); + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + expect(h.calls.map((call) => call.method)).toContain("ListResumeAccountCandidates"); + expect(handle.failoverChip()).not.toBeNull(); + }); + + it("a non-switchable harness never offers the failover", async () => { + const handle = mountBanner({ harness: "cursor", window: "usage", resetsAt: null, accountId: null }); + await flush(); + await flush(); + expect(handle.failoverChip()).toBeNull(); + expect(h.calls).toEqual([]); + }); + + it("an empty list hides the chip (only Resume and Resume at reset remain)", async () => { + h.setCandidates({ chatId: "chat-1", harness: "claude-code", candidates: [], signedOut: [] }); + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + expect(handle.failoverChip()).toBeNull(); + }); + + it("lists the ranked rows with the recommended mark, and switches on a click", async () => { + h.setCandidates({ + chatId: "chat-1", + harness: "claude-code", + limitedAccountId: "a1", + candidates: [account("a2", { planLabel: "Pro" }), account("a3")], + signedOut: [], + }); + h.setResumeReply({ type: "switched", accountId: "a3", accountEmail: "cass@example.com" }); + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + press(handle.failoverChip()!); + + const card = handle.card(); + expect(card).not.toBeNull(); + expect(card!.querySelector(".menu-heading")!.textContent).toBe("Resume on another Claude account"); + const rows = Array.from(card!.querySelectorAll(".menu-row")); + expect(rows.length).toBe(2); + // The first candidate is the recommendation; the caption says what + // switching means (story 53). + expect(rows[0]!.textContent).toContain("Recommended"); + expect(card!.querySelector(".failover-warning")!.textContent).toBe( + "Switching applies to every chat on this harness, not just this one.", + ); + + press(rows[1]!); + await flush(); + expect(h.calls.find((call) => call.method === "ResumeOnAnotherAccount")?.params).toEqual({ + chatId: "chat-1", + accountId: "a3", + }); + // The outcome rides the strip's notice line. + expect(handle.banner()!.querySelector(".limited-banner-notice")!.textContent).toBe( + "Resumed on cass@example.com — continuing where the chat left off.", + ); + }); + + it("a failed switch surfaces its reason on the notice line", async () => { + h.setCandidates({ + chatId: "chat-1", + harness: "claude-code", + candidates: [account("a2")], + signedOut: [], + }); + h.setResumeReply({ type: "failed", reason: "engine offline" }); + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + press(handle.failoverChip()!); + press(Array.from(handle.card()!.querySelectorAll(".menu-row"))[0]!); + await flush(); + expect(handle.banner()!.querySelector(".limited-banner-notice")!.textContent).toBe("engine offline"); + }); + + it("a signed-out row is listed with the mark and a Sign in again action, never a switch", async () => { + const dead = account("dead", { signInState: "signed-out" }); + h.setCandidates({ + chatId: "chat-1", + harness: "claude-code", + candidates: [account("a2")], + signedOut: [dead], + }); + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + press(handle.failoverChip()!); + + const card = handle.card()!; + const deadRow = Array.from(card.querySelectorAll<HTMLElement>(".menu-row")).find((row) => + row.textContent!.includes("dead@example.com"), + )!; + expect(deadRow.querySelector(".failover-row-signed-out")!.textContent).toBe("Signed out"); + const signIn = deadRow.querySelector<HTMLButtonElement>(".failover-row-sign-in")!; + expect(signIn).not.toBeNull(); + + press(signIn); + await flush(); + // The engine's own login flow (story 47) — never an account switch. + expect(h.calls.find((call) => call.method === "StartAgentLogin")?.params).toMatchObject({ + harness: "claude-code", + }); + expect(h.calls.find((call) => call.method === "ResumeOnAnotherAccount")).toBeUndefined(); + }); +}); + +// ── The strip mounts the banner (the chat page's wiring) ─────────────────── + +describe("StatusStrip — the Limited arm", () => { + it("renders the banner instead of the error word when the chat reads Limited", async () => { + const host = document.createElement("div"); + document.body.appendChild(host); + const root: Root = createRoot(host); + act(() => { + root.render( + createElement( + StatusStrip, + { + status: "errored", + sending: false, + limited: { + chatId: "chat-1", + client: h.client, + limit: claudeLimit({ resetsAt: NOW + 45 * 60_000 }), + scheduled: null, + now: NOW, + onResume: () => {}, + }, + }, + ), + ); + }); + mounted.push(() => { + act(() => { + root.unmount(); + }); + host.remove(); + }); + await flush(); + const strip = document.querySelector<HTMLElement>(".status-strip"); + expect(strip).not.toBeNull(); + expect(strip!.querySelector(".limited-banner")).not.toBeNull(); + expect(strip!.querySelector(".status-strip-error")).toBeNull(); + }); + + it("keeps the plain error word when the limit record is absent", () => { + const host = document.createElement("div"); + document.body.appendChild(host); + const root: Root = createRoot(host); + act(() => { + root.render( + createElement(StatusStrip, { status: "errored", sending: false, limited: null }), + ); + }); + mounted.push(() => { + act(() => { + root.unmount(); + }); + host.remove(); + }); + const strip = document.querySelector<HTMLElement>(".status-strip"); + expect(strip!.querySelector(".status-strip-error")!.textContent).toBe("Run failed"); + expect(strip!.querySelector(".limited-banner")).toBeNull(); + }); +}); diff --git a/web/packages/app/tests/limited-row.test.ts b/web/packages/app/tests/limited-row.test.ts new file mode 100644 index 0000000000..21a474d056 --- /dev/null +++ b/web/packages/app/tests/limited-row.test.ts @@ -0,0 +1,272 @@ +// @vitest-environment jsdom + +/** + * The sidebar row's Limited marker (orchestration v2, ticket 06/10): a chat + * whose session row is errored with a recorded usage limit shows the amber + * "Limited" word and dot in its corner — replacing the plain "Failed" word + * (or the idle time-ago when the chat was seen). The REAL ChatList mounts + * over the real store chain (SidebarStore → UiSettingsStore → jsdom + * localStorage) — the sidebar-row-send-truth mounted-suite idiom, doubled + * the same way; no JSX (createElement). + */ + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { encodeScopedId, type ChatStatus } from "@roboco/engine-client"; +import type { Chat, SessionLimit } from "@roboco/proto"; +import { ChatList } from "../src/components/chat-list"; +import { uiSettings, UI_SETTINGS_STORAGE_KEY } from "../src/state/ui-settings"; + +// ── Controllable doubles (the fleet/session/router layers) ───────────────── + +const h = vi.hoisted(() => { + if ( + typeof HTMLImageElement !== "undefined" && + typeof HTMLImageElement.prototype.decode !== "function" + ) { + (HTMLImageElement.prototype as unknown as { decode: () => Promise<void> }).decode = + () => Promise.resolve(); + } + const engines = [{ key: "eng-1", label: "Local Engine", baseUrl: "local" }]; + const sessions = new Map<string, unknown>(); + const engineEntry = { + key: "eng-1", + info: { deviceId: "dev-1", workspaceScope: "local" as const }, + state: "connected" as "connected" | "reconnecting" | "off", + lastError: null, + generation: 1, + chats: { rows: [] as Chat[], loaded: true, error: null }, + spaces: { rows: [], loaded: true, error: null }, + devices: { rows: [], loaded: true, error: null }, + sessions: { rows: [] as ChatStatus[], loaded: true, error: null }, + }; + const registry = { engines: [engineEntry], configurationError: null }; + const fleetListeners = new Set<() => void>(); + let snapshot = { + generation: 1, + capabilities: [], + chats: engineEntry.chats, + spaces: engineEntry.spaces, + devices: engineEntry.devices, + statuses: engineEntry.sessions, + }; + return { + engines, + sessions, + engineEntry, + registry, + subscribeFleet: (listener: () => void): (() => void) => { + fleetListeners.add(listener); + return () => { + fleetListeners.delete(listener); + }; + }, + getSnapshot: (): typeof snapshot => snapshot, + notifyFleet: (): void => { + snapshot = { ...snapshot, chats: { ...engineEntry.chats } }; + for (const listener of fleetListeners) { + listener(); + } + }, + }; +}); + +vi.mock("../src/state/fleet", async () => { + const { useSyncExternalStore } = await import("react"); + return { + useFleet: () => ({ active: "eng-1", engines: h.engines, configurationError: null }), + useFleetRegistry: () => h.registry, + useFleetSnapshot: (): ReturnType<typeof h.getSnapshot> => + useSyncExternalStore(h.subscribeFleet, h.getSnapshot), + engineStatesOf: () => new Map(), + fleetLocalDeviceId: () => null, + }; +}); + +vi.mock("../src/state/session-provider", () => ({ + useEngineSessions: () => h.sessions, + useEngineSession: () => null, +})); + +vi.mock("@tanstack/react-router", async () => { + const { createElement } = await import("react"); + return { + useNavigate: () => async () => {}, + useParams: () => ({}), + useRouterState: <T,>(opts: { select: (state: unknown) => T }): T => + opts.select({ location: { pathname: "/" } }), + Link: (props: { params?: { chatId?: string }; className?: string; children?: React.ReactNode }) => + createElement( + "a", + { + href: props.params?.chatId === undefined ? "#" : `#/chat/${props.params.chatId}`, + className: props.className, + onClick: (event: MouseEvent) => event.preventDefault(), + }, + props.children, + ), + }; +}); + +beforeAll(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + if (typeof Element.prototype.scrollIntoView !== "function") { + Element.prototype.scrollIntoView = () => {}; + } + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => + setTimeout(() => callback(performance.now()), 0)) as unknown as typeof requestAnimationFrame; + if (typeof Element.prototype.animate !== "function") { + (Element.prototype as unknown as { animate: () => { cancel(): void } }).animate = () => ({ + cancel(): void {}, + }); + } +}); + +afterAll(() => { + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +// ── Fixtures ─────────────────────────────────────────────────────────────── + +function sc(rawId: string): string { + return encodeScopedId("eng-1", rawId); +} + +function chat(rawId: string, fields: Partial<Chat> = {}): Chat { + return { + id: sc(rawId), + deviceId: "dev-1", + title: `Chat ${rawId}`, + archived: false, + cwd: null, + branch: null, + checkoutId: null, + config: null, + lastMessagePreview: null, + lastMessageAt: "2026-10-16T12:04:00Z", + createdAt: "2026-10-16T12:04:00Z", + ...fields, + }; +} + +function limit(fields: Partial<SessionLimit> = {}): SessionLimit { + return { harness: "claude-code", window: "5-hour", ...fields }; +} + +function status(rawId: string, fields: Partial<ChatStatus> = {}): ChatStatus { + return { + chatId: sc(rawId), + deviceId: "dev-1", + status: "errored", + lastCompletedTurn: null, + startedAt: null, + updatedAt: new Date().toISOString(), + limit: null, + scheduledResume: null, + ...fields, + }; +} + +const mounted: Array<() => void> = []; + +afterEach(() => { + while (mounted.length > 0) { + mounted.pop()!(); + } + document.body.replaceChildren(); + h.sessions.clear(); + uiSettings.updateImmediate({ sidebarPinnedSessionIdsByProfile: {} }); + localStorage.removeItem(UI_SETTINGS_STORAGE_KEY); +}); + +function mountChatList(): HTMLElement { + // The detailed card (compact off) carries the corner the stories read. + uiSettings.updateImmediate({ sidebarCompact: false }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(createElement(ChatList)); + }); + mounted.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + return container; +} + +/** One chat's corner, by raw chat id. */ +function cornerOf(container: HTMLElement, rawId: string): HTMLElement | null { + const link = Array.from(container.querySelectorAll<HTMLElement>("a.chat-row")).find( + (el) => el.getAttribute("href") === `#/chat/${sc(rawId)}`, + ); + return link?.querySelector<HTMLElement>(".chat-row-corner") ?? null; +} + +describe("the sidebar row's Limited marker (mounted)", () => { + it("a limit record turns the corner amber: word Limited, dot-limited (story 43)", () => { + h.engineEntry.chats.rows = [chat("a"), chat("b")]; + h.engineEntry.sessions.rows = [ + status("a", { limit: limit({ resetsAt: Date.now() + 45 * 60_000 }) }), + // The untouched chat keeps its own corner word (unseen → Done). + status("b", { status: "idle", lastCompletedTurn: "t1" }), + ]; + const container = mountChatList(); + const corner = cornerOf(container, "a"); + expect(corner!.textContent).toBe("Limited"); + expect(corner!.querySelector(".dot-limited")).not.toBeNull(); + expect(cornerOf(container, "b")!.textContent).toBe("Done"); + }); + + it("a seen Limited chat still shows the word — the time-ago stands down", () => { + h.engineEntry.chats.rows = [ + chat("a", { lastSeenAt: "2026-10-16T12:04:01Z" }), + ]; + h.engineEntry.sessions.rows = [status("a", { limit: limit() })]; + const container = mountChatList(); + // Unseen would read "errored" (Failed); seen reads idle — the Limited + // override replaces the time-ago either way, like the desktop's + // `status_label`. + const corner = cornerOf(container, "a"); + expect(corner!.textContent).toBe("Limited"); + expect(corner!.querySelector(".chat-row-time")).toBeNull(); + }); + + it("the same record on any other status is inert — a working row never marks", () => { + h.engineEntry.chats.rows = [chat("a")]; + h.engineEntry.sessions.rows = [ + status("a", { status: "working", limit: limit() }), + ]; + const container = mountChatList(); + const corner = cornerOf(container, "a"); + expect(corner!.textContent).toContain("Working"); + expect(corner!.querySelector(".dot-limited")).toBeNull(); + }); + + it("a plain error keeps the failure red word", () => { + h.engineEntry.chats.rows = [chat("a")]; + h.engineEntry.sessions.rows = [status("a")]; + const container = mountChatList(); + const corner = cornerOf(container, "a"); + expect(corner!.textContent).toBe("Failed"); + expect(corner!.querySelector(".dot-errored")).not.toBeNull(); + }); +}); diff --git a/web/packages/app/tests/limited.test.ts b/web/packages/app/tests/limited.test.ts new file mode 100644 index 0000000000..19bef8ad0f --- /dev/null +++ b/web/packages/app/tests/limited.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import type { EngineClient } from "@roboco/engine-client"; +import type { + AgentAccount, + AgentUsageWindow, + HarnessId, + ResumeAccountCandidates, +} from "@roboco/proto"; +import { + RESUME_CONTINUE_PROMPT, + cancelScheduledResume, + candidateResetCaption, + failoverCardTitle, + failoverListOffers, + failoverOutcomeNotice, + failoverRows, + harnessOffersFailover, + limitedBannerLabel, + listResumeAccountCandidates, + resumeAtResetArmable, + resumeOnAnotherAccount, + scheduleResumeAtReset, +} from "../src/lib/limited"; + +/** + * Limited chats on the web (orchestration v2, ticket 10): the pure + * view-model the banner renders from, and the four RPC wrappers its + * actions go through. Names mirror the Rust tests they port + * (proto/src/view.rs's limited tests, shell.rs's `failover_rows` / + * `failover_outcome_notice` / `candidate_reset_caption`). + */ + +const NOW = Date.parse("2026-10-16T12:00:00Z"); + +/** Epoch ms `secs` after the fixed now. */ +function ms(secs: number): number { + return NOW + secs * 1000; +} + +function limit(fields: Partial<{ harness: HarnessId; window: string; resetsAt: number | null; accountId: string | null }> = {}): { + harness: HarnessId; + window: string; + resetsAt: number | null; + accountId: string | null; +} { + return { + harness: "claude-code", + window: "5-hour", + resetsAt: null, + accountId: null, + ...fields, + }; +} + +function scheduled(fireAt: number): { fireAt: number } { + return { fireAt }; +} + +class FakeCaller { + readonly calls: { method: string; params: unknown }[] = []; + replies: Map<string, unknown> = new Map(); + + async call<T>(method: string, params?: unknown): Promise<T> { + this.calls.push({ method, params }); + const byMethod = this.replies.get(method); + if (byMethod !== undefined) { + return byMethod as T; + } + return {} as T; + } +} + +const CALLER = new FakeCaller() as unknown as EngineClient; + +// ── The continue prompt ────────────────────────────────────────────────── + +describe("RESUME_CONTINUE_PROMPT", () => { + it("is the engine's fixed wording (roboco_proto::RESUME_CONTINUE_PROMPT)", () => { + expect(RESUME_CONTINUE_PROMPT).toBe("Continue where you left off."); + }); +}); + +// ── The banner's label and arms ────────────────────────────────────────── + +describe("limitedBannerLabel", () => { + it("names the window and its reset (resets is relative, never past)", () => { + expect(limitedBannerLabel(limit({ resetsAt: ms(45 * 60) }), null, NOW)).toBe( + "5-hour limit · resets in 45m", + ); + expect(limitedBannerLabel(limit({ resetsAt: ms(3 * 3600 + 5 * 60) }), null, NOW)).toBe( + "5-hour limit · resets in 3h 05m", + ); + // A reset already due: the honest offer is Resume, not a countdown. + expect(limitedBannerLabel(limit({ resetsAt: ms(-600) }), null, NOW)).toBe( + "5-hour limit · resets now", + ); + }); + + it("an armed continuation names when it fires instead (ticket 07)", () => { + expect(limitedBannerLabel(limit({ resetsAt: ms(3 * 3600) }), scheduled(ms(45 * 60)), NOW)).toBe( + "5-hour limit · resumes in 45m", + ); + }); + + it("an unknown reset says so; a missing record keeps the generic word", () => { + expect(limitedBannerLabel(limit(), null, NOW)).toBe("5-hour limit · reset time unknown"); + expect(limitedBannerLabel(null, null, NOW)).toBe("Usage limit reached"); + }); +}); + +describe("resumeAtResetArmable", () => { + it("arms only with a reset still ahead and nothing armed", () => { + expect(resumeAtResetArmable(limit({ resetsAt: ms(60) }), null, NOW)).toBe(true); + expect(resumeAtResetArmable(limit({ resetsAt: ms(-60) }), null, NOW)).toBe(false); + expect(resumeAtResetArmable(limit(), null, NOW)).toBe(false); + expect(resumeAtResetArmable(limit({ resetsAt: ms(60) }), scheduled(ms(60)), NOW)).toBe(false); + expect(resumeAtResetArmable(null, null, NOW)).toBe(false); + }); +}); + +// ── The failover picker's model ────────────────────────────────────────── + +function account(id: string, fields: Partial<AgentAccount> = {}): AgentAccount { + return { + id, + harness: "claude-code", + email: `${id}@example.com`, + planLabel: null, + active: false, + usageWindows: [], + signInState: "unknown", + switchable: true, + ...fields, + }; +} + +function candidates(fields: Partial<ResumeAccountCandidates> = {}): ResumeAccountCandidates { + return { + chatId: "chat-1", + harness: "claude-code", + limitedAccountId: null, + candidates: [], + signedOut: [], + ...fields, + }; +} + +describe("harnessOffersFailover / failoverCardTitle", () => { + it("only the switchable harnesses offer the account failover (ticket 09)", () => { + expect(harnessOffersFailover("claude-code")).toBe(true); + expect(harnessOffersFailover("codex")).toBe(true); + expect(harnessOffersFailover("cursor")).toBe(false); + expect(harnessOffersFailover("pi")).toBe(false); + expect(failoverCardTitle("codex")).toBe("Resume on another Codex account"); + expect(failoverCardTitle("claude-code")).toBe("Resume on another Claude account"); + }); +}); + +describe("failoverRows", () => { + it("candidates first (best = recommended), then the signed-out accounts", () => { + const best = account("best"); + const second = account("second"); + const dead = account("dead", { signInState: "signed-out" }); + const rows = failoverRows(candidates({ candidates: [best, second], signedOut: [dead] })); + expect(rows).toEqual([ + { kind: "candidate", account: best, recommended: true }, + { kind: "candidate", account: second, recommended: false }, + { kind: "signedOut", account: dead }, + ]); + }); + + it("tolerates a payload without the additive signedOut field", () => { + const list = candidates({ candidates: [account("best")] }) as ResumeAccountCandidates & { + signedOut?: AgentAccount[]; + }; + delete list.signedOut; + expect(failoverRows(list)).toEqual([{ kind: "candidate", account: account("best"), recommended: true }]); + }); +}); + +describe("failoverListOffers", () => { + it("both lists empty leaves only Resume (the chip hides)", () => { + expect(failoverListOffers(candidates())).toBe(false); + expect(failoverListOffers(candidates({ candidates: [account("a")] }))).toBe(true); + expect(failoverListOffers(candidates({ signedOut: [account("dead", { signInState: "signed-out" })] }))).toBe(true); + }); +}); + +describe("failoverOutcomeNotice", () => { + it("switched names the account; no candidates explains itself; failed surfaces the reason", () => { + expect( + failoverOutcomeNotice({ type: "switched", accountId: "a1", accountEmail: "ada@example.com" }), + ).toBe("Resumed on ada@example.com — continuing where the chat left off."); + expect(failoverOutcomeNotice({ type: "switched", accountId: "a1", accountEmail: null })).toBe( + "Resumed on another account — continuing where the chat left off.", + ); + expect(failoverOutcomeNotice({ type: "noCandidates" })).toBe( + "No other account is available right now.", + ); + expect(failoverOutcomeNotice({ type: "failed", reason: "engine offline" })).toBe("engine offline"); + }); +}); + +describe("candidateResetCaption", () => { + function windowAt(label: string, resetsAt: string | null, usedFraction = 0.4): AgentUsageWindow { + return { label, usedFraction, resetsAt }; + } + + it("one entry per shown window that knows its reset (story 49)", () => { + const account = { + ...accountRowBase(), + usageWindows: [windowAt("Session", new Date(ms(45 * 60)).toISOString())], + }; + expect(candidateResetCaption(account, NOW)).toBe("session resets in 45m"); + }); + + it("joins two shown windows and drops unknown resets", () => { + const account = { + ...accountRowBase(), + usageWindows: [ + windowAt("Session", new Date(ms(45 * 60)).toISOString()), + windowAt("Week", new Date(ms(2 * 86_400 + 3 * 3600)).toISOString()), + windowAt("Bonus", null), + ], + }; + expect(candidateResetCaption(account, NOW)).toBe( + "session resets in 45m · week resets in 2d", + ); + }); + + it("ignores the windows past the two the meters show", () => { + const account = { + ...accountRowBase(), + usageWindows: [windowAt("A", null), windowAt("B", null), windowAt("C", new Date(ms(60)).toISOString())], + }; + expect(candidateResetCaption(account, NOW)).toBeNull(); + }); + + it("is null with nothing to say", () => { + expect(candidateResetCaption({ ...accountRowBase(), usageWindows: [] }, NOW)).toBeNull(); + }); + + function accountRowBase(): AgentAccount { + return account("metered"); + } +}); + +// ── The RPC wrappers ───────────────────────────────────────────────────── + +describe("scheduleResumeAtReset / cancelScheduledResume", () => { + it("arm and withdraw the engine's durable continuation (ticket 07)", async () => { + const caller = new FakeCaller(); + await scheduleResumeAtReset(caller, "chat-1"); + await cancelScheduledResume(caller, "chat-1"); + expect(caller.calls).toEqual([ + { method: "Mutate", params: { op: "scheduleResumeAtReset", chatId: "chat-1" } }, + { method: "Mutate", params: { op: "cancelScheduledResume", chatId: "chat-1" } }, + ]); + }); +}); + +describe("listResumeAccountCandidates", () => { + it("asks the engine's ranking RPC with the chat id", async () => { + const caller = new FakeCaller(); + const list = candidates({ candidates: [account("best")] }); + caller.replies.set("ListResumeAccountCandidates", list); + await expect(listResumeAccountCandidates(caller, "chat-1")).resolves.toEqual(list); + expect(caller.calls).toEqual([ + { method: "ListResumeAccountCandidates", params: { chatId: "chat-1" } }, + ]); + }); +}); + +describe("resumeOnAnotherAccount", () => { + it("sends the picked account; omitted takes the engine's recommendation", async () => { + const caller = new FakeCaller(); + caller.replies.set("ResumeOnAnotherAccount", { type: "noCandidates" }); + await resumeOnAnotherAccount(caller, "chat-1", "a1"); + await resumeOnAnotherAccount(caller, "chat-1"); + expect(caller.calls).toEqual([ + { method: "ResumeOnAnotherAccount", params: { chatId: "chat-1", accountId: "a1" } }, + { method: "ResumeOnAnotherAccount", params: { chatId: "chat-1" } }, + ]); + }); +}); + +// A compile-time stand-in: the wrappers accept the real client's shape. +it("satisfies the EngineClient call surface", () => { + expect(typeof scheduleResumeAtReset(CALLER, "chat-1")).toBe("object"); +}); diff --git a/web/packages/app/tests/notifications.test.ts b/web/packages/app/tests/notifications.test.ts index ed2b1f146a..bc093455dc 100644 --- a/web/packages/app/tests/notifications.test.ts +++ b/web/packages/app/tests/notifications.test.ts @@ -65,6 +65,18 @@ test("aRunErrorChimesOnceAndNeverMasqueradesAsCompletion", () => { expect(soundSince(errored, errored, false)).toBeNull(); }); +test("aLimitedTransitionChimesAttentionLikeAnyError (proto view.rs)", () => { + const working = baseline("working", null); + const limited = baseline("limited", null); + expect(soundSince(limited, working, false)).toBe("attention"); + // Staying limited (a heartbeat re-publishing the row) never re-chimes, + // and a later plain error is the same attention state. + expect(soundSince(limited, limited, false)).toBeNull(); + const errored = baseline("errored", null); + expect(soundSince(errored, limited, false)).toBeNull(); + expect(soundSince(limited, errored, false)).toBeNull(); +}); + test("durableConnectivityDegradationChimesOncePerOutage", () => { expect(connectivitySoundSince("connected", "disabled")).toBeNull(); expect(connectivitySoundSince("reconnecting", "connected")).toBe("attention"); diff --git a/web/packages/app/tests/sidebar-pins.test.ts b/web/packages/app/tests/sidebar-pins.test.ts index 528245299a..5061d5a1d8 100644 --- a/web/packages/app/tests/sidebar-pins.test.ts +++ b/web/packages/app/tests/sidebar-pins.test.ts @@ -428,5 +428,6 @@ function chatRows(ids: readonly string[]): ChatRow[] { deviceOffline: false, changeRequest: null, sendState: null, + limited: false, })); } diff --git a/web/packages/app/tests/sidebar-view.test.ts b/web/packages/app/tests/sidebar-view.test.ts index 3d0de3e62e..ce970c95ad 100644 --- a/web/packages/app/tests/sidebar-view.test.ts +++ b/web/packages/app/tests/sidebar-view.test.ts @@ -443,5 +443,6 @@ function chatRows(chats: readonly Chat[]): ChatRow[] { deviceOffline: false, changeRequest: null, sendState: null, + limited: false, })); } diff --git a/web/packages/app/tests/view.test.ts b/web/packages/app/tests/view.test.ts index d5dd1b6ee0..7f302fc881 100644 --- a/web/packages/app/tests/view.test.ts +++ b/web/packages/app/tests/view.test.ts @@ -10,6 +10,8 @@ import { chatPageRow, displayStatus, effectiveIndicator, + formatLimitReset, + formatScheduledResume, mergePendingSpaces, mostUrgent, projectLabel, @@ -85,6 +87,43 @@ describe("effectiveIndicator", () => { expect(effectiveIndicator(status({ status: "idle" }), NOW)).toBe("none"); expect(effectiveIndicator(undefined, NOW)).toBe("none"); }); + + it("an_errored_row_with_a_limit_reads_as_limited (proto view.rs)", () => { + const limit = { harness: "claude-code", window: "5-hour", resetsAt: 0, accountId: null }; + expect(effectiveIndicator(status({ status: "errored", limit }), NOW)).toBe("limited"); + // The same record on any other status is inert — a row that is working + // again (the record cleared on the new turn) never shows a stale + // Limited marker. + expect(effectiveIndicator(status({ status: "working", limit }), NOW)).toBe("working"); + expect(effectiveIndicator(status({ status: "errored", limit: null }), NOW)).toBe("errored"); + // A payload from an older engine without the additive field reads as a + // plain error, never Limited. + const legacy = status({ status: "errored" }) as ChatStatus & { limit?: unknown }; + delete legacy.limit; + expect(effectiveIndicator(legacy, NOW)).toBe("errored"); + }); +}); + +describe("formatLimitReset / formatScheduledResume (proto view.rs)", () => { + it("reset_text_is_relative_and_never_counts_into_the_past", () => { + const at = (secs: number): number => NOW + secs * 1000; + expect(formatLimitReset(at(45 * 60), NOW)).toBe("resets in 45m"); + expect(formatLimitReset(at(30), NOW)).toBe("resets in 0m"); + expect(formatLimitReset(at(3 * 3600 + 5 * 60), NOW)).toBe("resets in 3h 05m"); + expect(formatLimitReset(at(7 * 3600), NOW)).toBe("resets in 7h"); + expect(formatLimitReset(at(2 * 86_400 + 3 * 3600), NOW)).toBe("resets in 2d"); + // A reset already due: the offer is Resume, not a negative countdown. + expect(formatLimitReset(at(-600), NOW)).toBe("resets now"); + expect(formatLimitReset(NOW, NOW)).toBe("resets now"); + }); + + it("scheduled_resume_text_is_relative_and_never_counts_into_the_past", () => { + const at = (secs: number): number => NOW + secs * 1000; + expect(formatScheduledResume(at(45 * 60), NOW)).toBe("resumes in 45m"); + expect(formatScheduledResume(at(3 * 3600 + 5 * 60), NOW)).toBe("resumes in 3h 05m"); + expect(formatScheduledResume(at(2 * 86_400 + 3 * 3600), NOW)).toBe("resumes in 2d"); + expect(formatScheduledResume(at(-600), NOW)).toBe("resumes now"); + }); }); describe("chatIndicator", () => { @@ -403,6 +442,50 @@ describe("send truth on the sidebar row (state.rs display_status_for + render_ch }); }); +describe("the sidebar row's Limited flag (state.rs is_limited)", () => { + const limit = { harness: "claude-code" as const, window: "5-hour", resetsAt: NOW + 45 * 60_000, accountId: null }; + + it("an errored row with a limit reads limited; the plain status stays errored/idle", () => { + const statuses = [status({ chatId: "a", status: "errored", limit })]; + const rows = chatListRows( + [chat({ id: "a", lastMessageAt: "2026-09-16T11:00:00Z" }), chat({ id: "b" })], + [], + statuses, + NOW, + ); + // The row status keeps the unseen-marker derivation (Errored), and the + // marker rides its own field — the desktop's ChatIndicator has no + // Limited variant either. + expect(rows[0]!.status).toBe("errored"); + expect(rows[0]!.limited).toBe(true); + expect(rows[1]!.limited).toBe(false); + }); + + it("a send in flight is Working, never Limited (indicator_for's overlay)", () => { + const statuses = [status({ chatId: "a", status: "errored", limit })]; + const rows = chatListRows([chat({ id: "a" })], [], statuses, NOW, [], { + sendsFor: () => [ + { + messageId: "m-a", + chatId: "a", + startedAtMs: NOW - 1_000, + text: "hello", + attachmentPaths: [], + }, + ], + }); + expect(rows[0]!.status).toBe("working"); + expect(rows[0]!.limited).toBe(false); + }); + + it("the record on any other status never marks the row", () => { + const statuses = [status({ chatId: "a", status: "working", limit })]; + const rows = chatListRows([chat({ id: "a" })], [], statuses, NOW); + expect(rows[0]!.status).toBe("working"); + expect(rows[0]!.limited).toBe(false); + }); +}); + describe("danglingSpaceChatsHiddenOnlyWhenSpacesAreLoaded (ticket 43)", () => { const space = (id: string, name: string | null): Space => ({ id, diff --git a/web/packages/engine-client/src/methods.ts b/web/packages/engine-client/src/methods.ts index eac7775cb5..e8e1d13cc0 100644 --- a/web/packages/engine-client/src/methods.ts +++ b/web/packages/engine-client/src/methods.ts @@ -31,6 +31,14 @@ export const CREATE_PAIRING_LINK = "CreatePairingLink"; export const LIST_AGENT_ACCOUNTS = "ListAgentAccounts"; export const ACTIVATE_AGENT_ACCOUNT = "ActivateAgentAccount"; export const FORGET_AGENT_ACCOUNT = "ForgetAgentAccount"; +/** "Resume on another account" (orchestration v2, ticket 09): the ranked + * switch targets for one Limited chat — `{chatId}` → `ResumeAccountCandidates` + * (the engine filters and ranks; signed-out accounts ride `signedOut`). */ +export const LIST_RESUME_ACCOUNT_CANDIDATES = "ListResumeAccountCandidates"; +/** The failover itself — `{chatId, accountId?}` → `ResumeOnAccountOutcome` + * (`switched | noCandidates | failed`); `accountId` omitted takes the + * engine's recommendation. */ +export const RESUME_ON_ANOTHER_ACCOUNT = "ResumeOnAnotherAccount"; export const START_AGENT_LOGIN = "StartAgentLogin"; export const COMPLETE_AGENT_LOGIN = "CompleteAgentLogin"; export const POLL_AGENT_LOGIN = "PollAgentLogin"; From 7faa328033e6a123c47ed782e5bb3dad7c0bb9c5 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 02:54:51 +0700 Subject: [PATCH 45/96] Completion notices render as system-notice cards (ticket 15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's completion-notice marker (tickets 11-14) renders as a distinct system-notice card on desktop and in the web client — never a user bubble, never markdown prose. Desktop (crates/ui): - MessagePart::CompletionNotice with sides builds one RowKind::CompletionNotice card: a quiet header plus one two-line row per side chat — title + short id, colored outcome verb and one-line reason — and the row itself is the control: clicking emits the new TranscriptEvent::OpenSideChat, which the shell routes to open_chat. - The notice's plain-text sibling part renders no row beside the card (the doc keeps it, so older layouts still read the notice as plain text); a marker with no sides falls back to that text. - The card is excluded from the hover timestamp/copy strip, like the fork and harness-switch seams. Web (web/packages/app): - The same card as a completionNotice row in the row model, rendered by CompletionNoticeRow and styled in app.css; onOpenChat threads from the chat page and the side-chat pane (route navigation to /chat/:chatId), absent on the read-only subagent pane so rows render inert. - Android unchanged by design: the mobile layout core skips unknown parts and the notice's text part carries it as plain text. No wire changes — the marker, NoticeSide and NoticeOutcome landed with tickets 11-14. Tests: GPUI card/row-model/fallback/compact/fingerprint tests in crates/ui/src/transcript.rs and web row-model tests in tests/transcript-completion-notice.test.ts. Not run locally (user override); CI on the PR is the signal. --- crates/ui/src/shell.rs | 7 + crates/ui/src/transcript.rs | 491 +++++++++++++++++- .../app/src/components/surface-registry.tsx | 8 + .../app/src/components/transcript.tsx | 96 +++- web/packages/app/src/lib/transcript.ts | 113 +++- web/packages/app/src/routes/chat-page.tsx | 12 + web/packages/app/src/styles/app.css | 135 +++++ .../transcript-completion-notice.test.ts | 209 ++++++++ 8 files changed, 1056 insertions(+), 15 deletions(-) create mode 100644 web/packages/app/tests/transcript-completion-notice.test.ts diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 51cfe40907..cb90337190 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3830,6 +3830,13 @@ impl Shell { .unwrap_or_else(|| source.id.clone()); self.open_fork_dialog(source, parent, Some(entry_id.clone()), cx); } + // A completion notice's side row (ticket 15): open the side chat + // it reports — the same path a sidebar click takes, so the + // notice works from the main transcript and from an open side + // chat's pane alike. + TranscriptEvent::OpenSideChat { chat_id } => { + self.open_chat(chat_id.clone(), cx); + } } } diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 6fdb953fa1..9700eee57a 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -39,7 +39,10 @@ use gpui::{ TextAlign, TextRun, Window, canvas, div, img, list, point, prelude::*, px, quad, size, }; -use roboco_doc::{MessagePart, MessageRole, MessageStatus, SessionMessageEntry, SubagentStatus}; +use roboco_doc::{ + MessagePart, MessageRole, MessageStatus, NoticeOutcome, NoticeSide, SessionMessageEntry, + SubagentStatus, +}; use roboco_proto::ToolCall; use crate::markdown::mermaid_cache::{self, MermaidCache}; @@ -1059,6 +1062,16 @@ pub enum RowKind { /// harness renders as, resolved once when the row is built. label: SharedString, }, + /// The engine's completion notice (orchestration v2 ticket 15): a + /// distinct system-notice card — one row per side chat, each naming it + /// by title and short id with its outcome and one-line reason and + /// opening that chat on click. Built from the entry's + /// [`MessagePart::CompletionNotice`] marker; the notice's plain-text + /// sibling part stays in the doc (older layouts' fallback) but renders + /// no row beside this one. + CompletionNotice { + sides: Arc<Vec<NoticeSide>>, + }, } /// A transcript row: stable id + content version (diff key) + block payload. @@ -1116,6 +1129,31 @@ fn fnv1a(bytes: &[u8]) -> u64 { hash } +/// The completion-notice row's diff key (ticket 15): every field a notice +/// side carries presentationally — chat id, title, outcome kind, and the +/// one-line detail — hashed together, so a re-delivery re-keys nothing but a +/// changed outcome, a renamed side chat or an edited detail re-splices the +/// card. +fn notice_fingerprint(sides: &[NoticeSide]) -> u64 { + let mut acc = Vec::new(); + for side in sides { + acc.extend_from_slice(side.chat_id.as_bytes()); + acc.push(0); + acc.extend_from_slice(side.title.as_bytes()); + acc.push(0); + acc.push(match side.outcome { + NoticeOutcome::Completed => 0, + NoticeOutcome::Errored => 1, + NoticeOutcome::Stopped => 2, + NoticeOutcome::Interrupted => 3, + NoticeOutcome::WaitingForInput => 4, + }); + acc.extend_from_slice(side.detail.as_deref().unwrap_or("").as_bytes()); + acc.push(0); + } + fnv1a(&acc) +} + fn tool_fingerprint(tools: &[ToolItem], auto_open: bool) -> u64 { let mut acc = Vec::with_capacity(tools.len() * 8 + 1); for t in tools { @@ -1369,6 +1407,17 @@ pub fn rows_for_entry( // position of the FIRST foldable part, so Input/Error chips ahead of it // keep their doc order. let mut compact_group_pos: Option<usize> = None; + // The engine's completion notice (orchestration v2 ticket 15): an entry + // carrying a marker with sides renders as ONE distinct system-notice + // card, and its plain-text sibling part — the same notice as prose, kept + // in the doc so older layouts stay readable — renders no row beside it. + // A marker with no sides (a partial row off a lenient doc decode) is not + // a notice renderable here; its text sibling carries the notice as plain + // text, exactly like an older build. + let has_notice = entry + .parts + .iter() + .any(|part| matches!(part, MessagePart::CompletionNotice { sides, .. } if !sides.is_empty())); let flush_group = |rows: &mut Vec<Row>, group: &mut Vec<ToolItem>, group_ix: &mut usize, last_ix: usize| { @@ -1484,8 +1533,10 @@ pub fn rows_for_entry( other => { // Compact mode: narration text folds into the single group as // a "Wrote" chip — only the reply (the trailing text run) - // stays a row. + // stays a row. A notice's text sibling never folds either: + // the card replaces it in every mode. if compact + && !has_notice && let MessagePart::Text { id: part_id, text } = other && !text.trim().is_empty() && reply_start.is_none_or(|start| part_ix < start) @@ -1507,7 +1558,7 @@ pub fn rows_for_entry( } match other { MessagePart::Text { id: part_id, text } => { - if text.trim().is_empty() { + if text.trim().is_empty() || has_notice { continue; } let key = format!("{}#{}", entry.id, part_id); @@ -1648,11 +1699,33 @@ pub fn rows_for_entry( // intake landed first): the part persists in the doc and // simply renders no row here until the UI port lands. MessagePart::Image { .. } => {} - // The engine's completion notice renders through its - // plain-text sibling part for now (orchestration v2 - // ticket 15 owns the distinct system-notice row); the - // marker itself is structure, not content. - MessagePart::CompletionNotice { .. } => {} + // The engine's completion notice (orchestration v2 + // ticket 15): a marker with sides renders as ONE distinct + // system-notice card — per-side outcome rows that open the + // side chat (the render arm). Its plain-text sibling part + // never renders a row here (`has_notice` above): the text + // stays in the doc for older layouts, so the notice reads + // once, not twice. A marker without sides renders nothing + // — the text sibling then carries the notice as plain + // prose, the older layout's fallback. + MessagePart::CompletionNotice { id: part_id, sides } => { + if sides.is_empty() { + continue; + } + rows.push(Row { + id: format!("{}#{}", entry.id, part_id).into(), + version: notice_fingerprint(sides), + turn_start: false, + kind: RowKind::CompletionNotice { + sides: Arc::new(sides.clone()), + }, + entry_id: entry_id.clone(), + timestamp: None, + copy_text: None, + compact_fold: None, + fork_entry: None, + }); + } } } } @@ -1743,7 +1816,9 @@ pub fn rows_for_entry( && let Some(last) = rows.last_mut() && !matches!( last.kind, - RowKind::ForkMarker { .. } | RowKind::HarnessSwitchMarker { .. } + RowKind::ForkMarker { .. } + | RowKind::HarnessSwitchMarker { .. } + | RowKind::CompletionNotice { .. } ) { last.timestamp = Some(entry.created_at); @@ -3459,6 +3534,12 @@ pub enum TranscriptEvent { chat_id: String, entry_id: String, }, + /// A completion notice's side row (orchestration v2 ticket 15): open + /// the side chat the row reports — select it in the shell, exactly like + /// a sidebar click. `chat_id` is the side chat's full id. + OpenSideChat { + chat_id: String, + }, } impl gpui::EventEmitter<TranscriptEvent> for Transcript {} @@ -7168,6 +7249,9 @@ impl Transcript { RowKind::HarnessSwitchMarker { label } => { harness_switch_marker(label.clone(), &theme) } + RowKind::CompletionNotice { sides } => { + self.render_completion_notice(&row.id, sides, &theme, cx) + } }; // Diagram fences this row just requested start rendering after layout. self.ensure_diagram_worker(cx); @@ -7413,6 +7497,142 @@ impl Transcript { .into_any_element() } + /// The engine's completion notice (orchestration v2 ticket 15): a + /// distinct system-notice card — never a user bubble, never markdown + /// prose. A quiet header announces the notice; below it one row per side + /// chat, the row itself the control that opens the chat: the title and + /// short id on the first line, the colored outcome verb with its + /// one-line reason (the error, the restart reason, the question) on the + /// second, and the spawn chip's trailing "opens elsewhere" glyph at the + /// end. The card is engine structure like the fork and switch seams — + /// no hover timestamp, no copy action, no fork action. + fn render_completion_notice( + &mut self, + row_id: &SharedString, + sides: &Arc<Vec<NoticeSide>>, + theme: &Theme, + cx: &mut Context<Self>, + ) -> AnyElement { + let header = div() + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .flex_none() + .size(px(20.0)) + .rounded(px(6.0)) + .bg(crate::theme::ink(0.09)) + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::INFO_CIRCLE) + .size(px(12.0)) + .text_color(theme.text_muted), + ), + ) + .child( + div() + .flex_none() + .text_size(px(12.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.text_muted) + .child("Completion notice"), + ); + let side_rows = sides.iter().enumerate().map(|(ix, side)| { + let chat_id = side.chat_id.clone(); + let title: SharedString = single_line(&side.title).into(); + let short_id: SharedString = short_chat_id(&side.chat_id).into(); + let outcome = notice_outcome_line(notice_outcome_verb(side.outcome), side.detail.as_deref()); + let outcome_color = notice_outcome_color(side.outcome, theme); + let full_title = title.clone(); + div() + .id(SharedString::from(format!("{row_id}#side{ix}"))) + .min_w_0() + .overflow_hidden() + .rounded(px(6.0)) + .px(px(6.0)) + .py(px(4.0)) + .flex() + .flex_col() + .gap(px(2.0)) + .text_size(px(12.0)) + .cursor_pointer() + .hover(|style| style.bg(crate::theme::ink(0.06))) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(TranscriptEvent::OpenSideChat { + chat_id: chat_id.clone(), + }); + })) + .tooltip(crate::settings::widgets::text_tooltip(format!( + "Open side chat — {full_title}" + ))) + // First line: the side chat's name — title (truncating) with + // its short id, and the "opens elsewhere" glyph at the end. + .child( + div() + .w_full() + .min_w_0() + .flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_color(theme.text.opacity(0.9)) + .font_weight(gpui::FontWeight::MEDIUM) + .child(title), + ) + .child( + div() + .flex_none() + .text_color(theme.text_muted.opacity(0.7)) + .child(short_id), + ) + .child( + crate::icons::icon(crate::icons::ARROW_UP_RIGHT) + .size(px(12.0)) + .text_color(theme.text_muted.opacity(0.6)), + ), + ) + // Second line: the outcome — its verb and one-line reason in + // the outcome's color (the reason truncates; `read_chat`, + // which the agent already saw, carries the full text). + .child( + div() + .w_full() + .min_w_0() + .truncate() + .text_color(outcome_color) + .child(outcome), + ) + }); + div() + .py(px(4.0)) + .w_full() + .child( + div() + .w_full() + .min_w_0() + .overflow_hidden() + .flex() + .flex_col() + .gap(px(4.0)) + .rounded(px(10.0)) + .border_1() + .border_color(crate::theme::hairline(0.08)) + .bg(crate::theme::ink(0.045)) + .px(px(8.0)) + .py(px(8.0)) + .child(header) + .children(side_rows), + ) + .into_any_element() + } + fn copy_message(&mut self, entry_id: SharedString, text: SharedString, cx: &mut Context<Self>) { cx.stop_propagation(); cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); @@ -8573,6 +8793,53 @@ fn fork_marker(source_title: SharedString, theme: &Theme) -> AnyElement { .into_any_element() } +/// Eight-char id prefix — the sidebar's convention, and the form the +/// notice's own text names a side chat by. A shorter id still reads whole, +/// and the cut never splits a character. +fn short_chat_id(id: &str) -> &str { + let mut cut = id.len().min(8); + while cut > 0 && !id.is_char_boundary(cut) { + cut -= 1; + } + &id[..cut] +} + +/// The outcome verb a notice side row shows — the same vocabulary the +/// notice's plain text uses (`side_line` in the engine), so the card and +/// the prose an agent reads agree. +fn notice_outcome_verb(outcome: NoticeOutcome) -> &'static str { + match outcome { + NoticeOutcome::Completed => "finished", + NoticeOutcome::Errored => "failed", + NoticeOutcome::Stopped => "stopped", + NoticeOutcome::Interrupted => "interrupted", + NoticeOutcome::WaitingForInput => "waiting for input", + } +} + +/// `<verb>: <detail>` when the row carries a one-liner, else `<verb>` — the +/// second line of a notice side row. The detail truncates in the layout; +/// the notice text (and `read_chat`) carries the full reason. +fn notice_outcome_line(verb: &str, detail: Option<&str>) -> String { + match detail { + Some(text) if !text.trim().is_empty() => format!("{verb}: {text}"), + _ => verb.to_owned(), + } +} + +/// The color a notice outcome renders in: finished is success, failed is +/// danger, waiting for input is warning, and the user-chosen or +/// restart-lost stops stay muted — quiet, never alarming, exactly like the +/// chip text tones they share a family with. +fn notice_outcome_color(outcome: NoticeOutcome, theme: &Theme) -> gpui::Hsla { + match outcome { + NoticeOutcome::Completed => theme.success_muted, + NoticeOutcome::Errored => theme.danger_muted, + NoticeOutcome::WaitingForInput => theme.warning_muted, + NoticeOutcome::Stopped | NoticeOutcome::Interrupted => theme.text_muted, + } +} + /// A quiet harness-switch seam (t3-orchestration-v2 ticket 24): the same /// divider treatment as the fork seam, one line — the label arrives /// pre-resolved ("Switched to Codex") so nothing here needs a catalog. No @@ -10014,6 +10281,212 @@ mod tests { }); } + /// A notice entry's shape (ticket 12's writer): a SYSTEM entry carrying + /// the structured marker plus the same notice as a plain text part — the + /// fallback older layouts read. + fn notice_entry(id: &str, sides: Vec<NoticeSide>, text: &str) -> SessionMessageEntry { + SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + provider_turn: None, + id: id.into(), + role: MessageRole::System, + parts: vec![ + MessagePart::CompletionNotice { + id: "n0".into(), + sides, + }, + MessagePart::Text { + id: "t0".into(), + text: text.into(), + }, + ], + created_at: 0, + device_id: "dev".into(), + status: Some(MessageStatus::Complete), + continuation_of: None, + } + } + + fn notice_side(chat_id: &str, title: &str, outcome: NoticeOutcome) -> NoticeSide { + NoticeSide { + chat_id: chat_id.into(), + title: title.into(), + outcome, + detail: None, + } + } + + /// The completion notice (orchestration v2 ticket 15): the marker's + /// sides render as ONE distinct system-notice card — one row per side + /// chat — and the plain-text sibling renders nothing beside it: the + /// notice reads once, not twice. + #[gpui::test] + fn a_completion_notice_renders_as_one_card_row_not_markdown( + cx: &mut gpui::TestAppContext, + ) { + let dir = tempfile::tempdir().unwrap(); + cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + let state = cx.new(|_| AppState::new()); + let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); + state.update(cx, |state, _| { + state.selected_chat = Some("chat-a".into()); + state.transcript_replayed = true; + state.transcript = vec![notice_entry( + "notice:1", + vec![ + notice_side("chat-side-1234", "Implement login", NoticeOutcome::Completed), + NoticeSide { + detail: Some("the build command timed out".into()), + ..notice_side("chat-side-5678", "Flaky test", NoticeOutcome::Errored) + }, + ], + "2 side chats reached outcomes:\n- \"Implement login\" (chat-si) finished.\n- \"Flaky test\" (chat-si) failed: the build command timed out.\nRead each chat's full result with the Roboco `read_chat` tool.", + )]; + state.transcript_revision += 1; + }); + transcript.update(cx, |this, cx| this.sync(cx)); + transcript.update(cx, |this, _| { + // One notice row, and nothing else: no markdown rows from the + // text sibling. + assert_eq!(this.rows.len(), 1, "one row — the notice card"); + let RowKind::CompletionNotice { sides } = &this.rows[0].kind else { + panic!("expected a completion notice row"); + }; + assert_eq!(sides.len(), 2); + assert_eq!(sides[0].chat_id, "chat-side-1234"); + assert_eq!(sides[0].title, "Implement login"); + assert_eq!(sides[0].outcome, NoticeOutcome::Completed); + assert_eq!(sides[1].outcome, NoticeOutcome::Errored); + assert_eq!( + sides[1].detail.as_deref(), + Some("the build command timed out") + ); + // The row id keys on the marker part, so replays keep identity. + assert_eq!(this.rows[0].id.as_ref(), "notice:1#n0"); + // A notice is engine structure: no hover timestamp, no copy, + // no fork action. + assert!(this.rows[0].timestamp.is_none()); + assert!(this.rows[0].copy_text.is_none()); + assert!(this.rows[0].fork_entry.is_none()); + }); + }); + } + + /// A marker with no sides (a partial row off a lenient doc decode — the + /// shape an older layout effectively reads) is not a notice this build + /// can render as a card: its text sibling still renders, so the notice + /// stays readable as plain text. + #[test] + fn a_notice_without_sides_falls_back_to_its_plain_text() { + let entry = notice_entry( + "notice:2", + Vec::new(), + "Side chat \"Migration\" (chat-si) was stopped.\nRead the full result with the Roboco `read_chat` tool, chat chat-si.", + ); + let rows = rows_for_entry(&entry, false, false, &mut parse); + assert!(!rows.is_empty(), "the notice text still renders"); + assert!( + rows.iter() + .all(|row| matches!(row.kind, RowKind::Markdown { .. })), + "no notice card without sides — plain markdown rows only" + ); + // The settled last row carries the metadata strip again, exactly + // like any text-bearing system entry. + assert!(rows.last().is_some_and(|row| row.timestamp.is_some())); + } + + /// The card's version re-keys when a side changes — a renamed side chat, + /// a different outcome, a new one-line reason — so the row re-splices + /// instead of painting stale sides. + #[test] + fn a_notice_row_re_keys_when_its_sides_change() { + let entry = notice_entry( + "notice:3", + vec![notice_side("chat-side-1234", "Implement login", NoticeOutcome::Completed)], + "text", + ); + let rows = rows_for_entry(&entry, false, false, &mut parse); + let version = rows[0].version; + assert_eq!( + version, + notice_fingerprint(&[notice_side( + "chat-side-1234", + "Implement login", + NoticeOutcome::Completed + )]) + ); + + let renamed = notice_entry( + "notice:3", + vec![notice_side("chat-side-1234", "Renamed", NoticeOutcome::Completed)], + "text", + ); + assert_ne!(rows_for_entry(&renamed, false, false, &mut parse)[0].version, version); + + let errored = notice_entry( + "notice:3", + vec![NoticeSide { + detail: Some("provider 502".into()), + ..notice_side("chat-side-1234", "Implement login", NoticeOutcome::Errored) + }], + "text", + ); + assert_ne!(rows_for_entry(&errored, false, false, &mut parse)[0].version, version); + + // Identical sides re-key identically: a replay is a no-op splice. + assert_eq!(rows_for_entry(&entry, false, false, &mut parse)[0].version, version); + } + + /// Compact mode never folds the notice into the work accordion: the card + /// is its own row in every mode, and its text sibling stays hidden + /// there too. + #[test] + fn a_notice_row_stays_unfolded_in_compact_mode() { + let entry = notice_entry( + "notice:4", + vec![notice_side("chat-side-1234", "Implement login", NoticeOutcome::WaitingForInput)], + "Side chat \"Implement login\" (chat-si) is waiting for input.", + ); + let rows = rows_for_entry(&entry, false, true, &mut parse); + assert_eq!(rows.len(), 1, "one card row, no work group"); + assert!(matches!(rows[0].kind, RowKind::CompletionNotice { .. })); + assert!(rows[0].compact_fold.is_none()); + } + + /// The side-row helpers: the short id never splits a character, the + /// outcome line appends its reason, and every outcome has a verb. + #[test] + fn notice_side_row_helpers_shape_the_card_lines() { + assert_eq!(short_chat_id("chat-side-1234"), "chat-si"); + assert_eq!(short_chat_id("short"), "short"); + // A multibyte id boundary never panics and still yields whole + // characters. + let wide = "ééééééé"; + assert!(short_chat_id(wide).chars().count() <= 8); + + assert_eq!( + notice_outcome_line("failed", Some("provider 502: bad gateway")), + "failed: provider 502: bad gateway" + ); + assert_eq!(notice_outcome_line("finished", None), "finished"); + // A blank reason reads as no reason. + assert_eq!(notice_outcome_line("failed", Some(" ")), "failed"); + + for (outcome, verb) in [ + (NoticeOutcome::Completed, "finished"), + (NoticeOutcome::Errored, "failed"), + (NoticeOutcome::Stopped, "stopped"), + (NoticeOutcome::Interrupted, "interrupted"), + (NoticeOutcome::WaitingForInput, "waiting for input"), + ] { + assert_eq!(notice_outcome_verb(outcome), verb); + } + } + fn with_tool_group_navigation( cx: &mut gpui::TestAppContext, run: impl FnOnce(Entity<AppState>, Entity<Transcript>, &mut gpui::App), diff --git a/web/packages/app/src/components/surface-registry.tsx b/web/packages/app/src/components/surface-registry.tsx index b86ccae576..9736e889dc 100644 --- a/web/packages/app/src/components/surface-registry.tsx +++ b/web/packages/app/src/components/surface-registry.tsx @@ -462,6 +462,13 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str // the web shape of the desktop's side-chat panel), `/rename` targets THIS // side chat, and the globals navigate like the chat page's. const navigate = useNavigate(); + // A completion notice's side row (ticket 15): open the side chat it + // reports — the route select, the web shape of the desktop's `open_chat` + // (shell/tabs.rs:205). A notice inside a side chat's own pane points at + // ITS side chats the same way. + const onOpenChat = (target: string): void => { + void navigate({ to: "/chat/$chatId", params: { chatId: target } }); + }; const workspaceCommands = useMemo<WorkspaceCommandActions>( () => ({ newChat: () => { @@ -516,6 +523,7 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str indicator={indicator} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onOpenChat={onOpenChat} deliveryDegraded={deliveryDegraded} /> )} diff --git a/web/packages/app/src/components/transcript.tsx b/web/packages/app/src/components/transcript.tsx index 3a16cd2d14..176313ce0b 100644 --- a/web/packages/app/src/components/transcript.tsx +++ b/web/packages/app/src/components/transcript.tsx @@ -17,7 +17,7 @@ import { Icon } from "@roboco/icons"; import { motion } from "@roboco/theme"; import { cubicBezierEval, useBottomClearance } from "../state/layout"; import { DESKTOP_QUERY, useMediaQuery } from "../state/media"; -import type { ContextUsage, FetchToolBlobReply, SessionMessageEntry } from "@roboco/proto"; +import type { ContextUsage, FetchToolBlobReply, NoticeSide, SessionMessageEntry } from "@roboco/proto"; import { echoStore, pendingSendStatus, @@ -52,6 +52,7 @@ import { flavourWord, formatElapsed, formatTimestamp, + noticeOutcomeLine, ownTurnReleasedForRestore, parseForRow, resolveViewportAnchor, @@ -60,6 +61,8 @@ import { SelectionDragTracker, selectionScrollStep, sendingBridge, + shortChatId, + singleLine, SPACE_LG, topGapFor, toolGroupCollapses, @@ -149,6 +152,7 @@ export const TranscriptView = memo(function TranscriptView({ turnStartedAt = null, store: sharedStore = null, onOpenSubagent, + onOpenChat, markdownSurface = null, deliveryDegraded = false, }: { @@ -198,6 +202,12 @@ export const TranscriptView = memo(function TranscriptView({ * (an unbound host can't open tabs). */ onOpenSubagent?: (payload: SubagentOpen) => void; + /** + * A completion notice's side row (`TranscriptEvent::OpenSideChat`, ticket + * 15): open the side chat it reports — the host navigates to it. Absent + * (the subagent pane's read-only doc): the rows render inert, no control. + */ + onOpenChat?: (chatId: string) => void; /** * The markdown host hooks (`RenderOptions` on the desktop): the chat's * workspace root and the internal-link action. Null (default, the subagent @@ -245,6 +255,7 @@ export const TranscriptView = memo(function TranscriptView({ indicator={indicator} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onOpenChat={onOpenChat} markdownSurface={markdownSurface} deliveryDegraded={deliveryDegraded} /> @@ -262,6 +273,7 @@ function TranscriptSurface({ indicator, turnStartedAt, onOpenSubagent, + onOpenChat, markdownSurface, deliveryDegraded, }: { @@ -275,6 +287,7 @@ function TranscriptSurface({ indicator: ChatIndicator | null; turnStartedAt: number | null; onOpenSubagent?: (payload: SubagentOpen) => void; + onOpenChat?: (chatId: string) => void; markdownSurface: MarkdownSurface | null; deliveryDegraded: boolean; }) { @@ -555,6 +568,7 @@ function TranscriptSurface({ trailer={trailerState} onJumpChange={onJumpChange} onOpenSubagent={onOpenSubagent} + onOpenChat={onOpenChat} toolMotion={toolMotion} chatArrival={chatArrival} /> @@ -624,6 +638,8 @@ interface ScrollerProps { readonly onJumpChange?: (state: JumpButtonState) => void; /** A spawn chip's open — the host registers the right-pane tab. */ readonly onOpenSubagent?: (payload: SubagentOpen) => void; + /** A completion notice's side row — the host opens the side chat. */ + readonly onOpenChat?: (chatId: string) => void; /** The surface's tool-group motion store (folds, reveals, blob fetches). */ readonly toolMotion: ToolGroupMotionStore; /** The surface's chat-switch arrival window (ticket 58's ONE predicate). */ @@ -665,6 +681,7 @@ function TranscriptScroller({ trailer, onJumpChange, onOpenSubagent, + onOpenChat, toolMotion, chatArrival, }: ScrollerProps) { @@ -1808,6 +1825,7 @@ function TranscriptScroller({ row={row} hovered={hoveredEntry?.entryId === row.entryId} onOpenSubagent={onOpenSubagent} + onOpenChat={onOpenChat} client={client} deviceId={deviceId} docId={docId} @@ -1987,6 +2005,12 @@ export function estimateRowHeight( // + gap(6) + the 13px title line — an estimate the measurement // corrects on mount. return 67; + case "completionNotice": + // `render_completion_notice` (transcript.rs): the wrapper pads (4+4), + // the card's own (8+8), the 20px header, the 4px gap, and one 42px + // two-line row per side — an estimate the measurement corrects on + // mount. + return 40 + kind.sides.length * 42; } } @@ -2096,6 +2120,7 @@ function RowContent({ row, hovered, onOpenSubagent, + onOpenChat, client, deviceId, docId, @@ -2111,6 +2136,7 @@ function RowContent({ row: TranscriptRow; hovered: boolean; onOpenSubagent?: (payload: SubagentOpen) => void; + onOpenChat?: (chatId: string) => void; client: EngineClient; deviceId: string | null; docId: string; @@ -2165,6 +2191,9 @@ function RowContent({ {kind.kind === "inputChip" && <InputChipRow header={kind.header} resolved={kind.resolved} />} {kind.kind === "errorChip" && <ErrorChipRow message={kind.message} />} {kind.kind === "forkMarker" && <ForkMarkerRow sourceTitle={kind.sourceTitle} />} + {kind.kind === "completionNotice" && ( + <CompletionNoticeRow sides={kind.sides} onOpenChat={onOpenChat} /> + )} {row.timestamp !== null && <RowMeta row={row} visible={hovered} isUserRow={kind.kind === "user"} />} </> ); @@ -2676,6 +2705,71 @@ function ForkMarkerRow({ sourceTitle }: { sourceTitle: string }) { ); } +/** + * The engine's completion notice (orchestration v2 ticket 15) — the web port + * of `render_completion_notice` (transcript.rs): a distinct system-notice + * card, never a user bubble and never markdown prose. A quiet header + * announces the notice; below it one row per side chat, the row itself the + * control that opens the chat: the title and short id on the first line, the + * colored outcome verb with its one-line reason (the error, the restart + * reason, the question) on the second, and the spawn chip's trailing + * "opens elsewhere" glyph at the end. Without a host handler (the read-only + * subagent pane) the rows render inert — no control, no lie. + */ +function CompletionNoticeRow({ + sides, + onOpenChat, +}: { + readonly sides: readonly NoticeSide[]; + readonly onOpenChat?: (chatId: string) => void; +}) { + return ( + <div className="completion-notice-row"> + <div className="completion-notice-card" role="status"> + <div className="completion-notice-header"> + <span className="completion-notice-tile" aria-hidden> + <Icon name="infoCircle" size={12} /> + </span> + <span className="completion-notice-label">Completion notice</span> + </div> + {sides.map((side) => { + const title = singleLine(side.title); + const openable = onOpenChat !== undefined; + const className = `completion-notice-side completion-notice-side-${side.outcome}`; + const body = ( + <> + <span className="completion-notice-side-name"> + <span className="completion-notice-side-title">{title}</span> + <span className="completion-notice-side-id">{shortChatId(side.chatId)}</span> + {openable && <Icon name="arrowUpRight" size={12} className="completion-notice-side-glyph" />} + </span> + <span className="completion-notice-side-outcome">{noticeOutcomeLine(side)}</span> + </> + ); + return openable ? ( + <button + key={side.chatId} + type="button" + className={className} + title={`Open side chat — ${title}`} + onClick={(event) => { + event.stopPropagation(); + onOpenChat?.(side.chatId); + }} + > + {body} + </button> + ) : ( + <div key={side.chatId} className={className}> + {body} + </div> + ); + })} + </div> + </div> + ); +} + // ── Shell chrome published from the transcript surface ────────────────────── /** diff --git a/web/packages/app/src/lib/transcript.ts b/web/packages/app/src/lib/transcript.ts index b46266df5e..dbb7b24ed3 100644 --- a/web/packages/app/src/lib/transcript.ts +++ b/web/packages/app/src/lib/transcript.ts @@ -11,6 +11,7 @@ import type { MessagePart, + NoticeSide, SessionMessageEntry, ToolCall, ToolDiff, @@ -1466,6 +1467,61 @@ export function fnv1a(text: string): number { return Number(BigInt.asIntN(32, hash)); } +/** + * The completion-notice row's diff key (ticket 15): every field a notice + * side carries presentationally — chat id, title, outcome kind, and the + * one-line detail — hashed together, so a re-delivery re-keys nothing but a + * changed outcome, a renamed side chat or an edited detail re-splices the + * card. Mirrors `notice_fingerprint` (transcript.rs). + */ +export function noticeFingerprint(sides: readonly NoticeSide[]): number { + let text = ""; + for (const side of sides) { + text += `${side.chatId}\u0000${side.title}\u0000${side.outcome}\u0000${side.detail ?? ""}\u0000`; + } + return fnv1a(text); +} + +/** + * Eight-character id prefix — the sidebar's convention, and the form the + * notice's own text names a side chat by. Mirrors `short_chat_id` + * (transcript.rs): the cut never splits a surrogate pair. + */ +export function shortChatId(id: string): string { + return Array.from(id).slice(0, 8).join(""); +} + +/** + * The outcome verb a notice side row shows — the same vocabulary the + * notice's plain text uses (`side_line` in the engine), so the card and the + * prose an agent reads agree. Mirrors `notice_outcome_verb` (transcript.rs). + */ +export function noticeOutcomeVerb(side: NoticeSide): string { + switch (side.outcome) { + case "completed": + return "finished"; + case "errored": + return "failed"; + case "stopped": + return "stopped"; + case "interrupted": + return "interrupted"; + case "waitingForInput": + return "waiting for input"; + } +} + +/** + * `<verb>: <detail>` when the row carries a one-liner, else `<verb>` — the + * second line of a notice side row. Mirrors `notice_outcome_line` + * (transcript.rs). + */ +export function noticeOutcomeLine(side: NoticeSide): string { + const verb = noticeOutcomeVerb(side); + const detail = side.detail?.trim() ?? ""; + return detail.length === 0 ? verb : `${verb}: ${detail}`; +} + // --------------------------------------------------------------------------- // Row model (transcript.rs rows_for_entry) // --------------------------------------------------------------------------- @@ -1525,7 +1581,17 @@ export type TranscriptRowKind = * source title is stamped on the part at fork time, so a later rename or * delete of the source never rewrites the seam. */ - | { readonly kind: "forkMarker"; readonly sourceChatId: string; readonly sourceTitle: string }; + | { readonly kind: "forkMarker"; readonly sourceChatId: string; readonly sourceTitle: string } + /** + * The engine's completion notice (orchestration v2 ticket 15, + * `RowKind::CompletionNotice`): a distinct system-notice card — one row + * per side chat, each naming it by title and short id with its outcome + * and one-line reason and opening that chat on click. Built from the + * entry's `completionNotice` marker; the notice's plain-text sibling part + * stays in the doc (older layouts' fallback) but renders no row beside + * this one. + */ + | { readonly kind: "completionNotice"; readonly sides: readonly NoticeSide[] }; /** A transcript row: stable id + content version (diff key) + block payload. */ export interface TranscriptRow { @@ -1679,6 +1745,16 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): // position of the FIRST foldable part, so Input/Error chips ahead of it // keep their doc order. let compactGroupPos: number | null = null; + // The engine's completion notice (orchestration v2 ticket 15): an entry + // carrying a marker with sides renders as ONE distinct system-notice + // card, and its plain-text sibling part — the same notice as prose, kept + // in the doc so older layouts stay readable — renders no row beside it. + // A marker with no sides (a partial row off a lenient doc decode) is not + // a notice renderable here; the text sibling then carries the notice as + // plain text, exactly like an older build. + const hasNotice = entry.parts.some( + (part) => part.kind === "completionNotice" && part.sides.length > 0, + ); const flushGroup = (): void => { if (pendingGroup.length === 0) { @@ -1765,9 +1841,11 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): } // Compact mode: narration text folds into the single group as a "Wrote" - // chip — only the reply (the trailing text run) stays a row. + // chip — only the reply (the trailing text run) stays a row. A notice's + // text sibling never folds either: the card replaces it in every mode. if ( compact && + !hasNotice && part.kind === "text" && part.text.trim().length > 0 && (replyStart === null || partIx < replyStart) @@ -1783,7 +1861,7 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): flushGroup(); } if (part.kind === "text") { - if (part.text.trim().length === 0) { + if (part.text.trim().length === 0 || hasNotice) { return; } const key = `${entry.id}#${part.id}`; @@ -1855,6 +1933,28 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): copyText: null, compactFold: null, }); + return; + } + if (part.kind === "completionNotice") { + // `MessagePart::CompletionNotice` (orchestration v2 ticket 15): the + // notice card. Its version fingerprints every side's chat id, title, + // outcome kind and one-line detail — a rename, a changed outcome or an + // edited reason re-keys the card; a replay re-keys nothing. A marker + // with no sides renders no row: its text sibling carries the notice + // as plain prose (the older layout's fallback). + if (part.sides.length === 0) { + return; + } + rows.push({ + id: `${entry.id}#${part.id}`, + version: noticeFingerprint(part.sides), + turnStart: false, + rowKind: { kind: "completionNotice", sides: part.sides }, + entryId: entry.id, + timestamp: null, + copyText: null, + compactFold: null, + }); } }); if (compact) { @@ -1915,11 +2015,14 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): // Timestamp strip under the entry's LAST row once the turn has settled // ("No timestamp hover mid-stream"). The fork seam carries NO message // metadata lane (transcript.rs:1674-1683 excludes ForkMarker — a quiet - // divider, never a hover target). + // divider, never a hover target); the completion notice joins it + // (transcript.rs excludes CompletionNotice — engine structure, not a + // hover target). if ( !streaming && rows.length > 0 && - rows[rows.length - 1]!.rowKind.kind !== "forkMarker" + rows[rows.length - 1]!.rowKind.kind !== "forkMarker" && + rows[rows.length - 1]!.rowKind.kind !== "completionNotice" ) { const last = rows[rows.length - 1]!; rows[rows.length - 1] = { diff --git a/web/packages/app/src/routes/chat-page.tsx b/web/packages/app/src/routes/chat-page.tsx index 50a7f523b1..b7c168900d 100644 --- a/web/packages/app/src/routes/chat-page.tsx +++ b/web/packages/app/src/routes/chat-page.tsx @@ -437,6 +437,17 @@ export function ConversationPage() { [chatId], ); + // A completion notice's side row (ticket 15): open the side chat — the + // route select, the web shape of the desktop's `open_chat` + // (shell/tabs.rs:205). A chat that left the list still owns its transcript, + // so the route is always the target. + const onOpenChat = useCallback( + (target: string) => { + void navigate({ to: "/chat/$chatId", params: { chatId: target } }); + }, + [navigate], + ); + const crStore = useMemo(() => { if (session === null) { return null; @@ -1376,6 +1387,7 @@ export function ConversationPage() { indicator={row?.status ?? "idle"} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onOpenChat={onOpenChat} deliveryDegraded={deliveryDegraded} /> )} diff --git a/web/packages/app/src/styles/app.css b/web/packages/app/src/styles/app.css index bfa58177e8..82e9696d57 100644 --- a/web/packages/app/src/styles/app.css +++ b/web/packages/app/src/styles/app.css @@ -11209,6 +11209,141 @@ button.files-save-pill:hover { color: var(--rb-text-muted); } +/* + * The engine's completion notice (orchestration v2 ticket 15) — the web + * port of `render_completion_notice` (transcript.rs): a 4px-block wrapper, + * then the card — 10px radius, hairline border, the input chip's quiet ink + * wash (0.045), 8px padding, 4px stack gap. The header is the input chip's + * tile + a 12px medium muted label. Each side row is a two-line control + * (6px radius, 6px/4px padding, 0.06 ink hover wash): title + short id + + * the spawn chip's "opens elsewhere" glyph on the first line, the outcome + * verb and one-line reason on the second — colored by outcome (success for + * finished, danger for failed, warning for waiting for input, muted for + * the stops). Without a host handler (the read-only subagent pane) the + * rows render inert, so only the button form carries the hover. + */ +.completion-notice-row { + padding-block: 4px; + width: 100%; +} + +.completion-notice-card { + width: 100%; + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 4px; + border-radius: 10px; + border: 1px solid rgb(var(--rb-hairline) / calc(0.08 * var(--rb-hairline-scale))); + background: rgb(var(--rb-ink) / 0.045); + padding: 8px; +} + +.completion-notice-header { + display: flex; + align-items: center; + gap: 8px; +} + +.completion-notice-tile { + flex: none; + width: 20px; + height: 20px; + border-radius: var(--rb-radius-control); + background: rgb(var(--rb-ink) / 0.09); + display: flex; + align-items: center; + justify-content: center; + color: var(--rb-text-muted); +} + +.completion-notice-label { + flex: none; + font-size: 12px; + font-weight: 500; + color: var(--rb-text-muted); +} + +.completion-notice-side { + min-width: 0; + overflow: hidden; + display: flex; + flex-direction: column; + gap: 2px; + border-radius: 6px; + padding: 4px 6px; + font: inherit; + font-size: 12px; + text-align: start; +} + +button.completion-notice-side { + border: none; + background: transparent; + color: inherit; + cursor: pointer; + appearance: none; +} + +button.completion-notice-side:hover { + background: rgb(var(--rb-ink) / 0.06); +} + +.completion-notice-side-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; +} + +.completion-notice-side-title { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: color-mix(in srgb, var(--rb-text) 90%, transparent); +} + +.completion-notice-side-id { + flex: none; + color: color-mix(in srgb, var(--rb-text-muted) 70%, transparent); +} + +.completion-notice-side-glyph { + flex: none; + color: color-mix(in srgb, var(--rb-text-muted) 60%, transparent); +} + +.completion-notice-side-outcome { + display: block; + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.completion-notice-side-completed .completion-notice-side-outcome { + color: var(--rb-success-muted); +} + +.completion-notice-side-errored .completion-notice-side-outcome { + color: var(--rb-danger-muted); +} + +.completion-notice-side-waitingForInput .completion-notice-side-outcome { + color: var(--rb-warning-muted); +} + +.completion-notice-side-stopped .completion-notice-side-outcome, +.completion-notice-side-interrupted .completion-notice-side-outcome { + color: var(--rb-text-muted); +} + /* ── Transcript responsive + reduced motion ─────────────────────────── */ @media (max-width: 768px) { diff --git a/web/packages/app/tests/transcript-completion-notice.test.ts b/web/packages/app/tests/transcript-completion-notice.test.ts new file mode 100644 index 0000000000..17f19cef0a --- /dev/null +++ b/web/packages/app/tests/transcript-completion-notice.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import type { MessagePart, NoticeSide, SessionMessageEntry } from "@roboco/proto"; +import { parseMarkdown } from "../src/lib/markdown"; +import { + fnv1a, + noticeFingerprint, + noticeOutcomeLine, + noticeOutcomeVerb, + rowsForEntry, + shortChatId, + singleLine, +} from "../src/lib/transcript"; + +/** + * The completion notice (orchestration v2 ticket 15) — the `completionNotice` + * marker part → the `completionNotice` row (the desktop's transcript.rs + * `MessagePart::CompletionNotice` arm + `RowKind::CompletionNotice`): ONE + * distinct system-notice card per marker, carrying the side-chat rows; the + * entry's plain-text sibling part — the same notice as prose, kept in the doc + * so older layouts stay readable — renders no row beside it. A marker with + * no sides (a partial row off a lenient doc decode) renders nothing, and the + * text sibling then carries the notice as plain text. + */ + +const parse = (_key: string, text: string, live: boolean) => parseMarkdown(text, live); + +function side( + chatId: string, + title: string, + outcome: NoticeSide["outcome"], + detail?: string, +): NoticeSide { + return { chatId, title, outcome, detail: detail ?? null }; +} + +function noticePart(id: string, sides: NoticeSide[]): MessagePart { + return { kind: "completionNotice", id, sides }; +} + +function textPart(id: string, text: string): MessagePart { + return { kind: "text", id, text }; +} + +function entry(id: string, parts: MessagePart[], fields: Partial<SessionMessageEntry> = {}): SessionMessageEntry { + return { + id, + role: "system", + parts, + createdAt: 1758000000000, + deviceId: "dev", + status: "complete", + continuationOf: null, + durationMs: null, + ...fields, + }; +} + +describe("the completion notice row", () => { + it("emits one card row carrying the sides; the text sibling renders nothing", () => { + const rows = rowsForEntry( + entry("notice:1", [ + noticePart("n0", [ + side("chat-side-1234", "Implement login", "completed"), + side("chat-side-5678", "Flaky test", "errored", "the build command timed out"), + ]), + textPart( + "t0", + '2 side chats reached outcomes:\n- "Implement login" (chat-si) finished.\n- "Flaky test" (chat-si) failed: the build command timed out.', + ), + ]), + { parse }, + ); + // One row — the card. The notice reads once, not twice. + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.rowKind).toEqual({ + kind: "completionNotice", + sides: [ + { chatId: "chat-side-1234", title: "Implement login", outcome: "completed", detail: null }, + { + chatId: "chat-side-5678", + title: "Flaky test", + outcome: "errored", + detail: "the build command timed out", + }, + ], + }); + // The row id is entry#part — stable across replays — and the card is the + // entry's first row, so it carries the turn gap like every first row. + expect(row.id).toBe("notice:1#n0"); + expect(row.turnStart).toBe(true); + // Engine structure, not a hover target: no timestamp strip, no copy. + expect(row.timestamp).toBeNull(); + expect(row.copyText).toBeNull(); + expect(row.compactFold).toBeNull(); + }); + + it("versions off the sides — a changed outcome or detail re-keys the card", () => { + const base = [side("chat-side-1234", "Implement login", "completed")]; + const first = rowsForEntry(entry("e", [noticePart("n0", base), textPart("t0", "text")]), { parse })[0]!; + expect(first.version).toBe(noticeFingerprint(base)); + + const renamed = rowsForEntry( + entry("e", [noticePart("n0", [side("chat-side-1234", "Renamed", "completed")]), textPart("t0", "text")]), + { parse }, + )[0]!; + expect(renamed.version).not.toBe(first.version); + + const errored = rowsForEntry( + entry("e", [ + noticePart("n0", [side("chat-side-1234", "Implement login", "errored", "provider 502")]), + textPart("t0", "text"), + ]), + { parse }, + )[0]!; + expect(errored.version).not.toBe(first.version); + + // Identical sides re-key identically: a replay is a no-op splice. + const replay = rowsForEntry(entry("e", [noticePart("n0", base), textPart("t0", "text")]), { parse })[0]!; + expect(replay.version).toBe(first.version); + // The fingerprint is the sides' presentation fields, joined with NULs so + // no field can bleed into the next one. + expect(first.version).toBe( + fnv1a("chat-side-1234\u0000Implement login\u0000completed\u0000\u0000"), + ); + }); + + it("a marker without sides falls back to its plain text — the notice stays readable", () => { + const rows = rowsForEntry( + entry("notice:2", [ + noticePart("n0", []), + textPart("t0", 'Side chat "Migration" (chat-si) was stopped.\nRead the full result.'), + ]), + { parse }, + ); + // No card without sides: plain markdown rows carry the notice as prose — + // exactly what an older layout (or a lenient decode of a partial row) + // reads. + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((row) => row.rowKind.kind === "markdown")).toBe(true); + // The settled last row carries the metadata strip again, like any + // text-bearing system entry. + expect(rows[rows.length - 1]!.timestamp).toBe(1758000000000); + }); + + it("renders in compact mode too — the card is never a folded work part", () => { + const rows = rowsForEntry( + entry("notice:3", [ + noticePart("n0", [side("chat-side-1234", "Implement login", "waitingForInput", "Bump the major version?")]), + textPart("t0", 'Side chat "Implement login" is waiting for input.'), + ]), + { parse, compact: true }, + ); + expect(rows).toHaveLength(1); + expect(rows[0]!.rowKind.kind).toBe("completionNotice"); + expect(rows[0]!.compactFold).toBeNull(); + }); + + it("sits alongside the parent's own turns, in document order", () => { + const rows = [ + ...rowsForEntry(entry("e1", [textPart("u1", "go")], { role: "user" }), { parse }), + ...rowsForEntry( + entry("e2", [ + noticePart("n0", [side("chat-side-1234", "Implement login", "completed")]), + textPart("t0", "text"), + ]), + { parse }, + ), + ...rowsForEntry(entry("e3", [textPart("a1", "the reply")], { role: "assistant" }), { parse }), + ]; + const kinds = rows.map((row) => + row.rowKind.kind === "completionNotice" + ? "notice" + : row.rowKind.kind === "user" + ? "user" + : "text", + ); + expect(kinds).toEqual(["user", "notice", "text"]); + }); +}); + +describe("the notice side-row helpers", () => { + it("shortens an id to eight characters without splitting a character", () => { + expect(shortChatId("chat-side-1234")).toBe("chat-si"); + expect(shortChatId("short")).toBe("short"); + // A surrogate pair never splits. + const wide = "😀😀😀😀😀😀😀😀😀"; + expect(shortChatId(wide).length).toBeLessThanOrEqual(16); + expect(Array.from(shortChatId(wide))).toHaveLength(8); + }); + + it("maps every outcome to a verb, matching the notice's own text", () => { + expect(noticeOutcomeVerb(side("c", "t", "completed"))).toBe("finished"); + expect(noticeOutcomeVerb(side("c", "t", "errored"))).toBe("failed"); + expect(noticeOutcomeVerb(side("c", "t", "stopped"))).toBe("stopped"); + expect(noticeOutcomeVerb(side("c", "t", "interrupted"))).toBe("interrupted"); + expect(noticeOutcomeVerb(side("c", "t", "waitingForInput"))).toBe("waiting for input"); + }); + + it("appends the one-line reason, and a blank reason reads as none", () => { + expect(noticeOutcomeLine(side("c", "t", "errored", "provider 502: bad gateway"))).toBe( + "failed: provider 502: bad gateway", + ); + expect(noticeOutcomeLine(side("c", "t", "completed"))).toBe("finished"); + expect(noticeOutcomeLine(side("c", "t", "errored", " "))).toBe("failed"); + // The title is one-lined for the card, like the fork seam's source title. + expect(singleLine("A title\nthat wraps")).toBe("A title that wraps"); + }); +}); From 755cdcdf71825591ea173cce74a30735300236ee Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 02:58:05 +0700 Subject: [PATCH 46/96] feat(mcp): fork chats with a fork_chat tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestration v2 ticket 23 (.scratch/t3-orchestration-v2/issues/23): agents can branch a conversation programmatically through the same ForkSideChat RPC the UI's fork button uses. - The tool: the source defaults to the calling chat, 'reply' names any settled assistant entry as the boundary, 'harness'/'model' override what the copy runs on (validated against this engine's catalogs; the source's own harness stays pickable when its CLI is gone, the fork dialog's rule), plus 'title' and 'prompt'/'wait'/'timeout_secs' with create_chat's semantics. A first prompt baselines its wait on the fork's copied transcript, so the wait reports the fork's own replies, never the copied history. - An agent-made fork records its creator with the notify flag (default on, 'notify: false' opts out) through a new additive agentOrigin param on ForkSideChat — the durable row the engine's completion notices key on. The plain assignment also strips anything the source row carried, so forks made without it (UI, web client, terminal) stay user-made and never notify, even when forking an agent-created side chat (ticket 11's rule). Engine tests cover both directions. - The copy hangs under the calling chat (create_chat's parent rule, so it is the agent's notifying side chat); a terminal without an origin uses the UI's placement — the source, or the source's parent when the source is itself a side chat, so the copy lists as a sibling. - Side chats get the same refusal as create_chat. The tool is documented in the MCP instructions and docs/mcp.md; MCP tool tests cover every option, the refusal, the notify opt-out, human forks staying unmarked, and the prompt/wait semantics against the stub engine. - CI: the roboco-mcp unit tests were previously run by no workflow; the engine-local-regressions job now runs cargo test -p roboco-mcp so the MCP seam tests actually gate the PR. Wire: additive only — the agentOrigin param on ForkSideChat's inline params (no registered wire type changed, no TS regeneration). Not run locally (CI runs them). --- .github/workflows/ui-tests.yml | 4 + crates/engine/src/rpc.rs | 16 + crates/engine/tests/agent_origin.rs | 148 ++++++ crates/roboco-mcp/src/jsonrpc.rs | 9 + crates/roboco-mcp/src/roboco.rs | 12 + crates/roboco-mcp/src/tools.rs | 704 +++++++++++++++++++++++++++- docs/mcp.md | 22 + 7 files changed, 899 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e4a4b950f9..3a83ecddba 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -66,6 +66,10 @@ jobs: run: | cargo test --locked -p roboco-sync --lib cargo test --locked -p roboco-update --lib + # t3-orchestration-v2 ticket 23: the Roboco MCP tool tests (the + # fork_chat seam). The crate's unit tests were previously not run + # by any workflow. + cargo test --locked -p roboco-mcp cargo test --locked -p roboco-engine --lib --test account_failover --test account_sign_in_state --test agent_origin --test codex_subagents --test completion_notices --test continue_on_harness --test engine_local --test harness_switch --test limited_chats --test local_profiles --test project_action_commands --test project_actions_remote --test project_actions_rpc --test remote_settings --test restart_resume --test resume_at_reset --test side_chats diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 64af832f8d..7c915cb1be 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -1601,6 +1601,16 @@ impl RpcService for EngineRpc { /// run time) is all the copy gets. #[serde(default)] model: Option<String>, + /// Agent-made fork (t3-orchestration-v2 ticket 23, the + /// `fork_chat` MCP tool): the chat whose agent forked + /// this one and whether it wants completion notices + /// (default on). The assignment REPLACES whatever the + /// source row carried, so a user-made fork — the UI, the + /// web client, a terminal — is never marked and never + /// notifies, even when the source itself was + /// agent-created (ticket 11's rule). + #[serde(default)] + agent_origin: Option<roboco_proto::AgentOrigin>, } let p: ForkParams = parse_params(params)?; let parent_chat_id = p @@ -1723,6 +1733,12 @@ impl RpcService for EngineRpc { chat.harness_session_id = None; chat.harness_session_cwd = None; chat.room_gen = Some(2); + // Ticket 23: an agent-made fork records its creator and + // notify flag — the durable row the engine's completion + // notices key on. The plain assignment also strips any + // origin the source row carried, so a fork nobody marked + // for the agent stays user-made and never notifies. + chat.agent_origin = p.agent_origin; // Ticket 22's override on the row's run config. A // cross-harness fork keeps only harness-agnostic settings: // the sandbox. Model, reasoning and model options are ids in diff --git a/crates/engine/tests/agent_origin.rs b/crates/engine/tests/agent_origin.rs index 2e7160aabb..32d0f0fac2 100644 --- a/crates/engine/tests/agent_origin.rs +++ b/crates/engine/tests/agent_origin.rs @@ -364,6 +364,154 @@ async fn user_created_chats_are_never_marked_agent_created() { core.shutdown().await; } +/// A source chat with one settled turn, so the fork RPC has a boundary to +/// copy through. +fn settled_turn(core: &EngineCore, chat: &str) { + let doc = core.doc_host.open(chat).expect("chat doc"); + for (id, role, text) in [ + ("u1", MessageRole::User, "remember this"), + ("a1", MessageRole::Assistant, "noted"), + ] { + doc.doc() + .push_message(&SessionMessageEntry { + origin_chat_id: None, + id: id.into(), + role, + parts: vec![MessagePart::Text { + id: format!("t-{id}"), + text: text.into(), + }], + created_at: 1, + device_id: core.device_id.clone(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + provider_turn: None, + }) + .expect("push entry"); + } +} + +/// An agent-made fork (the `fork_chat` MCP tool, ticket 23) stamps the +/// creator and notify flag on the copy — the row completion notices key on +/// — while a fork RPC without an `agentOrigin` stays user-made and never +/// notifies, even when the source row itself was agent-created. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn agent_made_forks_notify_and_user_forks_stay_plain() { + let (core, harness, _prompts, _data_dir) = setup(SteeringMode::StepBoundary); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": "fork-source", + "deviceId": core.device_id, + }), + ) + .await + .expect("createChat"); + settled_turn(&core, "fork-source"); + + // An agent fork: the agentOrigin param rides the RPC (notify omitted + // reads as on, notify: false opts out), and the copy hangs under the + // creator the tool names. + let fork = client + .call_as::<roboco_proto::Chat>( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "agent-fork-on", + "sourceChatId": "fork-source", + "parentChatId": PARENT, + "agentOrigin": { "chatId": PARENT }, + }), + ) + .await + .expect("agent fork"); + assert_eq!( + fork.agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify: true, + }) + ); + client + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "agent-fork-off", + "sourceChatId": "fork-source", + "parentChatId": PARENT, + "agentOrigin": { "chatId": PARENT, "notify": false }, + }), + ) + .await + .expect("agent fork"); + for (chat, notify) in [("agent-fork-on", true), ("agent-fork-off", false)] { + let row = core.workspace.chat(chat).unwrap().unwrap(); + assert_eq!( + row.agent_origin, + Some(roboco_proto::AgentOrigin { + chat_id: PARENT.into(), + notify, + }), + "{chat} records its creator and notify flag" + ); + assert_eq!(row.parent_chat_id.as_deref(), Some(PARENT)); + } + + // A fork without the param (the UI's fork button): user-made, hanging + // under the source as always. + client + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "ui-fork", "sourceChatId": "fork-source" }), + ) + .await + .expect("ui fork"); + let row = core.workspace.chat("ui-fork").unwrap().unwrap(); + assert_eq!(row.agent_origin, None); + assert_eq!(row.parent_chat_id.as_deref(), Some("fork-source")); + + // The strip: a USER fork of an agent-created side chat is not marked, + // even though the source row carried an origin (ticket 11 — user forks + // never notify). + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": "agent-side", + "deviceId": core.device_id, + "parentChatId": PARENT, + "agentOrigin": { "chatId": PARENT }, + }), + ) + .await + .expect("createChat"); + settled_turn(&core, "agent-side"); + client + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "user-sibling", + "sourceChatId": "agent-side", + "parentChatId": PARENT, + }), + ) + .await + .expect("sibling fork"); + let row = core.workspace.chat("user-sibling").unwrap().unwrap(); + assert_eq!( + row.agent_origin, None, + "a user fork never inherits the source's agent origin" + ); + assert_eq!(row.parent_chat_id.as_deref(), Some(PARENT)); + + let _ = harness.finish.send(()); + core.shutdown().await; +} + /// A Run command sent by a chat through the Roboco MCP surface carries its /// origin on the ledger row AND on the user message entry it writes. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/roboco-mcp/src/jsonrpc.rs b/crates/roboco-mcp/src/jsonrpc.rs index a30d82a9d2..15e14be65b 100644 --- a/crates/roboco-mcp/src/jsonrpc.rs +++ b/crates/roboco-mcp/src/jsonrpc.rs @@ -69,6 +69,15 @@ side` for a child with an explicit parent or your origin chat. Omitted kind \ keeps legacy defaults: side with an origin/parent, standalone otherwise. Side \ chats cannot create chats.\n\ \n\ +Forking: `fork_chat` branches a conversation into a new side chat — by \ +default the one you are speaking from, from its latest reply. `reply` forks \ +from any earlier assistant reply (an entry id from `read_chat`; errored and \ +interrupted replies count), `harness` and `model` move the fork onto another \ +agent (it then starts from a size-limited transcript of the source), and \ +`prompt` with `wait` behave like `create_chat`'s. A fork you make is a side \ +chat you created: it notifies you by default (`notify: false` opts out), and \ +side chats cannot fork chats.\n\ +\n\ If a chat is `awaitingInput`, answer it with `respond_to_input`."; const PARSE_ERROR: i64 = -32700; diff --git a/crates/roboco-mcp/src/roboco.rs b/crates/roboco-mcp/src/roboco.rs index c8e5419743..e672b58596 100644 --- a/crates/roboco-mcp/src/roboco.rs +++ b/crates/roboco-mcp/src/roboco.rs @@ -343,6 +343,18 @@ impl Roboco { .to_owned()) } + /// Fork a chat into a new side chat through the same RPC the UI's fork + /// button uses (t3-orchestration-v2 ticket 23, the `fork_chat` tool): + /// the copy carries the source's transcript up to the boundary and, + /// where the harness supports it, the native-fork intent. An + /// `agentOrigin` in `params` marks an agent-made fork (the creator and + /// its notify flag — the row completion notices key on); a user fork + /// sends none and never notifies. + pub async fn fork_side_chat(&self, params: Value) -> anyhow::Result<Chat> { + let reply = self.call(methods::FORK_SIDE_CHAT, params).await?; + serde_json::from_value(reply).context("ForkSideChat: unexpected shape") + } + /// Report that the calling chat (`origin_chat_id`) observed `chat_id` /// after its last outcome happened — through `wait_for_turn`, a send /// with wait, `read_chat` or `get_chat` (ticket 14). Completion-notice diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index f51560dfe1..716436bbc8 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -143,6 +143,24 @@ fn catalog() -> Vec<ToolDef> { } }), }, + ToolDef { + name: "fork_chat", + description: "Fork a chat into a new side chat: the copy carries the source's transcript up to a chosen reply and, on the same harness, continues its real provider session. The source defaults to the chat you are speaking from; reply takes any settled assistant entry id from read_chat (errored and interrupted included) and defaults to the latest; harness and model override what the copy runs on — a different harness starts from a size-limited transcript of the source instead; prompt and wait behave like create_chat's. The fork lists under you like any side chat you create and notifies you by default (notify: false opts out). A side chat cannot call it. Returns the new chat id.", + input_schema: json!({ + "type": "object", + "properties": { + "chat": { "type": "string", "description": "Source chat to fork: id, unique id prefix, or exact title. Defaults to the chat you are speaking from." }, + "reply": { "type": "string", "description": "Assistant reply to fork from, inclusive: the entry id read_chat reports. Any settled reply — errored and interrupted included. Defaults to the latest reply." }, + "harness": { "type": "string", "description": "Harness id for the copy (see list_harnesses). Defaults to the source's harness, which keeps the native fork." }, + "model": { "type": "string", "description": "Model id from list_models on the chosen harness. Omit to keep the source's (or the harness default)." }, + "title": { "type": "string", "description": "Sidebar title. Otherwise the fork's first turn generates its own." }, + "prompt": { "type": "string", "description": "First message to send to the fork right away." }, + "wait": { "type": "boolean", "default": false, "description": "With prompt: block until the first turn finishes and return the reply." }, + "notify": { "type": "boolean", "default": true, "description": "For a fork you make: whether you want a completion notice when it finishes, fails, stops or needs input (default on). Pass false for a fire-and-forget fork." }, + "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 600 } + } + }), + }, ToolDef { name: "read_chat", description: "Read a chat's transcript as plain messages (newest window by default). Tool calls are summarized one per line. Use it to collect a side chat's result after a completion notice names it.", @@ -317,6 +335,26 @@ struct CreateChatArgs { timeout_secs: Option<u64>, } +/// `fork_chat` (t3-orchestration-v2 ticket 23): the source, boundary and +/// overrides an agent forks with. Every option is optional; the source +/// defaults to the chat this server speaks for. +#[derive(Deserialize, Default)] +struct ForkChatArgs { + chat: Option<String>, + reply: Option<String>, + harness: Option<String>, + model: Option<String>, + title: Option<String>, + prompt: Option<String>, + #[serde(default)] + wait: bool, + /// Opt out of completion notices for this fork (default on). Only + /// meaningful when an agent is the creator; user forks never notify. + #[serde(default)] + notify: Option<bool>, + timeout_secs: Option<u64>, +} + #[derive(Deserialize)] struct ReadChatArgs { chat: String, @@ -472,6 +510,7 @@ impl Tools { "get_chat" => self.get_chat(parse(args)?).await, "create_chat" => self.create_chat(parse(args)?).await, "create_chats" => self.batch(parse(args)?, true).await, + "fork_chat" => self.fork_chat(parse(args)?).await, "send_messages" => self.batch(parse(args)?, false).await, "read_chat" => self.read_chat(parse(args)?).await, "send_message" => self.send_message(parse(args)?).await, @@ -897,6 +936,218 @@ impl Tools { Ok(result) } + /// `fork_chat` (t3-orchestration-v2 ticket 23): fork a chat into a new + /// side chat through the same RPC the UI's fork button uses. The source + /// defaults to the calling chat; the boundary, harness and model ride + /// the RPC verbatim; a first prompt and wait behave exactly like + /// `create_chat`'s. An agent-made fork records its creator (with the + /// `notify` opt-out) the same way `create_chat` does, so completion + /// notices reach the forking chat; a human at a terminal creates an + /// unmarked fork that never notifies. + async fn fork_chat(&self, args: ForkChatArgs) -> anyhow::Result<Value> { + // The same guard as `create_chat`: the calling chat must be + // top-level. Side chats cannot fork chats. + let origin_chat = match self.roboco.origin().chat_id.as_deref() { + Some(id) => Some(self.roboco.resolve_chat(id).await?), + None => None, + }; + if let Some(chat) = &origin_chat { + anyhow::ensure!( + chat.parent_chat_id.is_none(), + "Side chats cannot fork chats. Ask your parent chat to create another side chat." + ); + } + // Source: the explicit `chat` argument, else the chat this server + // speaks for (spec story 65: "by default my own"). A terminal + // without an origin must name one. + let source = match args + .chat + .as_deref() + .map(str::trim) + .filter(|chat| !chat.is_empty()) + { + Some(key) => self.roboco.resolve_chat(key).await?, + None => match &origin_chat { + Some(chat) => chat.clone(), + None => anyhow::bail!( + "fork_chat needs a source chat: pass `chat` (id, prefix, or title), or run it from inside a chat" + ), + }, + }; + // Agent-made forks are marked exactly like `create_chat`'s side + // chats: the creator (this server's origin chat) and its notify + // preference. No origin chat — a human at a terminal — never marks + // the fork, so it never notifies. + let agent_origin = self.roboco.origin().chat_id.as_deref().map(|origin_id| { + roboco_proto::AgentOrigin { + chat_id: origin_id.to_owned(), + notify: args.notify.unwrap_or(true), + } + }); + let harnesses = self + .roboco + .harnesses() + .await + .with_context(|| format!("harness catalog on device {}", source.device_id))?; + let harness_override = + match args.harness.as_deref().map(str::trim).filter(|h| !h.is_empty()) { + Some(raw) => { + let id: HarnessId = parse_enum("harness", raw).map_err(anyhow::Error::msg)?; + // The source's own harness stays pickable even when its + // CLI is gone (the fork dialog's rule — the copy runs on + // it either way); any other pick must be offered here. + let source_harness = source.config.as_ref().map(|config| config.harness); + anyhow::ensure!( + source_harness == Some(id) + || harnesses.iter().any(|h| h.id == id && h.available()), + "harness {raw} is not available on device {} (see list_harnesses)", + source.device_id + ); + Some(id) + } + None => None, + }; + if let Some(model) = args + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + // The model must be offered by the harness the copy runs on: + // the override, else the source's (else the offered default — + // a configless source runs on the engine default). + let target = match harness_override + .or_else(|| source.config.as_ref().map(|config| config.harness)) + { + Some(harness) => harness, + None => default_harness(&harnesses)?, + }; + let models = self + .roboco + .models(target) + .await + .with_context(|| format!("model catalog on device {}", source.device_id))?; + anyhow::ensure!( + models.iter().any(|m| m.id == model), + "model {model:?} is not offered by {target:?} on device {}; available: {}", + source.device_id, + models + .iter() + .map(|m| m.id.as_str()) + .collect::<Vec<_>>() + .join(", ") + ); + } + + let chat_id = uuid::Uuid::new_v4().to_string(); + // Where the copy hangs: the calling chat when an agent forks — + // `create_chat`'s rule, so the fork is the agent's side chat and + // owes it the notices — else the UI's rule: the source, or the + // source's parent when the source is itself a side chat, so the + // copy lists as a sibling instead of nesting under a side chat. + let parent_chat_id = match origin_chat.as_ref() { + Some(origin) => origin.id.clone(), + None => source + .parent_chat_id + .clone() + .unwrap_or_else(|| source.id.clone()), + }; + let mut params = json!({ + "chatId": chat_id, + "sourceChatId": source.id, + "parentChatId": parent_chat_id, + }); + // Overrides ride only when chosen — the same wire shape the fork + // dialog sends — so an engine predating tickets 21/22/23 still + // reads its classic fork. + if let Some(reply) = args + .reply + .as_deref() + .map(str::trim) + .filter(|reply| !reply.is_empty()) + { + params["boundary"] = json!(reply); + } + if let Some(harness) = harness_override { + if let Ok(value) = serde_json::to_value(harness) { + params["harness"] = value; + } + } + if let Some(model) = args + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + params["model"] = json!(model); + } + if let Some(origin) = &agent_origin { + params["agentOrigin"] = json!({ + "chatId": origin.chat_id, + "notify": origin.notify, + }); + } + let chat = self.roboco.fork_side_chat(params).await?; + if let Some(title) = args + .title + .as_deref() + .map(str::trim) + .filter(|title| !title.is_empty()) + { + self.roboco + .mutate(json!({ "op": "renameChat", "chatId": chat.id, "title": title })) + .await?; + } + + let mut result = json!({ + "chatId": chat.id, + "kind": "side", + "sourceChatId": source.id, + "deviceId": chat.device_id, + "parentChatId": chat.parent_chat_id, + "agentOrigin": chat.agent_origin, + "title": args.title, + "harness": chat.config.as_ref().map(|config| config.harness), + "model": chat.config.as_ref().and_then(|config| config.model.clone()), + }); + if let Some(prompt) = args.prompt.filter(|prompt| !prompt.trim().is_empty()) { + let spaces = self.roboco.spaces().await?; + let space = chat + .space_id + .as_deref() + .and_then(|id| spaces.iter().find(|space| space.id == id)); + // The copied history is already in the fork's doc: baseline the + // turn on the entries present before the send, so a wait + // reports the fork's own replies, never the copied ones + // (`create_chat`'s empty baseline is wrong for a fork). + let message_ids = self + .roboco + .transcript(&chat.id) + .await? + .into_iter() + .map(|entry| entry.id) + .collect(); + let sent = self + .deliver(&chat, space, &harnesses, None, prompt, "run") + .await?; + result["sent"] = sent; + let pending = Arc::new(PendingTurn { + baseline: None, + start: TurnStart { message_ids }, + }); + self.pending_turns + .lock() + .await + .insert(chat.id.clone(), pending.clone()); + if args.wait { + result["turn"] = self + .await_pending_turn(&chat, pending, wait_duration(args.timeout_secs)) + .await?; + } + } + Ok(result) + } + async fn read_chat(&self, args: ReadChatArgs) -> anyhow::Result<Value> { let chat = self.roboco.resolve_chat(&args.chat).await?; let entries = self.roboco.transcript(&chat.id).await?; @@ -1381,12 +1632,40 @@ mod tests { /// When set, `WatchSessions` reports this chat as Working — a wait /// on it can only time out. working_session: Option<String>, + /// Alpha's config harness — overridden to test forking onto the + /// source's own harness while its CLI is no longer installed (the + /// fork dialog keeps that pick legal). + alpha_harness: Option<&'static str>, } fn stream(item: Value) -> RpcReply { RpcReply::Stream(futures::stream::iter(vec![item]).boxed()) } + /// The workspace's chat rows, as `WatchChats` reports them. The fork + /// handler builds its copy from the same shape, so the stub's chats + /// stay one source of truth. + fn rows(&self) -> Value { + json!([ + { + "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", + "archived": false, "spaceId": "space-1", + "config": { + "harness": self.alpha_harness.unwrap_or("claude-code"), + "model": "opus", "reasoning": null, "sandbox": "workspace-write" + }, + "createdAt": "2026-09-01T00:00:00Z" + }, + { + "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", + "parentChatId": self.beta_parent, + "agentOrigin": self.beta_agent_origin, + "archived": false, "spaceId": "space-1", + "createdAt": "2026-09-02T00:00:00Z" + } + ]) + } + #[async_trait] impl RpcService for World { async fn handle(&self, method: &str, params: Value) -> Result<RpcReply, RpcError> { @@ -1411,21 +1690,7 @@ mod tests { "id": "space-2", "deviceId": "dev-local", "path": "/repo/other", "name": "comet", "gitDetected": true, "createdAt": "2026-09-01T00:00:00Z" }])), - methods::WATCH_CHATS => stream(json!([ - { - "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", - "archived": false, "spaceId": "space-1", - "config": { "harness": "claude-code", "model": "opus", "reasoning": null, "sandbox": "workspace-write" }, - "createdAt": "2026-09-01T00:00:00Z" - }, - { - "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", - "parentChatId": self.beta_parent, - "agentOrigin": self.beta_agent_origin, - "archived": false, "spaceId": "space-1", - "createdAt": "2026-09-02T00:00:00Z" - } - ])), + methods::WATCH_CHATS => stream(self.rows()), methods::WATCH_SESSIONS => stream(match &self.working_session { Some(chat_id) => json!([{ "chatId": chat_id, "deviceId": "dev-local", @@ -1437,7 +1702,9 @@ mod tests { { "id": "claude-code", "name": "Claude Code", "supportsSteering": true, "steeringMode": "step-boundary", "reasoningLevels": [], "installed": true, "enabled": true }, { "id": "codex", "name": "Codex", "supportsSteering": true, - "steeringMode": "turn-boundary", "reasoningLevels": [], "installed": false, "enabled": true } + "steeringMode": "turn-boundary", "reasoningLevels": [], "installed": false, "enabled": true }, + { "id": "pi", "name": "Pi", "supportsSteering": true, + "steeringMode": "step-boundary", "reasoningLevels": [], "installed": true, "enabled": true } ])), methods::LIST_MODELS => RpcReply::Value(json!([ { "id": "opus", "label": "Opus" }, { "id": "sonnet", "label": "Sonnet" } @@ -1472,6 +1739,84 @@ mod tests { .push((method.to_owned(), params)); RpcReply::Value(json!({})) } + methods::FORK_SIDE_CHAT => { + // The engine's fork semantics as far as the tools + // exercise them (tickets 21–23): the copy is the source + // row with a new id, the chosen parent, no title, the + // agent origin (absent strips — user forks never + // notify), and the harness/model overrides on its run + // config, a cross-harness pick resetting the model + // vocabulary. Recorded like a write so tests assert + // exactly what the tool sent. + self.writes + .lock() + .unwrap() + .push((method.to_owned(), params.clone())); + let rows = self.rows().as_array().cloned().unwrap_or_default(); + let source = rows + .iter() + .find(|chat| chat["id"] == params["sourceChatId"]) + .cloned() + .ok_or_else(|| { + RpcError::Failed("Source chat no longer exists".into()) + })?; + let mut chat = source.clone(); + chat["id"] = params["chatId"].clone(); + let parent = params + .get("parentChatId") + .filter(|value| !value.is_null()) + .cloned() + .unwrap_or_else(|| source["id"].clone()); + chat["parentChatId"] = parent; + chat["title"] = Value::Null; + chat["agentOrigin"] = params + .get("agentOrigin") + .filter(|value| !value.is_null()) + .cloned() + .unwrap_or(Value::Null); + let requested_harness = params + .get("harness") + .and_then(Value::as_str) + .map(str::trim) + .filter(|harness| !harness.is_empty()); + let requested_model = params + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()); + if requested_harness.is_some() || requested_model.is_some() { + let base = chat + .get("config") + .filter(|value| !value.is_null()) + .cloned() + .unwrap_or(json!({ + "harness": "claude-code", "model": null, "reasoning": null, + "sandbox": "workspace-write", + })); + let cross = requested_harness + .is_some_and(|harness| base["harness"].as_str() != Some(harness)); + chat["config"] = json!({ + "harness": requested_harness.or_else(|| base["harness"].as_str()), + "model": if cross { + requested_model + } else { + requested_model.or_else(|| base["model"].as_str()) + }, + "reasoning": if cross { + Value::Null + } else { + base["reasoning"].clone() + }, + "modelOptions": if cross { + json!({}) + } else { + base.get("modelOptions").cloned().unwrap_or(json!({})) + }, + "sandbox": base["sandbox"].clone(), + }); + } + RpcReply::Value(chat) + } other => return Err(RpcError::UnknownMethod(other.into())), }) } @@ -1831,6 +2176,308 @@ mod tests { ); } + #[tokio::test] + async fn fork_chat_with_no_options_forks_the_caller_from_its_latest_reply() { + let world = Arc::new(World::default()); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + let forked = tools.call("fork_chat", json!({})).await.unwrap(); + let chat_id = forked["chatId"].as_str().unwrap().to_owned(); + assert!(!chat_id.is_empty(), "a fresh id comes back"); + assert_eq!(forked["kind"], "side"); + assert_eq!(forked["sourceChatId"], "chat-alpha-1"); + assert_eq!(forked["parentChatId"], "chat-alpha-1"); + // An agent-made fork is a notifying side chat of the caller. + assert_eq!( + forked["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": true }) + ); + assert_eq!(forked["harness"], "claude-code"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes.len(), 1); + let (method, params) = &writes[0]; + assert_eq!(method, methods::FORK_SIDE_CHAT); + assert_eq!(params["chatId"], chat_id); + assert_eq!(params["sourceChatId"], "chat-alpha-1"); + assert_eq!(params["parentChatId"], "chat-alpha-1"); + assert_eq!( + params["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": true }) + ); + // No options picked: the classic fork payload — the boundary, + // harness and model ride only when chosen. + assert!(params.get("boundary").is_none()); + assert!(params.get("harness").is_none()); + assert!(params.get("model").is_none()); + } + + #[tokio::test] + async fn fork_chat_options_ride_the_rpc_and_title_the_copy() { + let world = Arc::new(World::default()); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + let forked = tools + .call( + "fork_chat", + json!({ + "chat": "alpha", + "reply": "a1", + "harness": "pi", + "model": "sonnet", + "title": "Branch", + }), + ) + .await + .unwrap(); + let chat_id = forked["chatId"].as_str().unwrap().to_owned(); + assert_eq!(forked["kind"], "side"); + assert_eq!(forked["sourceChatId"], "chat-alpha-1"); + // The fork hangs under the CALLING chat (create_chat's rule), so it + // is the agent's own side chat. + assert_eq!(forked["parentChatId"], "chat-alpha-1"); + assert_eq!( + forked["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": true }) + ); + assert_eq!(forked["title"], "Branch"); + assert_eq!(forked["harness"], "pi"); + assert_eq!(forked["model"], "sonnet"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes.len(), 2, "the fork, then the rename"); + let (method, params) = &writes[0]; + assert_eq!(method, methods::FORK_SIDE_CHAT); + assert_eq!(params["chatId"], chat_id); + assert_eq!(params["sourceChatId"], "chat-alpha-1"); + assert_eq!(params["parentChatId"], "chat-alpha-1"); + assert_eq!(params["boundary"], "a1"); + assert_eq!(params["harness"], "pi"); + assert_eq!(params["model"], "sonnet"); + assert_eq!( + params["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": true }) + ); + let (method, params) = &writes[1]; + assert_eq!(method, methods::MUTATE); + assert_eq!(params["op"], "renameChat"); + assert_eq!(params["chatId"], chat_id); + assert_eq!(params["title"], "Branch"); + } + + #[tokio::test] + async fn fork_chat_validates_the_catalogs_before_forking() { + let world = Arc::new(World::default()); + let tools = tools(world.clone(), Origin::default()); + for args in [ + // Not installed on this device. + json!({ "chat": "alpha", "harness": "codex" }), + // Not a harness at all. + json!({ "chat": "alpha", "harness": "not-a-harness" }), + // Not offered by the harness the copy would run on. + json!({ "chat": "alpha", "model": "nope" }), + ] { + let error = tools.call("fork_chat", args).await.unwrap_err(); + assert!(world.writes.lock().unwrap().is_empty(), "{error}"); + } + // A cross-harness pick with its model rides the wire and lands on + // the copy's config. + let forked = tools + .call( + "fork_chat", + json!({ "chat": "alpha", "harness": "pi", "model": "sonnet" }), + ) + .await + .unwrap(); + assert_eq!(forked["harness"], "pi"); + assert_eq!(forked["model"], "sonnet"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes[0].1["harness"], "pi"); + assert_eq!(writes[0].1["model"], "sonnet"); + drop(writes); + + // The source's own harness stays pickable even when its CLI is gone + // (the fork dialog's rule): the copy runs on it either way. + let codex_source = Arc::new(World { + alpha_harness: Some("codex"), + ..Default::default() + }); + let tools = tools(codex_source.clone(), Origin::default()); + let forked = tools + .call("fork_chat", json!({ "chat": "alpha", "harness": "codex" })) + .await + .unwrap(); + assert_eq!(forked["harness"], "codex"); + assert_eq!( + codex_source.writes.lock().unwrap()[0].1["harness"], + "codex" + ); + } + + #[tokio::test] + async fn side_chats_cannot_fork_chats() { + let world = Arc::new(World { + beta_parent: Some("chat-alpha-1".into()), + ..Default::default() + }); + let side = tools( + world.clone(), + Origin { + chat_id: Some("chat-beta-2".into()), + device_id: None, + }, + ); + for args in [json!({}), json!({ "chat": "alpha" })] { + assert!( + side.call("fork_chat", args) + .await + .unwrap_err() + .contains("Side chats cannot"), + "the same refusal create_chat gives" + ); + } + assert!(world.writes.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn fork_chat_notify_opt_out_and_human_forks_stay_unmarked() { + let world = Arc::new(World::default()); + let agent = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + // `notify: false` opts the creator out at fork time. + let forked = agent + .call("fork_chat", json!({ "notify": false })) + .await + .unwrap(); + assert_eq!( + forked["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": false }) + ); + assert_eq!( + world.writes.lock().unwrap()[0].1["agentOrigin"], + json!({ "chatId": "chat-alpha-1", "notify": false }) + ); + + // A human at a terminal: no agent origin, hanging under the source + // like the UI's fork button. + let human = tools(world.clone(), Origin::default()); + let forked = human + .call("fork_chat", json!({ "chat": "alpha" })) + .await + .unwrap(); + assert_eq!(forked["parentChatId"], "chat-alpha-1"); + assert!(forked["agentOrigin"].is_null()); + assert!(world.writes.lock().unwrap()[1].1.get("agentOrigin").is_none()); + + // A human forking a side chat: the copy lists as a sibling under the + // side chat's parent, exactly like the side chat's own fork button — + // never a child of a side chat. + let world = Arc::new(World { + beta_parent: Some("chat-alpha-1".into()), + ..Default::default() + }); + let human = tools(world.clone(), Origin::default()); + let forked = human + .call("fork_chat", json!({ "chat": "beta" })) + .await + .unwrap(); + assert_eq!(forked["parentChatId"], "chat-alpha-1"); + assert!(world.writes.lock().unwrap()[0].1.get("agentOrigin").is_none()); + + // A terminal with neither an origin nor a source has nothing to fork. + let bare = tools(Arc::new(World::default()), Origin::default()); + let error = bare.call("fork_chat", json!({})).await.unwrap_err(); + assert!(error.contains("needs a source chat"), "{error}"); + } + + #[tokio::test] + async fn fork_chat_prompt_and_wait_behave_like_create_chat() { + // The copied history is baselined out of the wait: a fork that never + // runs must not report the source's replies as its own turn's. + let world = Arc::new(World::default()); + let tools = tools( + world.clone(), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ); + let forked = tools + .call( + "fork_chat", + json!({ "prompt": "continue from the reply", "wait": true, "timeout_secs": 1 }), + ) + .await + .unwrap(); + assert_eq!(forked["turn"]["timedOut"], true, "{forked}"); + assert_eq!(forked["turn"]["replies"], json!([]), "{forked}"); + assert_eq!(forked["sent"]["delivery"], "run"); + let writes = world.writes.lock().unwrap(); + assert_eq!(writes.len(), 2); + assert_eq!(writes[0].0, methods::FORK_SIDE_CHAT); + assert_eq!(writes[1].0, methods::QUEUE_COMMAND); + let request = &writes[1].1["command"]["request"]; + // The first prompt rides the run un-attributed, like create_chat's. + assert_eq!(request["prompt"], "continue from the reply"); + assert_eq!(request["harness"], "claude-code"); + assert_eq!(writes[1].1["originChatId"], "chat-alpha-1"); + + // And a wait that completes: the fork's own reply comes back. + let service = Arc::new(DelayedTurn { + world: World::default(), + sent: Mutex::new(None), + reply_delay: Duration::from_millis(100), + never_reply: false, + host_skew_millis: 0, + }); + let tools = Tools::new(Arc::new(Roboco::with_client( + memory_client(service.clone()), + Origin { + chat_id: Some("chat-alpha-1".into()), + device_id: None, + }, + ))); + let forked = tools + .call( + "fork_chat", + json!({ "prompt": "continue from the reply", "wait": true, "timeout_secs": 2 }), + ) + .await + .unwrap(); + assert_eq!(forked["turn"]["outcome"], "completed", "{forked}"); + assert_eq!(forked["turn"]["replies"][0]["text"], "new reply"); + let writes = service.world.writes.lock().unwrap(); + assert_eq!(writes.len(), 3, "{writes:?}"); + assert_eq!(writes[0].0, methods::FORK_SIDE_CHAT); + assert_eq!(writes[0].1["sourceChatId"], "chat-alpha-1"); + assert_eq!(writes[1].0, methods::QUEUE_COMMAND); + assert_eq!( + writes[1].1["command"]["request"]["prompt"], + "continue from the reply" + ); + // The wait that completed observed the fork's outcome (ticket 14): + // it is reported, so no completion notice is owed for it. + assert_eq!(writes[2].0, methods::ACK_SIDE_CHAT); + assert_eq!(writes[2].1["originChatId"], "chat-alpha-1"); + assert_eq!( + writes[2].1["chatId"], + writes[1].1["chatId"] + ); + } + #[tokio::test] async fn sends_carry_the_origin_chat_as_structured_data() { let world = Arc::new(World::default()); @@ -2719,6 +3366,15 @@ mod tests { assert!(instructions.contains("git worktree add")); assert!(instructions.contains("never switches branches in the main checkout")); assert!(instructions.contains("Read-only side chats")); + + // The fork tool (ticket 23): what it forks from, that it can move + // harnesses, and that a fork the agent makes notifies like any + // side chat it created. + assert!(instructions.contains("fork_chat")); + assert!(instructions.contains("from its latest reply")); + assert!(instructions.contains("errored and interrupted replies count")); + assert!(instructions.contains("size-limited transcript")); + assert!(instructions.contains("side chats cannot fork chats")); } #[tokio::test] @@ -2747,5 +3403,21 @@ mod tests { let wait = find("wait_for_turn"); assert!(wait.description.contains("quick synchronous")); assert!(wait.description.contains("notify you")); + + // fork_chat (ticket 23): the fork flow, the notify default, and the + // side-chat refusal. + let fork = find("fork_chat"); + assert!(fork.description.contains("settled assistant entry id from read_chat")); + assert!(fork.description.contains("size-limited transcript")); + assert!(fork.description.contains("prompt and wait behave like create_chat")); + assert!(fork.description.contains("notify: false")); + assert!(fork.description.contains("A side chat cannot call it")); + let schema = &fork.input_schema; + for option in ["chat", "reply", "harness", "model", "title", "prompt", "wait", "notify", "timeout_secs"] { + assert!( + schema["properties"][option].is_object(), + "fork_chat must document the {option} option" + ); + } } } diff --git a/docs/mcp.md b/docs/mcp.md index 29cc19b77e..5c60e0f79e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -127,6 +127,27 @@ transcript instead — while a pick of the source's own harness keeps the native fork. A source chat that hit a usage limit can still be forked into another harness this way. +An additive `agentOrigin` (ticket 23) rides the same RPC when a chat's agent +forks through the `fork_chat` MCP tool: `{ chatId, notify }` marks the copy +agent-created, the durable row the engine's completion notices key on +(`notify: false` is the opt-out). The assignment replaces anything the source +row carried, so forks made without it — the UI, the web client, a terminal — +stay user-made and never notify, even when the source itself was +agent-created. + +The `fork_chat` MCP tool drives the same RPC for agents. Its source defaults +to the calling chat; `reply` is the boundary entry id; `harness` and `model` +are validated against this engine's catalogs before the RPC (the source's own +harness stays pickable even when its CLI is gone, mirroring the fork dialog); +`title`, `prompt`, `wait` and `timeout_secs` behave like `create_chat`'s. The +copy hangs under the calling chat — `create_chat`'s parent rule, so the fork +is the agent's side chat and owes it the notices — while a terminal without +an origin uses the UI's placement: the source, or the source's parent when +the source is itself a side chat, so the copy lists as a sibling. A first +prompt baselines its wait on the copied transcript, so a wait reports the +fork's own replies, never the copied history. Side chats cannot call it, the +same refusal `create_chat` gives. + ## Tools Chats are referenced by full id, a unique id prefix, or an exact title. @@ -145,6 +166,7 @@ name (default: the local engine's device — the engine is device-local, so a | `get_chat` | above + `WatchDocMessages` opening frame (pending input) | | `create_chat` | `Mutate createChat` (+ `renameChat`; optional first send) | | `create_chats` | Concurrent `create_chat` requests with per-request results | +| `fork_chat` | `ForkSideChat` (+ `renameChat`; optional first send) | | `send_messages` | Concurrent `send_message` requests with per-request results | | `read_chat` | `WatchDocMessages` opening `reset` frame, rendered | | `send_message` | `QueueCommand` Run / Steer, or `QueueMessage` | From cd06c369fe1e6915d4e41f17006a65d782691b7a Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:23:29 +0700 Subject: [PATCH 47/96] fix(mcp): review pass on fork_chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-axis review follow-ups on ticket 23: - The harness override rides the wire through json! (the codebase's idiom) instead of a silently-dropped to_value error branch. - docs/mcp.md's ForkSideChat signature lists the new additive agentOrigin? param, matching the paragraph that documents it. Accepted as judgement calls: fork_chat shares its prompt/wait tail and catalog-validation shape with create_chat (sibling flows in the same file, like the batch tools), and the World stub re-implements the engine's fork overrides deliberately — its comment says so. Not run locally (CI runs them). --- crates/roboco-mcp/src/tools.rs | 6 +++--- docs/mcp.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 716436bbc8..5f737e1d4b 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1069,9 +1069,9 @@ impl Tools { params["boundary"] = json!(reply); } if let Some(harness) = harness_override { - if let Ok(value) = serde_json::to_value(harness) { - params["harness"] = value; - } + // The kebab-case wire name ("codex", "claude-code"), like the + // fork dialog's params. + params["harness"] = json!(harness); } if let Some(model) = args .model diff --git a/docs/mcp.md b/docs/mcp.md index 5c60e0f79e..9eb91f48ee 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -113,7 +113,7 @@ lost the bind) gets no Roboco tools rather than a dead server. ### Forks `ForkSideChat { chatId, sourceChatId, parentChatId?, boundary?, harness?, -model? }` copies a chat's history through its latest completed response into +model?, agentOrigin? }` copies a chat's history through its latest completed response into a new chat and appends a `fork` part (a system entry: `sourceChatId`, `sourceTitle`) as the seam; the transcript draws it as "This chat was forked from <title>". `parentChatId` defaults to the source; a side chat's own fork From 31e2819285f7ebf6867e22dab28ae44837f2c716 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:29:15 +0700 Subject: [PATCH 48/96] fix(proto): ResumeAccountCandidates cannot derive Default --- crates/proto/src/entities.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index a81a39c743..a998ad7e7f 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -1100,7 +1100,7 @@ pub struct AgentAccountsSnapshot { /// recommended one. Signed-out accounts ride `signed_out` — the picker /// lists them with a "Sign in again" action instead of a switch (stories /// 47/50). Both lists empty leaves only Resume (and Resume at reset). -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, TS)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct ResumeAccountCandidates { pub chat_id: String, From b22abb856d6fc80c1a3a15b6d2907134493bb7e9 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:32:26 +0700 Subject: [PATCH 49/96] fix(web): code-review fixes for the Limited/accounts parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract SignedOutSwitchConfirm — the switch-to-a-signed-out ask was copy-pasted between the account ring and Settings → Accounts. - relativeResetText gates on truncated seconds like the Rust original, so a sub-second positive remainder reads as due, not 'in 0m'. - Narrow the failover picker's non-null assertions behind one local. - Pin the escape rule for a limited (non-live) indicator, and mount-test the settings row's signed-out mark, reason and Sign in again. --- .../app/src/components/account-usage.tsx | 52 ++++++++++++++----- .../app/src/components/limited-banner.tsx | 11 ++-- web/packages/app/src/lib/view.ts | 8 +-- .../app/src/routes/settings-accounts.tsx | 36 +++++-------- web/packages/app/tests/account-row.test.ts | 46 +++++++++++++++- web/packages/app/tests/escape.test.ts | 3 ++ 6 files changed, 109 insertions(+), 47 deletions(-) diff --git a/web/packages/app/src/components/account-usage.tsx b/web/packages/app/src/components/account-usage.tsx index 7e1b66942a..f2c8cfa5c7 100644 --- a/web/packages/app/src/components/account-usage.tsx +++ b/web/packages/app/src/components/account-usage.tsx @@ -248,20 +248,11 @@ export function AccountUsageIndicator({ client, harness, targetDeviceId }: Accou {/* The "switch to a signed-out account" ask (ticket 08) — the same modal-over-popover the desktop's `render_switch_confirm` draws. */} {confirmSwitch !== null && ( - <Dialog ariaLabel="Switch to a signed-out account?" onClose={() => setConfirmSwitch(null)}> - <DialogCard> - <DialogTitle>Switch to a signed-out account?</DialogTitle> - <DialogBody>{signedOutSwitchWarning(confirmSwitch)}</DialogBody> - <div className="dialog-actions-row"> - <BtnGhost type="button" onClick={() => setConfirmSwitch(null)}> - Cancel - </BtnGhost> - <BtnPrimary type="button" onClick={confirmSwitchTo}> - Switch anyway - </BtnPrimary> - </div> - </DialogCard> - </Dialog> + <SignedOutSwitchConfirm + account={confirmSwitch} + onCancel={() => setConfirmSwitch(null)} + onConfirm={confirmSwitchTo} + /> )} </> ); @@ -277,6 +268,39 @@ export function AccountUsageIndicator({ client, harness, targetDeviceId }: Accou } } +/** + * The "switch to a signed-out account" ask (ticket 08, the desktop's + * `render_switch_confirm`): the warning, Cancel, and Switch anyway. One + * shared dialog — the footer's account ring and Settings → Accounts ask + * with the same words. Exported for the settings page's rows. + */ +export function SignedOutSwitchConfirm({ + account, + onCancel, + onConfirm, +}: { + readonly account: AgentAccount; + readonly onCancel: () => void; + readonly onConfirm: () => void; +}) { + return ( + <Dialog ariaLabel="Switch to a signed-out account?" onClose={onCancel}> + <DialogCard> + <DialogTitle>Switch to a signed-out account?</DialogTitle> + <DialogBody>{signedOutSwitchWarning(account)}</DialogBody> + <div className="dialog-actions-row"> + <BtnGhost type="button" onClick={onCancel}> + Cancel + </BtnGhost> + <BtnPrimary type="button" onClick={onConfirm}> + Switch anyway + </BtnPrimary> + </div> + </DialogCard> + </Dialog> + ); +} + const RING_RADIUS = 6; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; diff --git a/web/packages/app/src/components/limited-banner.tsx b/web/packages/app/src/components/limited-banner.tsx index 2ee8457d06..3a24eec6ab 100644 --- a/web/packages/app/src/components/limited-banner.tsx +++ b/web/packages/app/src/components/limited-banner.tsx @@ -143,8 +143,9 @@ export function LimitedBanner({ } }, [chatId, listFor]); - const rows = list !== null && listFor === chatId ? failoverRows(list) : []; - const hasCandidates = list !== null && listFor === chatId && failoverListOffers(list); + const failoverList = list !== null && listFor === chatId ? list : null; + const rows = failoverList !== null ? failoverRows(failoverList) : []; + const showFailover = offersFailover && failoverList !== null && failoverListOffers(failoverList); /** Run the failover for `accountId` — the reply becomes the strip's * outcome line; a failed switch surfaces the reason right there. */ @@ -224,13 +225,13 @@ export function LimitedBanner({ > Resume </button> - {offersFailover && hasCandidates && ( + {showFailover && failoverList !== null && ( <PickerCard open={open} onOpenChange={setOpen} placement="anchorAboveEnd" role="dialog" - ariaLabel={failoverCardTitle(list!.harness)} + ariaLabel={failoverCardTitle(failoverList.harness)} width={400} overlaySource="composer-pickers" trigger={ @@ -244,7 +245,7 @@ export function LimitedBanner({ } > <FailoverCard - list={list!} + list={failoverList} rows={rows} loading={loading} switching={switching} diff --git a/web/packages/app/src/lib/view.ts b/web/packages/app/src/lib/view.ts index d67831ca0c..928ab2a2fa 100644 --- a/web/packages/app/src/lib/view.ts +++ b/web/packages/app/src/lib/view.ts @@ -321,11 +321,13 @@ export function formatScheduledResume(fireAt: number, now: number): string { * rest, days floor-divide the hours. */ function relativeResetText(verb: string, at: number, now: number): string { - const untilMs = at - now; - if (untilMs <= 0) { + // The Rust original gates on `num_seconds() <= 0` (truncating), so a + // sub-second positive remainder already reads as due — match it exactly. + const untilSecs = Math.floor((at - now) / 1000); + if (untilSecs <= 0) { return `${verb} now`; } - const minutes = Math.floor(untilMs / 60_000); + const minutes = Math.floor(untilSecs / 60); if (minutes < 60) { return `${verb} in ${minutes}m`; } diff --git a/web/packages/app/src/routes/settings-accounts.tsx b/web/packages/app/src/routes/settings-accounts.tsx index 8655db34d1..42b29a34b3 100644 --- a/web/packages/app/src/routes/settings-accounts.tsx +++ b/web/packages/app/src/routes/settings-accounts.tsx @@ -4,6 +4,7 @@ import type { AgentAccount, AgentAccountsSnapshot, AgentLoginStart, HarnessId } import { useEngineSession } from "../state/session-provider"; import { useNow } from "../state/hooks"; import { useFleet, useFleetRegistry } from "../state/fleet"; +import { SignedOutSwitchConfirm } from "../components/account-usage"; import { applySettingsTargetChange, settingsDeviceSwitcherRows, @@ -45,7 +46,6 @@ import { providerNote, PROVIDERS, signedOut, - signedOutSwitchWarning, signInOptionFor, startAgentLogin, usageColorVar, @@ -359,29 +359,17 @@ function AccountsSettingsPageBody() { {/* The "switch to a signed-out account" ask (ticket 08) — the same confirm the desktop's `render_switch_confirm` draws. */} {confirmSwitch !== null && ( - <Dialog ariaLabel="Switch to a signed-out account?" onClose={() => setConfirmSwitch(null)}> - <DialogCard> - <DialogTitle>Switch to a signed-out account?</DialogTitle> - <DialogBody>{signedOutSwitchWarning(confirmSwitch)}</DialogBody> - <div className="dialog-actions-row"> - <BtnGhost type="button" onClick={() => setConfirmSwitch(null)}> - Cancel - </BtnGhost> - <BtnPrimary - type="button" - onClick={() => { - const account = confirmSwitch; - setConfirmSwitch(null); - if (account !== null) { - accountAction("activate", account, true); - } - }} - > - Switch anyway - </BtnPrimary> - </div> - </DialogCard> - </Dialog> + <SignedOutSwitchConfirm + account={confirmSwitch} + onCancel={() => setConfirmSwitch(null)} + onConfirm={() => { + const account = confirmSwitch; + setConfirmSwitch(null); + if (account !== null) { + accountAction("activate", account, true); + } + }} + /> )} </div> ); diff --git a/web/packages/app/tests/account-row.test.ts b/web/packages/app/tests/account-row.test.ts index 4d131efb47..a1271a5db1 100644 --- a/web/packages/app/tests/account-row.test.ts +++ b/web/packages/app/tests/account-row.test.ts @@ -196,6 +196,9 @@ const mounted: Array<() => void> = []; /** The settings row's `onForget` presses — reset per test. */ const settingsForgetCalls: AgentAccount[] = []; +/** The settings row's `onSignInAgain` presses — reset per test. */ +const settingsSignInAgainCalls: AgentAccount[] = []; + afterEach(() => { while (mounted.length > 0) { mounted.pop()!(); @@ -207,6 +210,7 @@ afterEach(() => { h.check.release = null; h.check.verdict = { kind: "resolve", status: { updateAvailable: false } }; settingsForgetCalls.length = 0; + settingsSignInAgainCalls.length = 0; }); function mountAccountRow(): MountedAccountRow { @@ -542,7 +546,9 @@ describe("AccountRow (settings) — the Forget affordance (wpn-09)", () => { onForget: () => { settingsForgetCalls.push(account); }, - onSignInAgain: () => {}, + onSignInAgain: () => { + settingsSignInAgainCalls.push(account); + }, }), ); }); @@ -618,4 +624,42 @@ describe("AccountRow (settings) — the Forget affordance (wpn-09)", () => { }); expect(settingsForgetCalls).toEqual([account]); }); + + it("a signed-out row carries the mark, the reason beside its meters, and Sign in again (ticket 08)", () => { + const account = settingsAccount({ + signInState: "signed-out", + // Stale cached meters: they used to hide the sign-out. + usageWindows: [{ label: "5h", usedFraction: 0.3, resetsAt: null }], + }); + const handle = mountSettingsRow(account); + const row = handle.container; + // The mark. + expect(row.querySelector(".badge-signed-out")!.textContent).toBe("Signed out"); + // The reason rides below the meters — never only when they're gone. + expect(row.querySelector(".usage-list")).not.toBeNull(); + expect(row.querySelector(".account-note-signed-out")!.textContent).toBe( + "Signed out — sign in again", + ); + // The action (active or not — a dead login can be revived in place). + const signIn = Array.from(row.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Sign in again", + ); + expect(signIn).not.toBeNull(); + act(() => { + signIn!.click(); + }); + expect(settingsSignInAgainCalls).toEqual([account]); + }); + + it("a healthy row stays quiet: no mark, no note, no sign-in action", () => { + const handle = mountSettingsRow( + settingsAccount({ signInState: "signed-in", usageWindows: [{ label: "5h", usedFraction: 0.3, resetsAt: null }] }), + ); + const row = handle.container; + expect(row.querySelector(".badge-signed-out")).toBeNull(); + expect(row.querySelector(".account-note")).toBeNull(); + expect( + Array.from(row.querySelectorAll("button")).some((button) => button.textContent === "Sign in again"), + ).toBe(false); + }); }); diff --git a/web/packages/app/tests/escape.test.ts b/web/packages/app/tests/escape.test.ts index 1f694f5025..5f9305a30d 100644 --- a/web/packages/app/tests/escape.test.ts +++ b/web/packages/app/tests/escape.test.ts @@ -60,6 +60,9 @@ describe("resolveShellEscape", () => { // Everything else is not live: completed, errored, idle, unknown. expect(resolveShellEscape(input({ indicator: "completed" }))).toEqual({ kind: "ignored" }); expect(resolveShellEscape(input({ indicator: "errored" }))).toEqual({ kind: "ignored" }); + // A limited chat is NOT live — nothing to interrupt (the full proto + // `Indicator` reaches here now that `effectiveIndicator` can say it). + expect(resolveShellEscape(input({ indicator: "limited" }))).toEqual({ kind: "ignored" }); expect(resolveShellEscape(input({ indicator: "idle" }))).toEqual({ kind: "ignored" }); expect(resolveShellEscape(input({ indicator: null }))).toEqual({ kind: "ignored" }); // A live chat with no selection has nothing to interrupt. From 03a926ac88091e45fb39c01600b4ee591e8a0c93 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:35:06 +0700 Subject: [PATCH 50/96] docs(web): note the signed-out rules in the account ring's header --- web/packages/app/src/components/account-usage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/packages/app/src/components/account-usage.tsx b/web/packages/app/src/components/account-usage.tsx index f2c8cfa5c7..c40f5a08bf 100644 --- a/web/packages/app/src/components/account-usage.tsx +++ b/web/packages/app/src/components/account-usage.tsx @@ -36,6 +36,10 @@ import { BtnGhost, BtnPrimary, Dialog, DialogBody, DialogCard, DialogTitle } fro * muted below the thresholds. The ring degrades quietly: no active account * with usage windows, no harness that reports usage, no client — no chip. * + * A signed-out account is marked on its row, offers "Sign in again" (the + * engine's own login flow, driven inline here), and asks before a switch + * lands on it — ticket 08, the same rules Settings → Accounts follows. + * * Loads mirror the desktop: the first sight of a target lists plain-then- * forced (the engine's persisted usage paints at once, then the probe * replaces it); opening the card re-probes, throttled to 30s; a 5-minute From 3bdf1e08cc732841a18f62a6f581d56c5a4a4d82 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:46:29 +0700 Subject: [PATCH 51/96] fix(engine): settle the account-failover compile errors --- crates/engine/src/account_failover.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/account_failover.rs b/crates/engine/src/account_failover.rs index 2324cd9902..04a4401a65 100644 --- a/crates/engine/src/account_failover.rs +++ b/crates/engine/src/account_failover.rs @@ -102,9 +102,10 @@ pub(crate) async fn candidates( .and_then(|row| row.limit.as_ref()) .and_then(|limit| limit.account_id.clone()); if failover_supported(harness) { + let candidates = accounts.failover_candidates(harness, limited.as_deref()).await; ( limited, - accounts.failover_candidates(harness, limited.as_deref()).await, + candidates, // The dead logins the picker offers "Sign in again" // for (stories 47/50) — listed, never switched to. accounts.failover_signed_out(harness).await, @@ -227,7 +228,7 @@ async fn attempt( .into(), ); }; - ranked.remove(at) + Some(ranked.remove(at)) } None => ranked.first().cloned(), }; @@ -358,6 +359,7 @@ fn dispatch_continuation( message_id: new_id(), }, ) + .map(drop) .map_err(|err| err.to_string()) } From 73c864698da2e4cb50120455346f891c013bcf42 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 03:46:30 +0700 Subject: [PATCH 52/96] feat(forks): continue the real Claude session from forked chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 18 (t3-orchestration-v2, spec stories 57, 58 and 60): forking a Claude Code chat now continues the source's real harness session instead of pasting history into a fresh one, on the plumbing ticket 17 built (RunRequest.provider_fork, AgentEvent::TurnRef, the engine fork path). - Turn refs: every `result` frame closes its turn with a provider turn reference — the session id plus the uuid of the last top-level (untagged) assistant/user frame before it; tagged subagent frames never count. The normalizer emits it ahead of the result's own events so the engine stamps it on the entry the turn streamed into (the wire types landed with ticket 17; no new wire shapes). - The fork's first launch: `--resume <source> --fork-session`, giving the fork a NEW session id read from the init frame, with nothing pasted. An earlier-turn boundary also passes the hidden `--resume-session-at <uuid>` (inclusive) — whether the boundary is the session's latest turn comes from a scan of the CLI's own session file (the last top-level message record); the latest turn needs no cut. - Fallbacks, never a failed send: a source session the CLI's store no longer has skips the fork outright (the budgeted transcript rides in front of the prompt, like ticket 09's refused resume); a CLI that rejects the flags exits before it ever speaks, which a one-byte liveness probe on the launch detects (our initialize control request goes unanswered, stdout closes empty) and retries once as a fresh session with the budgeted transcript pasted. ClaudeHarness gains a with_config_root test override for the session store; the session line stream chains the probe's consumed byte back so no output is lost. - The fake Claude CLI gains fork scenarios: fork-latest (no resume-session-at, bare prompt), fork-earlier (the boundary uuid), fork-refused (exits before speaking; the retry must carry the budgeted history and no session flags), fork-missing; the happy frames gain uuids. Tests: normalizer turn-ref units (last-frame-wins, tagged frames skipped, uuid+session required), the session-store scan unit, harness tests for all four fork launches plus turn refs on the happy path and the live 2.1.228 capture replay, engine side-chat tests on a ClaudeCode stand-in (the ref stamp and the intent reaching the first run with the fallback attached, consumed once, the fork's own session resuming). The claude harness suite is now wired into ui-tests.yml (it was previously not run by any workflow). Not run locally per the verification policy; CI is the signal. --- .github/workflows/ui-tests.yml | 7 + crates/engine/tests/side_chats.rs | 280 ++++++++++- crates/harness/src/claude/mod.rs | 464 +++++++++++++++---- crates/harness/src/claude/normalize.rs | 163 ++++++- crates/harness/tests/claude.rs | 277 +++++++++++ crates/harness/tests/fixtures/fake-claude.sh | 120 ++++- 6 files changed, 1203 insertions(+), 108 deletions(-) diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index e4a4b950f9..2d0dd82393 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -58,6 +58,13 @@ jobs: # the native fork (thread/fork + fallback paste) and provider turn # refs; it was previously not run by any workflow. run: cargo test --locked -p roboco-harness --test codex + - name: Claude native driver integration tests + # t3-orchestration-v2 ticket 18: the fake-CLI suite covers the + # native fork (--resume + --fork-session + --resume-session-at, the + # refused-flag and missing-session budgeted pastes) and provider + # turn refs (message uuids); it was previously not run by any + # workflow (unix-gated like the fake CLI itself). + run: cargo test --locked -p roboco-harness --test claude - name: Pi native driver integration tests run: | cargo test --locked -p roboco-harness --test pi diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 5f4ffe30b9..304ffbb553 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -24,7 +24,11 @@ //! transcript rides the request as the refused-fork fallback. A missing //! ref or a changed harness keeps the ticket-01 budgeted paste. The turn //! refs themselves ride assistant entries via a harness event the engine -//! stamps at segment finish. +//! stamps at segment finish. Claude Code (ticket 18) speaks the same +//! contract with its own ref shape — the provider session id plus the +//! uuid of the turn's last top-level message frame — and its stand-in +//! tests pin that shape and the consumed-once intent on a ClaudeCode +//! harness. //! - "Fork from here" (t3-orchestration-v2 ticket 21): the RPC's optional //! `boundary` picks the reply to fork at — any settled assistant entry, //! an earlier, errored or interrupted one — and the copy (and the budgeted @@ -540,6 +544,263 @@ async fn a_fork_with_a_provider_turn_ref_forks_the_source_session() { core.shutdown().await; } +/// A Claude Code stand-in (t3-orchestration-v2 ticket 18): one run that +/// reports the fork's NEW session id (what the CLI's init frame carries) +/// and a provider turn ref — the uuid of the last top-level message frame +/// of the turn, exactly the event the Claude driver emits — capturing the +/// request it ran on. +struct ClaudeCapture(Arc<Mutex<Vec<RunRequest>>>); +#[async_trait] +impl Harness for ClaudeCapture { + fn id(&self) -> HarnessId { + HarnessId::ClaudeCode + } + fn display_name(&self) -> &str { + "ClaudeCapture" + } + fn supports_steering(&self) -> bool { + true + } + fn steering_mode(&self) -> SteeringMode { + SteeringMode::StepBoundary + } + fn reasoning_levels(&self) -> &[ReasoningLevel] { + &[] + } + async fn models(&self) -> Result<Vec<Model>, HarnessError> { + Ok(vec![]) + } + async fn run( + &self, + request: RunRequest, + _: RunControls, + ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + self.0.lock().unwrap().push(request); + Ok(futures::stream::iter(vec![ + Ok(AgentEvent::SessionStarted { + harness: HarnessId::ClaudeCode, + model: "claude-fable-5".into(), + tools: vec![], + cwd: "/tmp".into(), + session_id: "claude-fork-new-session".into(), + assistant_message_id: uuid::Uuid::new_v4().to_string(), + }), + Ok(AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "claude-fork-new-session".into(), + turn_id: "msg-uuid-9".into(), + }, + }), + Ok(AgentEvent::TextDelta { + text: "Claude fork answer".into(), + }), + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("claude-fork-new-session".into()), + }), + ]) + .boxed()) + } +} + +/// Claude Code assistant entries carry the provider turn reference +/// (ticket 18): the harness event rides the stream and the engine stamps +/// it on the entry the turn streamed into — the session id plus the uuid +/// of the turn's last top-level message frame — so a later fork finds its +/// boundary there. +#[tokio::test] +async fn claude_assistant_entries_record_the_provider_turn_ref() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(ClaudeCapture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::ClaudeCode).unwrap(); + core.workspace + .create_chat( + "chat", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.sessions + .dispatch( + "chat", + HarnessId::ClaudeCode, + RunRequest { + mcp: None, + prompt: "turn one".into(), + harness: Some(HarnessId::ClaudeCode), + model: None, + reasoning: None, + model_options: Default::default(), + cwd: "/tmp".into(), + sandbox: SandboxLevel::WorkspaceWrite, + auto_approve: true, + resume: None, + provider_fork: None, + resume_fallback_history: None, + attachments: vec![], + worktree: None, + }, + Some("u1".into()), + None, + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while requests.lock().unwrap().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while core + .sessions + .session_status("chat") + .is_some_and(|s| s.status != roboco_proto::SessionStatus::Idle) + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let entries = core + .doc_host + .open("chat") + .unwrap() + .doc() + .read_entries() + .unwrap(); + let assistant: Vec<_> = entries + .iter() + .filter(|e| e.role == MessageRole::Assistant) + .collect(); + assert_eq!(assistant.len(), 1, "{entries:?}"); + assert_eq!( + assistant[0].provider_turn.as_ref(), + Some(&roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "claude-fork-new-session".into(), + turn_id: "msg-uuid-9".into(), + }), + "the turn ref rides the entry it streamed into: {entries:?}" + ); + core.shutdown().await; +} + +/// A Claude Code fork's first run carries the source session and the +/// boundary message uuid to the driver (ticket 18): `--resume <source> +/// --fork-session` (plus `--resume-session-at <uuid>` at the driver when the +/// boundary is an earlier turn), nothing pasted, the budgeted transcript as +/// the refused-fork fallback — and the intent is consumed once, with the +/// fork's own NEW session resuming afterwards. +#[tokio::test] +async fn a_claude_fork_carries_the_source_session_and_boundary_uuid_to_its_first_run() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(ClaudeCapture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::ClaudeCode).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "claude-source-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + // The boundary reply, stamped the way a Claude run does: the provider + // session plus the uuid of the turn's last top-level message frame. + let mut boundary = message( + "a1", + MessageRole::Assistant, + "I remember PINEAPPLE", + MessageStatus::Complete, + ); + boundary.provider_turn = Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "claude-source-session".into(), + turn_id: "msg-uuid-7".into(), + }); + source.doc().push_message(&boundary).unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": "side", "sourceChatId": "main" }), + ) + .await + .unwrap(); + + // The fork's first run: the native-fork directive names the source + // session and the boundary message uuid, and the delivered prompt + // carries no transcript — the forked session holds the history. + let request = side_turn_on_harness( + &core, + &requests, + "side", + "What did I ask?", + "s1", + HarnessId::ClaudeCode, + ) + .await; + assert_eq!( + request.resume, None, + "the fork never resumes the source session as a plain resume" + ); + let fork = request + .provider_fork + .expect("native fork directive rides the run"); + assert_eq!(fork.session_id, "claude-source-session"); + assert_eq!(fork.last_turn, "msg-uuid-7"); + assert_eq!(request.prompt, "What did I ask?", "{}", request.prompt); + // The budgeted transcript rides the request as the refused-fork + // fallback (what the driver pastes onto the fresh session the CLI + // starts when it rejects the flags or the session is gone). + let fallback = fork.fallback_history.expect("fallback history attached"); + assert!(fallback.contains("PINEAPPLE"), "{fallback}"); + assert!(fallback.ends_with("\n\n"), "{fallback}"); + + // The second run: the intent is spent, and the fork's own NEW session + // (the init frame's id) resumes — nothing pasted. + let request = side_turn_on_harness( + &core, + &requests, + "side", + "And now?", + "s2", + HarnessId::ClaudeCode, + ) + .await; + assert_eq!(request.provider_fork, None); + assert_eq!( + request.resume.as_deref(), + Some("claude-fork-new-session"), + "the fork's own session resumes" + ); + assert_eq!(request.prompt, "And now?", "{}", request.prompt); + core.shutdown().await; +} + /// Old chats (no refs) and a changed harness fall back to the budgeted paste /// from ticket 01: no native-fork directive, history in front of the prompt. #[tokio::test] @@ -1296,6 +1557,19 @@ async fn side_turn( chat: &str, prompt: &str, message_id: &str, +) -> RunRequest { + side_turn_on_harness(core, requests, chat, prompt, message_id, HarnessId::Mock).await +} + +/// [`side_turn`] on an explicit harness — the Claude Code fork tests +/// dispatch on the stand-in registered under that id. +async fn side_turn_on_harness( + core: &EngineCore, + requests: &Arc<Mutex<Vec<RunRequest>>>, + chat: &str, + prompt: &str, + message_id: &str, + harness: HarnessId, ) -> RunRequest { let before = requests.lock().unwrap().len(); let resume = core @@ -1307,11 +1581,11 @@ async fn side_turn( core.sessions .dispatch( chat, - HarnessId::Mock, + harness, RunRequest { mcp: None, prompt: prompt.into(), - harness: Some(HarnessId::Mock), + harness: Some(harness), model: None, reasoning: None, model_options: Default::default(), diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index c5ae83b6de..996527b249 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -42,6 +42,18 @@ //! either way. `background_tasks_changed` reports the live set to the host. //! - Interrupt: cancelling [`RunControls::interrupt`] tears the runtime down — //! the interrupt control request, then SIGTERM and SIGKILL. +//! - TURN REFS (t3-orchestration-v2 ticket 18): every `result` closes its +//! turn with a provider turn reference — the session id plus the uuid of +//! the last top-level (untagged) assistant/user frame before it — so a +//! fork can continue the provider session at exactly that turn. +//! - NATIVE FORK (same ticket): a fork's first run resumes the SOURCE +//! session with `--fork-session` (plus the hidden +//! `--resume-session-at <uuid>` when the boundary is an earlier turn, +//! inclusive), giving the fork a NEW session id that carries the copied +//! history — nothing is pasted. A CLI that rejects the flags (it exits +//! before ever speaking), or a source session the CLI's store no longer +//! has, falls back to a fresh session with the budgeted transcript: a +//! fork never fails the send. pub mod catalog; mod discovery; @@ -56,7 +68,7 @@ use async_trait::async_trait; use futures::StreamExt; use futures::stream::BoxStream; use serde_json::Value; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt as _, AsyncWriteExt, BufReader}; use tokio::sync::mpsc; use roboco_proto::{ @@ -131,6 +143,83 @@ async fn session_known(config_root: &std::path::Path, session_id: &str) -> bool .unwrap_or(false) } +/// The uuid of the LAST top-level message record (`assistant` or `user`, +/// no parent tool-use id, not a sidechain) in the CLI's own session file +/// for `session_id` — how a fork launch tells the session's LATEST turn +/// (`--fork-session` alone continues the whole session) from a boundary at +/// an EARLIER turn (the hidden `--resume-session-at` cuts it there, +/// inclusive). `None` when the file is missing, unreadable, or holds no +/// message records: the boundary is then treated as an earlier turn — the +/// flag combination the CLI may refuse, which is exactly what the fork's +/// budgeted-paste fallback covers. +async fn session_last_message_uuid( + config_root: &std::path::Path, + session_id: &str, +) -> Option<String> { + use std::io::BufRead as _; + if session_id.is_empty() + || !session_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return None; + } + let config_root = config_root.to_owned(); + let session_id = session_id.to_owned(); + tokio::task::spawn_blocking(move || { + let Ok(projects) = std::fs::read_dir(config_root.join("projects")) else { + return None; + }; + // Session ids are global; the project directory encoding does not + // matter here (the same walk `session_known` and + // `Normalizer::for_resume` use). + let file = projects.flatten().find_map(|project| { + std::fs::File::open(project.path().join(format!("{session_id}.jsonl"))).ok() + })?; + // Long sessions run to hundreds of MB: only lines that can carry a + // uuid are parsed, off the runtime, and only the LAST top-level + // message record is kept. + let mut reader = std::io::BufReader::with_capacity(1 << 16, file); + let mut line = Vec::new(); + let mut last = None; + loop { + line.clear(); + match reader.read_until(b'\n', &mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + if !line.contains(b"\"uuid\"") { + continue; + } + let Ok(text) = std::str::from_utf8(&line) else { + continue; + }; + // Torn lines and older/newer unknown shapes are harmless. + let Ok(record) = serde_json::from_str::<Value>(text) else { + continue; + }; + if !matches!( + record.get("type").and_then(Value::as_str), + Some("assistant") | Some("user") + ) || record.get("isSidechain").and_then(Value::as_bool) == Some(true) + || !record.get("parent_tool_use_id").is_none_or(Value::is_null) + { + continue; + } + if let Some(uuid) = record + .get("uuid") + .and_then(Value::as_str) + .filter(|uuid| !uuid.is_empty()) + { + last = Some(uuid.to_owned()); + } + } + last + }) + .await + .unwrap_or(None) +} + /// The `--model` value: the 1M context window is selected via a model-id /// suffix (`sonnet[1m]`), exactly how the CLI itself does it. fn model_arg(request: &RunRequest) -> Option<String> { @@ -180,6 +269,11 @@ pub struct ClaudeHarness { initialize: discovery::InitializeCache, models_cache: crate::catalog::Catalog, workspace_commands: crate::skills::CommandDiscovery, + /// Test override for the CLI's config root — the directory whose + /// `projects/` holds the session store the fork launch reads (a + /// missing source session and the latest-turn boundary). Unset in + /// production: `CLAUDE_CONFIG_DIR` (or `~/.claude`) applies. + config_root: Option<PathBuf>, } impl Default for ClaudeHarness { @@ -191,6 +285,7 @@ impl Default for ClaudeHarness { initialize: discovery::InitializeCache::default(), models_cache: crate::catalog::Catalog::default(), workspace_commands: crate::skills::CommandDiscovery::default(), + config_root: None, } } } @@ -206,6 +301,15 @@ impl ClaudeHarness { self } + /// Point the session store at a fixed config root instead of + /// `CLAUDE_CONFIG_DIR` / `~/.claude` (tests drive the fork launch + /// against a crafted store; the env var is process-global and would + /// race parallel tests). + pub fn with_config_root(mut self, root: impl Into<PathBuf>) -> Self { + self.config_root = Some(root.into()); + self + } + /// Tune the interrupt→SIGTERM→SIGKILL escalation timing. pub fn with_graces(mut self, interrupt_grace: Duration, kill_grace: Duration) -> Self { self.interrupt_grace = interrupt_grace; @@ -611,10 +715,12 @@ impl ClaudeHarness { title_only: bool, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { let exe = self.resolve_executable()?; - let config_root = crate::model_context::root( - "CLAUDE_CONFIG_DIR", - crate::executable::home_or_current_dir().join(".claude"), - ); + let config_root = self.config_root.clone().unwrap_or_else(|| { + crate::model_context::root( + "CLAUDE_CONFIG_DIR", + crate::executable::home_or_current_dir().join(".claude"), + ) + }); // A resume the CLI will REFUSE — its session store has no such id — // starts a fresh session instead when the engine attached a // fallback history (the account-failover continuation, ticket 09): @@ -633,107 +739,213 @@ impl ClaudeHarness { request.resume = None; request.prompt = format!("{history}{}", request.prompt); } - let mut cmd = self.build_command(&exe, &request); - if title_only { - cmd.args([ - "--system-prompt", - crate::TITLE_INSTRUCTIONS, - "--tools", - "", - "--strict-mcp-config", - "--mcp-config", - "{\"mcpServers\":{}}", - "--setting-sources", - "", - ]); - } else if let Some(mcp) = &request.mcp { - // Roboco's own server rides beside the user's configured servers - // (no `--strict-mcp-config`): the CLI merges an inline JSON - // config with settings-sourced ones. - cmd.args(["--mcp-config", &mcp_config_arg(mcp)]); + // A native fork (t3-orchestration-v2 ticket 18): the CLI continues + // the SOURCE session as a NEW session id — `--resume <source> + // --fork-session` — so the copied history rides the provider and + // nothing is pasted. A boundary at an earlier turn also passes the + // hidden `--resume-session-at <uuid>` (inclusive); the latest turn + // needs no cut, and the unverified flag combination is exactly + // what the fallback below covers. A source session the CLI's store + // no longer has skips the fork outright; a CLI that REJECTS the + // flags — it exits before it ever speaks — retries once as a fresh + // session with the budgeted transcript in front of the prompt. A + // fork never fails the send. + let mut fork_flags: Option<Vec<String>> = None; + // Only set when the fork launch is ATTEMPTED — the budgeted + // transcript the retry pastes if the CLI rejects the flags. A + // missing session never attempts; its fallback pastes at once, + // exactly like the refused resume above. + let mut fork_fallback: Option<String> = None; + if let Some(fork) = request.provider_fork.clone() { + if session_known(&config_root, &fork.session_id).await { + // The source session doubles as the launch's resume id, and + // the restored spawn chips (subagent transcript tabs) come + // from its history — the forked copy continues it. + request.resume = Some(fork.session_id.clone()); + let latest = session_last_message_uuid(&config_root, &fork.session_id).await; + let mut flags = vec!["--fork-session".to_string()]; + if latest.as_deref() != Some(fork.last_turn.as_str()) { + flags.push(format!("--resume-session-at={}", fork.last_turn)); + } + fork_flags = Some(flags); + fork_fallback = fork.fallback_history; + } else { + tracing::debug!( + target: "roboco_harness::claude", + "fork source {} has no session file; starting fresh with the budgeted history", + fork.session_id + ); + request.provider_fork = None; + if let Some(history) = fork.fallback_history { + request.prompt = format!("{history}{}", request.prompt); + } + } } - let normalizer = if let Some(session_id) = &request.resume { - Normalizer::for_resume(&config_root, session_id).await - } else { - Normalizer::new() - }; - let mut child = cmd.spawn().map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - HarnessError::NotInstalled(crate::executable::binary_hint(&exe)) + let images = load_image_blocks(&request.attachments).await; + // One launch — or two, when the CLI refuses the fork flags: the + // second drops every session flag and pastes the budgeted + // transcript (the fallback the engine attached for exactly this + // refusal). + for attempt in 0..2 { + if attempt == 1 { + request.resume = None; + request.provider_fork = None; + fork_flags = None; + if let Some(history) = fork_fallback.take() { + request.prompt = format!("{history}{}", request.prompt); + } + tracing::debug!( + target: "roboco_harness::claude", + "the CLI refused the fork launch; starting fresh with the budgeted history" + ); + } + let mut cmd = self.build_command(&exe, &request); + if let Some(flags) = &fork_flags { + cmd.args(flags.clone()); + } + if title_only { + cmd.args([ + "--system-prompt", + crate::TITLE_INSTRUCTIONS, + "--tools", + "", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "--setting-sources", + "", + ]); + } else if let Some(mcp) = &request.mcp { + // Roboco's own server rides beside the user's configured servers + // (no `--strict-mcp-config`): the CLI merges an inline JSON + // config with settings-sourced ones. + cmd.args(["--mcp-config", &mcp_config_arg(mcp)]); + } + let normalizer = if let Some(session_id) = &request.resume { + Normalizer::for_resume(&config_root, session_id).await } else { - HarnessError::Io(e) + Normalizer::new() + }; + let mut child = cmd.spawn().map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + HarnessError::NotInstalled(crate::executable::binary_hint(&exe)) + } else { + HarnessError::Io(e) + } + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| HarnessError::Protocol("claude child has no stdin".into()))?; + let mut stdout = child + .stdout + .take() + .ok_or_else(|| HarnessError::Protocol("claude child has no stdout".into()))?; + let stderr_tail = crate::StderrTail::default(); + if let Some(stderr) = child.stderr.take() { + let tail = stderr_tail.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(target: "roboco_harness::claude", "stderr: {line}"); + tail.push(&line); + } + }); } - })?; - let stdin = child - .stdin - .take() - .ok_or_else(|| HarnessError::Protocol("claude child has no stdin".into()))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| HarnessError::Protocol("claude child has no stdout".into()))?; - let stderr_tail = crate::StderrTail::default(); - if let Some(stderr) = child.stderr.take() { - let tail = stderr_tail.clone(); - tokio::spawn(async move { - let mut lines = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = lines.next_line().await { - tracing::debug!(target: "roboco_harness::claude", "stderr: {line}"); - tail.push(&line); + let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<StdinMsg>(); + tokio::spawn(stdin_writer(stdin, stdin_rx)); + if !title_only { + // The opening control request: `perTaskStopAffordance` tells the + // CLI this host stops tasks individually, so an interrupt ends + // the turn and spares background agents. + let _ = stdin_tx.send(StdinMsg::Line(wire::initialize_request_line( + "roboco_initialize", + ))); + } + + // The fork launch's acceptance probe (ticket 18): a CLI that + // rejects the flags exits before it ever speaks — our opening + // control request gets no answer, so stdout closes with + // nothing on it. One byte proves the CLI accepted the launch + // (its answer itself, or the init frame); EOF or a read error + // proves refusal, and the loop retries once as a fresh session + // with the budgeted transcript. An interrupt mid-probe hands + // the child to the session loop, which sees the token and ends + // the run interrupted. Every other launch — plain, resumed, + // title — is handed over unread, as before. + let mut head: Vec<u8> = Vec::new(); + if attempt == 0 && fork_flags.is_some() && !title_only { + enum Probe { + Spoke(usize), + Silent, + Interrupted, } - }); - } + let mut probe = [0u8; 1]; + let outcome = tokio::select! { + biased; + _ = controls.interrupt.cancelled() => Probe::Interrupted, + read = stdout.read(&mut probe) => match read { + Ok(n) if n > 0 => Probe::Spoke(n), + _ => Probe::Silent, + }, + }; + match outcome { + Probe::Spoke(n) => head = probe[..n].to_vec(), + Probe::Silent => { + shutdown_child(&mut child, self.kill_grace).await; + continue; + } + Probe::Interrupted => {} + } + } - let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<StdinMsg>(); - tokio::spawn(stdin_writer(stdin, stdin_rx)); - if !title_only { - // The opening control request: `perTaskStopAffordance` tells the - // CLI this host stops tasks individually, so an interrupt ends - // the turn and spares background agents. - let _ = stdin_tx.send(StdinMsg::Line(wire::initialize_request_line( - "roboco_initialize", - ))); + // The initial prompt as the first stdin user line (streaming-input + // mode). Ultrathink rides every user message — steers included. + // Staged image attachments are inlined as base64 image content blocks + // ahead of the text (verified against the real CLI); their path refs + // also ride the prompt text, so a skipped/unreadable file degrades to + // the old-app behavior (the agent opens the path with its Read tool). + // The line carries a uuid: the CLI then reports its + // `command_lifecycle`, which decides when a `result` is a steer + // boundary instead of the turn's end. A refused fork's retry + // delivers the budgeted transcript here — it is already in front + // of the prompt. + let first_command = uuid::Uuid::new_v4().to_string(); + let first = wire::prompt_line( + &apply_ultrathink(request.reasoning, &request.prompt), + &first_command, + &images, + ); + let _ = stdin_tx.send(StdinMsg::Line(first)); + + // The probe's consumed byte rides ahead of the pipe (no output + // is lost); an empty head is every unprobed launch. + let stdout_lines = + BufReader::new(std::io::Cursor::new(head).chain(stdout)).lines(); + let (event_tx, event_rx) = mpsc::channel::<Result<AgentEvent, HarnessError>>(256); + tokio::spawn(run_session(Session { + normalizer, + title_only, + child, + stdout_lines, + first_command, + stdin_tx, + event_tx, + controls, + config: request, + interrupt_grace: self.interrupt_grace, + kill_grace: self.kill_grace, + stderr_tail, + })); + + return Ok(futures::stream::unfold(event_rx, |mut rx| async move { + rx.recv().await.map(|ev| (ev, rx)) + }) + .boxed()); } - - // The initial prompt as the first stdin user line (streaming-input - // mode). Ultrathink rides every user message — steers included. - // Staged image attachments are inlined as base64 image content blocks - // ahead of the text (verified against the real CLI); their path refs - // also ride the prompt text, so a skipped/unreadable file degrades to - // the old-app behavior (the agent opens the path with its Read tool). - // The line carries a uuid: the CLI then reports its - // `command_lifecycle`, which decides when a `result` is a steer - // boundary instead of the turn's end. - let images = load_image_blocks(&request.attachments).await; - let first_command = uuid::Uuid::new_v4().to_string(); - let first = wire::prompt_line( - &apply_ultrathink(request.reasoning, &request.prompt), - &first_command, - &images, - ); - let _ = stdin_tx.send(StdinMsg::Line(first)); - - let (event_tx, event_rx) = mpsc::channel::<Result<AgentEvent, HarnessError>>(256); - tokio::spawn(run_session(Session { - normalizer, - title_only, - child, - stdout_lines: BufReader::new(stdout).lines(), - first_command, - stdin_tx, - event_tx, - controls, - config: request, - interrupt_grace: self.interrupt_grace, - kill_grace: self.kill_grace, - stderr_tail, - })); - - Ok(futures::stream::unfold(event_rx, |mut rx| async move { - rx.recv().await.map(|ev| (ev, rx)) - }) - .boxed()) + unreachable!("the launch loop returns (or errors) on its final pass") } } @@ -841,11 +1053,19 @@ async fn stdin_writer(mut stdin: ChildStdin, mut rx: mpsc::UnboundedReceiver<Std } } +/// The session's stdout line source: the fork probe's consumed lead byte +/// (chained back ahead of the pipe, so no output is lost) then the child's +/// stdout. The head is empty for every launch that was never probed — a +/// plain, resumed, or title launch behaves exactly as before. +type StdoutLines = tokio::io::Lines< + BufReader<tokio::io::Chain<std::io::Cursor<Vec<u8>>, crate::process::ChildStdout>>, +>; + struct Session { normalizer: Normalizer, title_only: bool, child: Child, - stdout_lines: tokio::io::Lines<BufReader<crate::process::ChildStdout>>, + stdout_lines: StdoutLines, /// The uuid the opening prompt was written with. first_command: String, stdin_tx: mpsc::UnboundedSender<StdinMsg>, @@ -1530,6 +1750,50 @@ mod tests { // Original input is preserved alongside the answers. assert!(updated["questions"].is_array()); } + + /// The fork launch's latest-turn detection (ticket 18): the session + /// store's last TOP-LEVEL message record — tagged subagent records and + /// non-message records (summaries, tags) never count. + #[tokio::test] + async fn the_session_scan_takes_the_last_top_level_message_record() { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("projects/probe"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + project.join("sess-scan.jsonl"), + concat!( + r#"{"type":"user","uuid":"u-1","parent_tool_use_id":null,"message":{"role":"user","content":"first"}}"#, + "\n", + // A tagged subagent record and a non-message record. + r#"{"type":"assistant","uuid":"sub-1","parent_tool_use_id":"toolu_x","isSidechain":true,"message":{"role":"assistant","content":[]}}"#, + "\n", + r#"{"type":"summary","uuid":"trailing","summary":"s"}"#, + "\n", + r#"{"type":"assistant","uuid":"msg-last","parent_tool_use_id":null,"message":{"role":"assistant","content":[]}}"#, + "\n", + // Records after the last message never count either. + r#"{"type":"summary","uuid":"after","summary":"s"}"#, + "\n", + // A torn tail line is harmless. + "{torn", + ), + ) + .unwrap(); + assert_eq!( + session_last_message_uuid(dir.path(), "sess-scan") + .await + .as_deref(), + Some("msg-last") + ); + // No session file (and unreadable ids): no latest turn — the + // launch treats the boundary as an earlier one. + assert_eq!(session_last_message_uuid(dir.path(), "no-such").await, None); + assert_eq!(session_last_message_uuid(dir.path(), "").await, None); + assert_eq!( + session_last_message_uuid(dir.path(), "../escape").await, + None + ); + } } #[cfg(test)] diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index 0b68bdfb13..f3a889a6c2 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -2,7 +2,8 @@ //! decoding, error-code mapping). use roboco_proto::{ - AgentErrorKind, AgentEvent, DoneStatus, HarnessId, TodoItem, TodoStatus, ToolCall, + AgentErrorKind, AgentEvent, DoneStatus, HarnessId, ProviderTurnRef, TodoItem, TodoStatus, + ToolCall, }; use serde_json::Value; @@ -228,6 +229,12 @@ pub(crate) struct Normalizer { assistant_message_id: String, /// Last session id seen (init or result) — used for synthetic Dones. pub session_id: Option<String>, + /// The uuid of the last TOP-LEVEL (untagged) assistant/user frame seen + /// — the turn ref of the turn a `result` closes (t3-orchestration-v2 + /// ticket 18): a fork resumes the provider session at exactly that + /// message (`--resume-session-at`, inclusive). Tagged subagent frames + /// never count (their uuids name the subagent's own nested turns). + last_message_uuid: Option<String>, } impl Normalizer { @@ -240,6 +247,7 @@ impl Normalizer { agent_spawn_tools: std::collections::HashSet::new(), assistant_message_id: new_message_id(), session_id: None, + last_message_uuid: None, } } @@ -353,6 +361,39 @@ impl Normalizer { (prev, self.assistant_message_id.clone()) } + /// Record the uuid of a top-level (untagged) message frame: the + /// candidate turn ref of the turn a later `result` closes — the LAST + /// one before the result wins (see [`Self::turn_ref`]). + fn note_message_uuid(&mut self, uuid: Option<&str>) { + if let Some(uuid) = uuid.filter(|u| !u.is_empty()) { + self.last_message_uuid = Some(uuid.to_owned()); + } + } + + /// The provider turn reference a `result` frame closes (ticket 18): + /// the session id plus the uuid of the last top-level assistant/user + /// frame before the result — what a fork resumes the provider session + /// at (`--resume <session> --fork-session`, `--resume-session-at + /// <uuid>` for an earlier boundary, inclusive). `None` when either + /// half is unknown (no message frame carried a uuid, or no session id + /// ever arrived): the engine then records no ref and a later fork + /// falls back to the budgeted paste. + fn turn_ref(&self, result_session: &Option<String>) -> Option<ProviderTurnRef> { + let session_id = result_session + .clone() + .or_else(|| self.session_id.clone()) + .filter(|id| !id.is_empty())?; + let turn_id = self + .last_message_uuid + .clone() + .filter(|id| !id.is_empty())?; + Some(ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id, + turn_id, + }) + } + /// Normalize one stdout frame into 0+ unified events. `interrupted` folds /// a post-interrupt `result` into `Done { status: Interrupted }`. pub fn normalize(&mut self, frame: Frame, interrupted: bool) -> Vec<AgentEvent> { @@ -527,6 +568,12 @@ impl Normalizer { } return out; } + // A top-level message frame: its uuid is the candidate turn + // ref (ticket 18) — the last one before the turn's result + // is what a fork resumes at. Recorded even when the frame + // yields no events (a tool-result-only user frame still + // names a turn boundary). + self.note_message_uuid(f.uuid.as_deref()); // Record spawn tool ids up front: `task_notification` keys on // them; a task may finish before a `task_started` arrives. for b in f.message.blocks() { @@ -664,6 +711,10 @@ impl Normalizer { ); return out; } + // A top-level user frame (tool results, or the CLI's echo of + // a line we wrote): its uuid is the candidate turn ref + // (ticket 18), same as an assistant frame's. + self.note_message_uuid(f.uuid.as_deref()); f.message .blocks() .filter(|b: &ContentBlock| b.kind == "tool_result") @@ -791,6 +842,14 @@ impl Normalizer { } }; let mut out = Vec::new(); + // The provider turn ref (ticket 18) rides FIRST, before the + // result's own events: the engine holds it while the entry + // the turn streamed into finishes, and the Done below — + // Usage and ContextUsage never close a segment — closes it, + // so the ref is stamped on exactly that entry. + if let Some(reference) = self.turn_ref(&f.session_id) { + out.push(AgentEvent::TurnRef { reference }); + } if let Some(window) = window { out.push(AgentEvent::ContextUsage { tokens: None, @@ -1396,6 +1455,108 @@ mod tests { ); } + /// The provider turn ref (t3-orchestration-v2 ticket 18): every + /// `result` closes the turn with the session id plus the uuid of the + /// LAST top-level assistant/user frame before it — tagged subagent + /// frames never count — emitted ahead of the result's own events so + /// the engine stamps it on the entry the turn streamed into. + #[test] + fn result_frames_emit_the_last_top_level_message_uuid_as_the_turn_ref() { + let mut norm = Normalizer::new(); + let init = r#"{"type":"system","subtype":"init","model":"m","cwd":"/x","session_id":"sess-t"}"#; + norm.normalize(crate::claude::wire::parse_frame(init).unwrap(), false); + let frames = [ + r#"{"type":"assistant","uuid":"msg-a","message":{"content":[{"type":"text","text":"a"}]}}"#, + // Tagged (subagent) frames carry uuids too — they name the + // subagent's own nested turns and must never become the ref. + r#"{"type":"assistant","uuid":"msg-sub","parent_tool_use_id":"toolu_s","message":{"content":[{"type":"text","text":"sub"}]}}"#, + r#"{"type":"user","uuid":"msg-b","message":{"content":[{"type":"tool_result","tool_use_id":"t","is_error":false}]}}"#, + r#"{"type":"result","subtype":"success","result":"done","errors":[],"usage":{"input_tokens":1,"output_tokens":2},"session_id":"sess-t"}"#, + ]; + let mut events = Vec::new(); + for raw in frames { + events.extend(norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false)); + } + assert_eq!( + events.iter().filter(|e| matches!(e, AgentEvent::TurnRef { .. })).count(), + 1, + "one ref per turn: {events:?}" + ); + assert!(events.contains(&AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "sess-t".into(), + turn_id: "msg-b".into(), + } + })); + // The ref rides ahead of the result's own events — the Done closes + // the entry it must be stamped on. + let done_at = events + .iter() + .rposition(|e| matches!(e, AgentEvent::Done { .. })) + .expect("done"); + let ref_at = events + .iter() + .position(|e| matches!(e, AgentEvent::TurnRef { .. })) + .expect("turn ref"); + assert!(ref_at < done_at); + } + + /// A result whose own frame carries no session id falls back to the + /// last one seen (the init's); frames without uuids, or a session id + /// that never arrived, record no ref at all — a later fork then falls + /// back to the budgeted paste instead. + #[test] + fn turn_refs_need_a_uuid_and_a_session_id() { + let mut norm = Normalizer::new(); + let init = r#"{"type":"system","subtype":"init","model":"m","cwd":"/x","session_id":"sess-fb"}"#; + norm.normalize(crate::claude::wire::parse_frame(init).unwrap(), false); + // No uuid on the message frames, and none on the result: no ref. + for raw in [ + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#, + r#"{"type":"result","subtype":"success","usage":{"input_tokens":0,"output_tokens":0}}"#, + ] { + let events = norm.normalize(crate::claude::wire::parse_frame(raw).unwrap(), false); + assert!( + !events.iter().any(|e| matches!(e, AgentEvent::TurnRef { .. })), + "{events:?}" + ); + } + // A uuid arrives; the result still names no session, so the ref + // falls back to the init's — the session a fork must resume. + let frame = r#"{"type":"assistant","uuid":"msg-late","message":{"content":[]}}"#; + norm.normalize(crate::claude::wire::parse_frame(frame).unwrap(), false); + let events = norm.normalize( + crate::claude::wire::parse_frame( + r#"{"type":"result","subtype":"success","usage":{"input_tokens":0,"output_tokens":0}}"#, + ) + .unwrap(), + false, + ); + assert!(events.contains(&AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "sess-fb".into(), + turn_id: "msg-late".into(), + } + })); + // No session id ever seen: a uuid alone is not a ref. + let mut fresh = Normalizer::new(); + let frame = r#"{"type":"assistant","uuid":"msg-2","message":{"content":[]}}"#; + fresh.normalize(crate::claude::wire::parse_frame(frame).unwrap(), false); + let events = fresh.normalize( + crate::claude::wire::parse_frame( + r#"{"type":"result","subtype":"success","usage":{"input_tokens":0,"output_tokens":0}}"#, + ) + .unwrap(), + false, + ); + assert!( + !events.iter().any(|e| matches!(e, AgentEvent::TurnRef { .. })), + "{events:?}" + ); + } + #[test] fn ede_diagnostics_never_surface_as_errors() { // The CLI's internal turn-accounting breadcrumbs must not become diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index ba62f12fff..29d63d7864 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -128,6 +128,236 @@ async fn a_resume_without_a_session_file_starts_fresh_with_the_history() { ); } +/// Write one Claude session file into a temporary config root — the +/// records the CLI's own store would hold for `session_id`, with the given +/// top-level message uuids in order (the last one is the session's latest +/// turn). Records that must never count ride after them: a tagged +/// subagent frame and a trailing non-message record. +fn claude_session_store(root: &std::path::Path, session_id: &str, uuids: &[&str]) { + let project = root.join("projects").join("probe-cwd"); + std::fs::create_dir_all(&project).unwrap(); + let mut file = String::new(); + for uuid in uuids { + file.push_str(&format!( + "{{\"type\":\"assistant\",\"uuid\":\"{uuid}\",\"parent_tool_use_id\":null,\ + \"message\":{{\"role\":\"assistant\",\"content\":[{{\"type\":\"text\",\"text\":\"m\"}}]}}}}\n" + )); + } + file.push_str( + "{\"type\":\"assistant\",\"uuid\":\"sub-uuid\",\"parent_tool_use_id\":\"toolu_x\",\ + \"isSidechain\":true,\"message\":{\"role\":\"assistant\",\"content\":[]}}\n", + ); + file.push_str("{\"type\":\"summary\",\"uuid\":\"trailing\",\"summary\":\"s\"}\n"); + std::fs::write(project.join(format!("{session_id}.jsonl")), file).unwrap(); +} + +/// The budgeted transcript the engine attaches as the refused-fork +/// fallback (ticket 01's selection): what a fresh fallback session must +/// receive in front of the prompt, and a native fork must NEVER see. +const FORK_FALLBACK_HISTORY: &str = "Continue this side chat using the following prior transcript as context.\ +<transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n"; + +/// A fork at the session's LATEST turn (ticket 18): the launch resumes the +/// source session with `--fork-session` and NO `--resume-session-at`, the +/// copied history rides the provider (nothing is pasted — the fake fails +/// the turn if any of the fallback reaches it), and the new session id +/// arrives on the init frame with its own turn ref. +#[tokio::test] +async fn a_fork_at_the_latest_turn_resumes_the_source_session_with_fork_session() { + let dir = tempfile::tempdir().unwrap(); + // The boundary uuid is the session's LAST top-level message — the + // latest turn, so fork-session alone carries the whole session. + claude_session_store(dir.path(), "fork-latest-src", &["u-1", "msg-latest-uuid"]); + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:fork-latest"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-latest-src".into(), + last_turn: "msg-latest-uuid".into(), + fallback_history: Some(FORK_FALLBACK_HISTORY.into()), + }); + let events = run_to_end(&harness, req, controls).await; + + // The forked session: a NEW session id read from the init frame. + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "sess-fork-latest-new" + )), + "the run continues the forked session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "forked latest answer" + ))); + // The turn's ref names the forked session and its last top-level + // message uuid — tagged subagent frames never count. + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "sess-fork-latest-new".into(), + turn_id: "msg-fork-last".into(), + } + })); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-fork-latest-new".into()), + }) + ); +} + +/// A fork at an EARLIER turn (ticket 21's boundary): the hidden +/// `--resume-session-at` rides with the boundary uuid (inclusive) beside +/// `--resume` + `--fork-session` — the combination the fallback exists for. +#[tokio::test] +async fn a_fork_at_an_earlier_turn_passes_the_boundary_uuid_to_resume_session_at() { + let dir = tempfile::tempdir().unwrap(); + // The boundary (msg-uuid-7) sits BEFORE the session's latest message + // (msg-newer-uuid) — an earlier turn, so the boundary uuid must cut it. + claude_session_store( + dir.path(), + "fork-earlier-src", + &["msg-uuid-7", "msg-newer-uuid"], + ); + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:fork-earlier"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-earlier-src".into(), + last_turn: "msg-uuid-7".into(), + fallback_history: Some(FORK_FALLBACK_HISTORY.into()), + }); + let events = run_to_end(&harness, req, controls).await; + + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "sess-fork-earlier-new" + )), + "the run continues the forked session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "forked earlier answer" + ))); + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "sess-fork-earlier-new".into(), + turn_id: "msg-fork-earlier-last".into(), + } + })); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-fork-earlier-new".into()), + }) + ); +} + +/// A CLI that REJECTS the fork flags — it exits before it ever speaks, so +/// the launch probe reads EOF — retries once as a fresh session with the +/// budgeted transcript in front of the prompt: a fork never fails the send. +#[tokio::test] +async fn a_fork_the_cli_refuses_falls_back_to_a_fresh_session_with_the_budgeted_history() { + let dir = tempfile::tempdir().unwrap(); + // An earlier-turn boundary, so the refused launch is the full flag + // combination (resume + fork-session + resume-session-at). + claude_session_store( + dir.path(), + "fork-refused-src", + &["msg-uuid-7", "msg-newer-uuid"], + ); + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:fork-refused"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-refused-src".into(), + last_turn: "msg-uuid-7".into(), + fallback_history: Some(FORK_FALLBACK_HISTORY.into()), + }); + let events = run_to_end(&harness, req, controls).await; + + // The fallback session served the turn (the fake fails it unless the + // prompt carries the budgeted history AND the user message, with no + // session flags at all). + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "sess-fork-fresh" + )), + "the refused fork fell back to a fresh session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "fresh fallback answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-fork-fresh".into()), + }) + ); +} + +/// A source session the CLI's store no longer has: the fork launch is +/// skipped outright — no session flags — and the budgeted transcript rides +/// in front of the prompt on the fresh session. +#[tokio::test] +async fn a_fork_of_a_missing_session_falls_back_to_the_budgeted_history() { + let dir = tempfile::tempdir().unwrap(); + // No session file for fork-missing-src: the store cannot know it. + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:fork-missing"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-missing-src".into(), + last_turn: "msg-uuid-7".into(), + fallback_history: Some(FORK_FALLBACK_HISTORY.into()), + }); + let events = run_to_end(&harness, req, controls).await; + + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } + if session_id == "sess-fork-missing-fresh" + )), + "the missing session fell back to a fresh session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "missing session fallback answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-fork-missing-fresh".into()), + }) + ); +} + #[tokio::test] async fn happy_path_normalizes_events_and_tags_subagents() { let (controls, _steer, _token) = controls("A"); @@ -154,6 +384,23 @@ async fn happy_path_normalizes_events_and_tags_subagents() { assert_eq!(tools, &vec!["Bash".to_string(), "Read".to_string()]); assert_eq!(session_id, "sess-1"); + // The turn ref (t3-orchestration-v2 ticket 18): the session id plus + // the uuid of the LAST top-level message frame before the result — a + // USER frame here (the tool-result echo closes the turn) — while the + // tagged subagent frames' uuids never count. + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "sess-1".into(), + turn_id: "msg-happy-2".into(), + } + })); + assert_eq!( + events.iter().filter(|e| matches!(e, AgentEvent::TurnRef { .. })).count(), + 1, + "one ref per turn: {events:?}" + ); + assert!(events.contains(&AgentEvent::ReasoningDelta { text: "pondering".into() })); @@ -665,6 +912,36 @@ async fn captured_live_background_subagent_frames_replay_correctly() { .collect(); assert_eq!(dones.len(), 2, "eager done + wake done: {events:?}"); + // Turn refs (ticket 18): one per turn — the session id plus the uuid of + // the last top-level message frame before EACH result (the live + // capture's own uuids; the tagged subagent frames streaming between + // the two results never count). Each ref rides ahead of its turn's + // Done so the engine stamps it on the entry that turn streamed into. + let refs: Vec<usize> = events + .iter() + .enumerate() + .filter_map(|(i, e)| matches!(e, AgentEvent::TurnRef { .. }).then_some(i)) + .collect(); + assert_eq!(refs.len(), 2, "one ref per turn: {events:?}"); + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "a8fb1e09-b66e-4d02-8759-398c9d784261".into(), + turn_id: "6a1d5b71-3411-44e0-ac9b-742ff98f9aea".into(), + } + })); + assert!(events.contains(&AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::ClaudeCode, + session_id: "a8fb1e09-b66e-4d02-8759-398c9d784261".into(), + turn_id: "86d6794c-69b5-4c12-a0bb-b7d3c2f04825".into(), + } + })); + assert!( + refs[0] < dones[0] && dones[0] < refs[1] && refs[1] < dones[1], + "each turn's ref precedes its own done: {events:?}" + ); + // The parent-feed Agent spawn is a plain tool call; the subagent's own // Bash call arrives tagged with the spawning tool-use id, between the // two dones, and never as a bare parent event. diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 7f256c0fe4..38d2beca6c 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -8,6 +8,37 @@ # 2.1.286). Driven by crates/harness/tests/claude.rs. read -r first || exit 1 + +# Fork launches (t3-orchestration-v2 ticket 18): the driver resumes the +# source session with --fork-session (and --resume-session-at <uuid> for a +# boundary at an earlier turn). The source session id picks the behavior: +# acceptance, or a CLI that REJECTS the flags outright — exiting before it +# ever speaks, which is the refusal the driver's launch probe turns into +# the budgeted-paste fallback. Routed off argv because a refusing CLI never +# reads the prompt line. +fork_src= +for arg in "$@"; do + case "$arg" in + --resume=fork-latest-src) fork_src=fork-latest-src ;; + --resume=fork-earlier-src) fork_src=fork-earlier-src ;; + --resume=fork-refused-src) fork_src=fork-refused-src ;; + esac +done +if [ -n "$fork_src" ]; then + case " $* " in + *" --fork-session "*) ;; + *) echo "fork launch without --fork-session" >&2; exit 21 ;; + esac + case "$fork_src" in + fork-refused-src) + # An unknown hidden flag: the CLI exits before answering anything + # (no stdout at all — the probe reads EOF). + echo "error: unknown option '--resume-session-at'" >&2 + exit 2 + ;; + esac +fi + # A run opens with an `initialize` control request (perTaskStopAffordance); # the real CLI answers it before the first user line. (Command discovery # sends its own initialize as the only line — matched by its scenario arm @@ -50,6 +81,85 @@ case "$first" in emit '{"type":"result","subtype":"success","result":"echoed","usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-resume-missing"}' ;; +*scenario:fork-latest*) + # A native fork at the LATEST turn (ticket 18): --resume + --fork-session, + # no --resume-session-at, and a BARE prompt — the copied history rides + # the forked session (any pasted transcript fails the turn). + [ -n "$fork_src" ] || { echo "scenario reached without a fork launch" >&2; exit 22; } + case " $* " in + *"--resume-session-at"*) echo "resume-session-at on a latest-turn fork" >&2; exit 22 ;; + esac + for bad in 'PINEAPPLE' '<transcript>'; do + case "$first" in *"$bad"*) echo "history pasted into a native fork: $bad" >&2; exit 22 ;; esac + done + fid=$(uuid_of "$first") + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"queued\"}" + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"started\"}" + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-latest-new"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"forked latest answer"}}}' + # Top-level frames with uuids (the forked session's own turn): the LAST + # one before the result is the turn ref, tagged frames never count. + emit '{"type":"assistant","uuid":"msg-fork-thinking","parent_tool_use_id":null,"message":{"content":[{"type":"thinking","thinking":"hmm"}]}}' + emit '{"type":"assistant","uuid":"msg-fork-sub","parent_tool_use_id":"sub-x","message":{"content":[{"type":"text","text":"sub interior"}]}}' + emit '{"type":"assistant","uuid":"msg-fork-last","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"forked latest answer"}]}}' + emit '{"type":"result","subtype":"success","result":"forked latest","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-latest-new"}' + emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"completed\"}" + ;; + +*scenario:fork-earlier*) + # A native fork at an EARLIER turn (ticket 21's boundary): the hidden + # --resume-session-at with the boundary uuid, inclusive, and a bare + # prompt like every native fork. + [ -n "$fork_src" ] || { echo "scenario reached without a fork launch" >&2; exit 23; } + case " $* " in + *"--resume-session-at=msg-uuid-7"*) ;; + *) echo "resume-session-at missing or mismatched: $*" >&2; exit 23 ;; + esac + for bad in 'PINEAPPLE' '<transcript>'; do + case "$first" in *"$bad"*) echo "history pasted into a native fork: $bad" >&2; exit 23 ;; esac + done + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-earlier-new"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"forked earlier answer"}}}' + emit '{"type":"assistant","uuid":"msg-fork-earlier-last","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"forked earlier answer"}]}}' + emit '{"type":"result","subtype":"success","result":"forked earlier","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-earlier-new"}' + ;; + +*scenario:fork-refused*) + # The CLI REFUSED the fork flags (it exited before speaking), so the + # driver fell back to a fresh session: no session flags at all, and the + # budgeted history rides in front of the prompt. + for arg in "$@"; do + case "$arg" in + --resume=*|--fork-session|--resume-session-at=*) echo "fallback still carries session flags: $arg" >&2; exit 24 ;; + esac + done + for want in 'PINEAPPLE' 'scenario:fork-refused'; do + case "$first" in *"$want"*) ;; *) echo "fallback prompt missing: $want" >&2; exit 24 ;; esac + done + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-fresh"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"fresh fallback answer"}}}' + emit '{"type":"assistant","uuid":"msg-fork-fresh","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"fresh fallback answer"}]}}' + emit '{"type":"result","subtype":"success","result":"fresh fallback","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-fresh"}' + ;; + +*scenario:fork-missing*) + # The source session's file is gone from the CLI's store: the driver + # skips the fork launch outright — no session flags, the budgeted history + # in front of the prompt (the fallback is mandatory, never a failed send). + for arg in "$@"; do + case "$arg" in + --resume=*|--fork-session|--resume-session-at=*) echo "missing-session fallback still carries session flags: $arg" >&2; exit 25 ;; + esac + done + for want in 'PINEAPPLE' 'scenario:fork-missing'; do + case "$first" in *"$want"*) ;; *) echo "missing-session prompt missing: $want" >&2; exit 25 ;; esac + done + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-missing-fresh"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"missing session fallback answer"}}}' + emit '{"type":"assistant","uuid":"msg-fork-missing","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"missing session fallback answer"}]}}' + emit '{"type":"result","subtype":"success","result":"missing session fallback","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-missing-fresh"}' + ;; + *scenario:title*) tools_off=false system_set=false @@ -83,11 +193,13 @@ case "$first" in emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"pondering"}}}' emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}}' # Subagent frames (parent_tool_use_id set): tagged, never in the parent feed. + # The tagged frames carry uuids too — they name the subagent's own nested + # turns and must never become the parent's turn ref. emit '{"type":"stream_event","parent_tool_use_id":"sub-1","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"SUBAGENT"}}}' - emit '{"type":"assistant","parent_tool_use_id":"sub-1","message":{"content":[{"type":"tool_use","id":"sub-tool","name":"Bash","input":{"command":"echo sub"}}]}}' - emit '{"type":"user","parent_tool_use_id":"sub-1","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool","is_error":false}]}}' - emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"Hello"},{"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"ls -la"}},{"type":"tool_use","id":"tool-2","name":"mcp__linear__search","input":{"q":"bug"}}]}}' - emit '{"type":"user","parent_tool_use_id":null,"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false},{"type":"tool_result","tool_use_id":"tool-2","is_error":true}]}}' + emit '{"type":"assistant","uuid":"msg-sub","parent_tool_use_id":"sub-1","message":{"content":[{"type":"tool_use","id":"sub-tool","name":"Bash","input":{"command":"echo sub"}}]}}' + emit '{"type":"user","uuid":"msg-sub-user","parent_tool_use_id":"sub-1","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool","is_error":false}]}}' + emit '{"type":"assistant","uuid":"msg-happy-1","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"Hello"},{"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"ls -la"}},{"type":"tool_use","id":"tool-2","name":"mcp__linear__search","input":{"q":"bug"}}]}}' + emit '{"type":"user","uuid":"msg-happy-2","parent_tool_use_id":null,"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","is_error":false},{"type":"tool_result","tool_use_id":"tool-2","is_error":true}]}}' # Informational rate-limit status: stays quiet. emit '{"type":"rate_limit_event","rate_limit_info":{"status":"allowed"}}' emit '{"type":"result","subtype":"success","result":"done!","errors":[],"usage":{"input_tokens":10,"output_tokens":20},"session_id":"sess-1","total_cost_usd":0.01}' From f57eaa7d55840481a8c0f39fe4c10f15835ebba4 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 04:06:49 +0700 Subject: [PATCH 53/96] Review round: notice side rows are clickable controls, with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the two-axis review: - The open control was untested — the ticket's core ask. The desktop side rows now carry a debug selector (the sidebar/chat-row idiom), and a new GPUI test mounts the transcript in a window, clicks the SECOND side row, and asserts the click emits TranscriptEvent::OpenSideChat with that row's own chat id. The web gains a jsdom suite (account-row's mounted idiom) that clicks the real CompletionNoticeRow, checks the inert no-handler variant (a plain row, never a dead control), and asserts the painted card's lines and outcome tone classes. - The web estimator's arithmetic comment did not match its own sum: the base is 48 (8 wrapper pads + 16 card pads + 20 header + 4 gap), not 40. Exported CompletionNoticeRow for its suite, like the settings AccountRow sibling. Not run locally (user override); CI is the signal. --- crates/ui/src/transcript.rs | 71 ++++++++++- .../app/src/components/transcript.tsx | 8 +- ...anscript-completion-notice-control.test.ts | 117 ++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 web/packages/app/tests/transcript-completion-notice-control.test.ts diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 9700eee57a..1f7ff5f80b 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -7547,8 +7547,10 @@ impl Transcript { let outcome = notice_outcome_line(notice_outcome_verb(side.outcome), side.detail.as_deref()); let outcome_color = notice_outcome_color(side.outcome, theme); let full_title = title.clone(); + let side_id = SharedString::from(format!("{row_id}#side{ix}")); div() - .id(SharedString::from(format!("{row_id}#side{ix}"))) + .id(side_id.clone()) + .debug_selector(move || side_id.to_string()) .min_w_0() .overflow_hidden() .rounded(px(6.0)) @@ -10487,6 +10489,73 @@ mod tests { } } + /// Ticket 15's control: clicking a notice's side row opens that side + /// chat — the row emits `TranscriptEvent::OpenSideChat` carrying THAT + /// row's chat id (the shell routes the event to `open_chat`, exactly + /// like a sidebar click). + #[gpui::test] + fn clicking_a_notice_side_row_opens_that_side_chat(cx: &mut gpui::TestAppContext) { + use gpui::Modifiers; + + let dir = tempfile::tempdir().unwrap(); + let (state, transcript) = cx.update(|cx| { + gpui_base::init(cx); + cx.set_global(Theme::dark()); + crate::settings::init(crate::settings::UiSettings::default(), dir.path(), cx); + let state = cx.new(|_| AppState::new()); + let transcript = cx.new(|cx| Transcript::new(state.clone(), cx)); + (state, transcript) + }); + cx.update(|cx| { + state.update(cx, |state, _| { + state.selected_chat = Some("chat-a".into()); + state.transcript_replayed = true; + state.transcript = vec![notice_entry( + "notice:5", + vec![ + notice_side("chat-side-1234", "Implement login", NoticeOutcome::Completed), + NoticeSide { + detail: Some("the build command timed out".into()), + ..notice_side("chat-side-5678", "Flaky test", NoticeOutcome::Errored) + }, + ], + "2 side chats reached outcomes.", + )]; + state.transcript_revision += 1; + }); + transcript.update(cx, |this, cx| this.sync(cx)); + }); + struct NoticeHost { + transcript: Entity<Transcript>, + } + impl Render for NoticeHost { + fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { + div().size_full().child(self.transcript.clone()) + } + } + let opened: Rc<RefCell<Vec<String>>> = Rc::default(); + let recorded = opened.clone(); + let (_host, window) = + cx.add_window_view(|_, _| NoticeHost { + transcript: transcript.clone(), + }); + let _sub = window.update(|_, cx| { + cx.subscribe(&transcript, move |_, event: &TranscriptEvent, _| { + if let TranscriptEvent::OpenSideChat { chat_id } = event { + recorded.borrow_mut().push(chat_id.clone()); + } + }) + }); + window.update(|window, cx| window.draw(cx).clear()); + // The SECOND side chat's row: each row carries its own chat id, not + // the notice's or the first side's. + let row = window + .debug_bounds("notice:5#n0#side1") + .expect("the notice's second side row renders"); + window.simulate_click(row.center(), Modifiers::default()); + assert_eq!(opened.borrow().as_slice(), ["chat-side-5678"]); + } + fn with_tool_group_navigation( cx: &mut gpui::TestAppContext, run: impl FnOnce(Entity<AppState>, Entity<Transcript>, &mut gpui::App), diff --git a/web/packages/app/src/components/transcript.tsx b/web/packages/app/src/components/transcript.tsx index 176313ce0b..58c4da76c2 100644 --- a/web/packages/app/src/components/transcript.tsx +++ b/web/packages/app/src/components/transcript.tsx @@ -2010,7 +2010,7 @@ export function estimateRowHeight( // the card's own (8+8), the 20px header, the 4px gap, and one 42px // two-line row per side — an estimate the measurement corrects on // mount. - return 40 + kind.sides.length * 42; + return 48 + kind.sides.length * 42; } } @@ -2714,9 +2714,11 @@ function ForkMarkerRow({ sourceTitle }: { sourceTitle: string }) { * colored outcome verb with its one-line reason (the error, the restart * reason, the question) on the second, and the spawn chip's trailing * "opens elsewhere" glyph at the end. Without a host handler (the read-only - * subagent pane) the rows render inert — no control, no lie. + * subagent pane) the rows render inert — no control, no lie. Exported for + * its jsdom suite (tests/transcript-completion-notice-control.test.ts), + * like the settings page's AccountRow sibling. */ -function CompletionNoticeRow({ +export function CompletionNoticeRow({ sides, onOpenChat, }: { diff --git a/web/packages/app/tests/transcript-completion-notice-control.test.ts b/web/packages/app/tests/transcript-completion-notice-control.test.ts new file mode 100644 index 0000000000..824e96e265 --- /dev/null +++ b/web/packages/app/tests/transcript-completion-notice-control.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment jsdom + +/** + * The completion notice's open control (orchestration v2 ticket 15): each + * side row is a BUTTON that calls `onOpenChat` with THAT row's side-chat id; + * without a host handler (the read-only subagent pane) the row renders inert + * — a plain row, never a dead control. The REAL `CompletionNoticeRow` + * mounts (the mounted-suite idiom, account-row.test.ts): the notice data is + * the engine writer's own shape (completion_notices.rs `notice_entry`), the + * label/short-id/outcome lines come from the lib helpers, and the click is + * a real dispatchEvent through React's root. No JSX (createElement), + * per-file jsdom pragma only. + */ + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { NoticeSide } from "@roboco/proto"; +import { CompletionNoticeRow } from "../src/components/transcript"; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; +}); + +const sides: readonly NoticeSide[] = [ + { chatId: "chat-side-1234", title: "Implement login", outcome: "completed", detail: null }, + { + chatId: "chat-side-5678", + title: "Flaky test", + outcome: "errored", + detail: "the build command timed out", + }, +]; + +describe("the completion notice's open control", () => { + it("clicking a side row opens that side chat — its own id, not the first's", () => { + const opened: string[] = []; + act(() => { + root?.render( + createElement(CompletionNoticeRow, { + sides, + onOpenChat: (chatId: string) => { + opened.push(chatId); + }, + }), + ); + }); + const rows = Array.from( + container!.querySelectorAll("button.completion-notice-side"), + ); + expect(rows).toHaveLength(2, "one control per side chat"); + act(() => { + rows[1]!.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }), + ); + }); + expect(opened).toEqual(["chat-side-5678"]); + }); + + it("renders inert without a host handler — no button, no fake control", () => { + act(() => { + root?.render(createElement(CompletionNoticeRow, { sides })); + }); + expect( + container!.querySelectorAll("button.completion-notice-side"), + ).toHaveLength(0); + expect(container!.querySelectorAll(".completion-notice-side")).toHaveLength(2); + }); + + it("paints the card: the header label, and per side the title, short id and outcome line", () => { + act(() => { + root?.render( + createElement(CompletionNoticeRow, { + sides, + onOpenChat: () => { + // wired, not clicked here + }, + }), + ); + }); + expect( + container!.querySelector(".completion-notice-label")?.textContent, + ).toBe("Completion notice"); + const titles = Array.from( + container!.querySelectorAll(".completion-notice-side-title"), + ).map((node) => node.textContent); + expect(titles).toEqual(["Implement login", "Flaky test"]); + const ids = Array.from( + container!.querySelectorAll(".completion-notice-side-id"), + ).map((node) => node.textContent); + expect(ids).toEqual(["chat-si", "chat-si"]); + const outcomes = Array.from( + container!.querySelectorAll(".completion-notice-side-outcome"), + ).map((node) => node.textContent); + expect(outcomes).toEqual(["finished", "failed: the build command timed out"]); + // The outcome's own tone rides the row's class, one per outcome kind. + const classes = Array.from( + container!.querySelectorAll(".completion-notice-side"), + ).map((node) => node.className); + expect(classes[0]).toContain("completion-notice-side-completed"); + expect(classes[1]).toContain("completion-notice-side-errored"); + }); +}); From 37f3203b8cc5d9a7f1533626e2a53d9d81f26cd8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 04:12:06 +0700 Subject: [PATCH 54/96] Tidy the notice test comparison and wrap two long lines --- crates/ui/src/transcript.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 1f7ff5f80b..18658a4d97 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -1414,10 +1414,9 @@ pub fn rows_for_entry( // A marker with no sides (a partial row off a lenient doc decode) is not // a notice renderable here; its text sibling carries the notice as plain // text, exactly like an older build. - let has_notice = entry - .parts - .iter() - .any(|part| matches!(part, MessagePart::CompletionNotice { sides, .. } if !sides.is_empty())); + let has_notice = entry.parts.iter().any(|part| { + matches!(part, MessagePart::CompletionNotice { sides, .. } if !sides.is_empty()) + }); let flush_group = |rows: &mut Vec<Row>, group: &mut Vec<ToolItem>, group_ix: &mut usize, last_ix: usize| { @@ -7544,7 +7543,8 @@ impl Transcript { let chat_id = side.chat_id.clone(); let title: SharedString = single_line(&side.title).into(); let short_id: SharedString = short_chat_id(&side.chat_id).into(); - let outcome = notice_outcome_line(notice_outcome_verb(side.outcome), side.detail.as_deref()); + let outcome = + notice_outcome_line(notice_outcome_verb(side.outcome), side.detail.as_deref()); let outcome_color = notice_outcome_color(side.outcome, theme); let full_title = title.clone(); let side_id = SharedString::from(format!("{row_id}#side{ix}")); @@ -10553,7 +10553,10 @@ mod tests { .debug_bounds("notice:5#n0#side1") .expect("the notice's second side row renders"); window.simulate_click(row.center(), Modifiers::default()); - assert_eq!(opened.borrow().as_slice(), ["chat-side-5678"]); + assert_eq!( + opened.borrow().as_slice(), + ["chat-side-5678".to_string()].as_slice() + ); } fn with_tool_group_navigation( From a12461cf593a96f89b489abfea71f45476f6a8e6 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 04:19:53 +0700 Subject: [PATCH 55/96] fix(engine): box the recursive failover attempt as a Send future --- crates/engine/src/account_failover.rs | 209 +++++++++++++------------- 1 file changed, 107 insertions(+), 102 deletions(-) diff --git a/crates/engine/src/account_failover.rs b/crates/engine/src/account_failover.rs index 04a4401a65..b101b7a4e3 100644 --- a/crates/engine/src/account_failover.rs +++ b/crates/engine/src/account_failover.rs @@ -194,120 +194,125 @@ enum Attempt { /// later iterations fall back to the best-ranked. `tried` guards against /// a store that keeps offering the same row. #[allow(clippy::too_many_arguments)] -async fn attempt( - engine: &SessionsEngine, - accounts: &AgentAccounts, - chat_id: &str, +// Boxed with an explicit `Send` bound: `attempt` and `watch` recurse +// (a signed-out continuation tries the next candidate), and a recursive +// async fn's future cannot be proven `Send` for `tokio::spawn` otherwise. +fn attempt<'a>( + engine: &'a SessionsEngine, + accounts: &'a AgentAccounts, + chat_id: &'a str, harness: HarnessId, - limited_account_id: Option<&str>, + limited_account_id: Option<&'a str>, previous: Option<String>, mut tried: HashSet<String>, - mut pick: Option<&str>, -) -> Attempt { - for _ in 0..MAX_ATTEMPTS { - if engine.shutting_down() { - return Attempt::Failed("the engine is shutting down".into()); - } - let mut ranked = accounts - .failover_candidates(harness, limited_account_id) - .await; - // Rows this chain already tried are out — a probe that failed - // transiently (the row never changed) must move ON, not re-pick the - // same account and die on the second attempt. Skipped rows (signed - // out, still limited) drop out of the ranking on their own: their - // probes marked them. - ranked.retain(|row| !tried.contains(&row.id)); - // The user's pick must be a candidate — anything else (the limited - // account, a signed-out row, a foreign id) is refused, not silently - // replaced by the recommendation. - let candidate = match pick.take() { - Some(id) => { - let Some(at) = ranked.iter().position(|row| row.id == id) else { - return Attempt::Failed( - "That account is not a candidate — it is limited, signed out, or in use." - .into(), + mut pick: Option<&'a str>, +) -> futures::future::BoxFuture<'a, Attempt> { + Box::pin(async move { + for _ in 0..MAX_ATTEMPTS { + if engine.shutting_down() { + return Attempt::Failed("the engine is shutting down".into()); + } + let mut ranked = accounts + .failover_candidates(harness, limited_account_id) + .await; + // Rows this chain already tried are out — a probe that failed + // transiently (the row never changed) must move ON, not re-pick the + // same account and die on the second attempt. Skipped rows (signed + // out, still limited) drop out of the ranking on their own: their + // probes marked them. + ranked.retain(|row| !tried.contains(&row.id)); + // The user's pick must be a candidate — anything else (the limited + // account, a signed-out row, a foreign id) is refused, not silently + // replaced by the recommendation. + let candidate = match pick.take() { + Some(id) => { + let Some(at) = ranked.iter().position(|row| row.id == id) else { + return Attempt::Failed( + "That account is not a candidate — it is limited, signed out, or in use." + .into(), + ); + }; + Some(ranked.remove(at)) + } + None => ranked.first().cloned(), + }; + let Some(candidate) = candidate else { + return Attempt::NoCandidates; + }; + tried.insert(candidate.id.clone()); + // The chosen account is probed ONCE before switching: fresh meters, + // a hard rejection caught before anything is touched. + let row = match accounts.probe_account(harness, &candidate.id).await { + Ok(row) => row, + Err(err) => { + tracing::warn!( + chat = %chat_id, + account = %candidate.id, + error = %err, + "failover probe failed; trying the next candidate" ); - }; - Some(ranked.remove(at)) + continue; + } + }; + if row.sign_in_state == AgentSignInState::SignedOut { + tracing::info!( + chat = %chat_id, + account = %candidate.id, + "failover candidate is signed out; trying the next" + ); + continue; } - None => ranked.first().cloned(), - }; - let Some(candidate) = candidate else { - return Attempt::NoCandidates; - }; - tried.insert(candidate.id.clone()); - // The chosen account is probed ONCE before switching: fresh meters, - // a hard rejection caught before anything is touched. - let row = match accounts.probe_account(harness, &candidate.id).await { - Ok(row) => row, - Err(err) => { - tracing::warn!( + if row.limited_until.is_some_and(|until| until > chrono::Utc::now()) { + tracing::info!( chat = %chat_id, account = %candidate.id, + "failover candidate is still limited; trying the next" + ); + continue; + } + // Activate — the swap every chat on this harness now runs under. + if let Err(err) = accounts.activate(harness, &row.id).await { + tracing::warn!( + chat = %chat_id, + account = %row.id, error = %err, - "failover probe failed; trying the next candidate" + "failover activation failed; trying the next candidate" ); continue; } - }; - if row.sign_in_state == AgentSignInState::SignedOut { - tracing::info!( - chat = %chat_id, - account = %candidate.id, - "failover candidate is signed out; trying the next" - ); - continue; - } - if row.limited_until.is_some_and(|until| until > chrono::Utc::now()) { - tracing::info!( - chat = %chat_id, - account = %candidate.id, - "failover candidate is still limited; trying the next" - ); - continue; - } - // Activate — the swap every chat on this harness now runs under. - if let Err(err) = accounts.activate(harness, &row.id).await { - tracing::warn!( - chat = %chat_id, - account = %row.id, - error = %err, - "failover activation failed; trying the next candidate" - ); - continue; - } - // Retire the chat's warm runtime so the continuation starts under - // the new login (the fingerprint guard covers every other chat's - // warm runtime at its next dispatch). - let _ = engine.terminate(chat_id).await; - if let Err(reason) = dispatch_continuation(engine, chat_id, harness) { - // The switch landed but the continuation could not be queued: - // undo it, or the harness is left on an account nothing uses. - restore(engine, accounts, chat_id, harness, previous.as_deref()).await; - return Attempt::Failed(reason); + // Retire the chat's warm runtime so the continuation starts under + // the new login (the fingerprint guard covers every other chat's + // warm runtime at its next dispatch). + let _ = engine.terminate(chat_id).await; + if let Err(reason) = dispatch_continuation(engine, chat_id, harness) { + // The switch landed but the continuation could not be queued: + // undo it, or the harness is left on an account nothing uses. + restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + return Attempt::Failed(reason); + } + // The watcher owns the turn's outcome: signed-out → the next + // candidate; other failures → the previous account back. + let watcher_engine = engine.clone(); + let watcher_accounts = accounts.clone(); + let watcher_chat = chat_id.to_string(); + let watcher_limited = limited_account_id.map(str::to_owned); + let watcher_previous = previous.clone(); + tokio::spawn(async move { + watch( + &watcher_engine, + &watcher_accounts, + &watcher_chat, + harness, + watcher_limited.as_deref(), + watcher_previous, + tried, + ) + .await; + }); + return Attempt::Switched(row); } - // The watcher owns the turn's outcome: signed-out → the next - // candidate; other failures → the previous account back. - let watcher_engine = engine.clone(); - let watcher_accounts = accounts.clone(); - let watcher_chat = chat_id.to_string(); - let watcher_limited = limited_account_id.map(str::to_owned); - let watcher_previous = previous.clone(); - tokio::spawn(async move { - watch( - &watcher_engine, - &watcher_accounts, - &watcher_chat, - harness, - watcher_limited.as_deref(), - watcher_previous, - tried, - ) - .await; - }); - return Attempt::Switched(row); - } - Attempt::Failed("too many failover attempts".into()) + Attempt::Failed("too many failover attempts".into()) + }) } /// Queue the continuation: the same harness session, resumed with the From 54b14925df6749909e7ded6f63eb04aa816ad82c Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 04:50:55 +0700 Subject: [PATCH 56/96] fix: settle wiregen layout and desktop compile slips from tickets 07/09 --- crates/ui/src/settings/auto_resume.rs | 2 +- crates/ui/src/settings/shortcuts.rs | 2 +- crates/ui/src/shell.rs | 2 +- web/packages/proto/src/generated/ResumeOnAccountOutcome.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/settings/auto_resume.rs b/crates/ui/src/settings/auto_resume.rs index a2f5cd8fdc..7eba44e378 100644 --- a/crates/ui/src/settings/auto_resume.rs +++ b/crates/ui/src/settings/auto_resume.rs @@ -118,7 +118,7 @@ impl Render for AutoResumeCard { .ready() .is_some_and(|settings| settings.auto_resume_limited); let control: AnyElement = match &self.settings { - Loadable::Ready(_) => widgets::toggle_switch(theme, enabled, "auto-resume-limited") + Loadable::Ready(_) => widgets::toggle_switch(&theme, enabled, "auto-resume-limited") .id("auto-resume-limited-toggle") .tab_index(0) .role(gpui::Role::Switch) diff --git a/crates/ui/src/settings/shortcuts.rs b/crates/ui/src/settings/shortcuts.rs index 5c030aef57..dae535dc80 100644 --- a/crates/ui/src/settings/shortcuts.rs +++ b/crates/ui/src/settings/shortcuts.rs @@ -131,7 +131,7 @@ impl ShortcutsPage { completion_harnesses: popover::Loadable::Idle, completion_task: None, thread_naming: cx - .new(|cx| crate::settings::thread_naming::ThreadNamingCard::new(state, cx)), + .new(|cx| crate::settings::thread_naming::ThreadNamingCard::new(state.clone(), cx)), auto_resume: cx .new(|cx| crate::settings::auto_resume::AutoResumeCard::new(state, cx)), } diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index cb90337190..3fe61f19da 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -4743,7 +4743,7 @@ impl Shell { // same "resets in …" phrasing the banner // uses, one per shown meter. .children( - candidate_reset_caption(account, now).map(|caption| { + candidate_reset_caption(&account, now).map(|caption| { div() .text_size(px(10.5)) .text_color(theme.text_muted) diff --git a/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts b/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts index 41b0119ef8..1990450076 100644 --- a/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts +++ b/web/packages/proto/src/generated/ResumeOnAccountOutcome.ts @@ -13,4 +13,4 @@ export type ResumeOnAccountOutcome = { "type": "switched", accountId: string, /** * The row's email (or display name) — the "Resumed on …" label. */ -accountEmail?: string | null, } | { "type": "noCandidates" } | { "type": "failed", reason: string }; +accountEmail?: string | null, } | { "type": "noCandidates" } | { "type": "failed", reason: string, }; From 55e67cfc2ecc536750816d4dfa0596e91cfed174 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:18:15 +0700 Subject: [PATCH 57/96] feat(forks): continue the real OpenCode session from forked chats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t3-orchestration-v2 ticket 20 (spec stories 57, 58 and 66): forking an OpenCode chat now continues the source's real provider session instead of pasting history into a fresh one, with the budgeted transcript from ticket 01 as the fallback — mirroring ticket 17's Codex shape. - Turn refs: every prompt travels under a client-chosen user message id (1.x `messageID` on prompt_async and command, 2.x `id` on prompt, both `msg_`-prefixed — OpenCode takes client ids on both wires, checked against the 1.18.31 PromptInput/CommandInput schemas and the 2.0.11 OpenAPI). post_prompt emits an AgentEvent::TurnRef naming the session and that id before the prompt's own POST, so the engine stamps it on the transcript entry the turn streams (the generic ticket-17 path). 2.x command turns take no client id, so they carry no ref. - The fork's first run (RunRequest.provider_fork, injected by the engine's already-merged generic path) forks the source session before prompting: the boundary is EXCLUSIVE on both wires — 1.x `POST /session/{id}/fork {messageID}`, 2.x `POST /api/session/{id}/fork {before}` (v2 shape confirmed against the 2.0.11 packages/protocol/openapi.json, the source of /doc) — so the cut is the NEXT turn's user message, resolved from the session's message list (1.x GET /session/{id}/message, 2.x GET /api/session/{id}/message, cursor-paginated); with no next turn the whole session is copied (no boundary). - Fallbacks: a message list that fails, a boundary the session no longer holds, or a refused fork POST all fall back to a fresh session with the engine's budgeted transcript pasted in front of the prompt (converted through harness_prompt here, since the engine leaves OpenCode's canonical conversion to the driver) — byte-identical to the non-native paste. On success nothing is pasted. Tests: unit tests for the id mint, both message-list shapes, the fork-cut resolution (next turn, latest turn, synthetic report, missing boundary) and the id-carrying bodies; harness integration tests on the scripted server, now speaking both wire generations (v2 mode with /api routes, {data} wrappers and raw SSE frames, plus per-session message lists and fork outcomes): one TurnRef per turn with the posted id, the fork cut at the next turn on v1 and v2, whole-session fork at the latest turn, and the refused/unresolvable fallbacks pasting the budgeted history. Not run locally per the verification policy; CI is the signal (the suites are already on the ui-tests.yml lines). --- crates/harness/src/opencode/mod.rs | 379 ++++++++++++-- crates/harness/src/opencode/tests.rs | 79 ++- crates/harness/tests/opencode.rs | 704 ++++++++++++++++++++++++++- 3 files changed, 1106 insertions(+), 56 deletions(-) diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index ba0e4d902b..78eb8e724c 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -35,6 +35,18 @@ //! THAT session — exactly what the desktop's working-predicate keys on. //! Busy is re-asserted at the top of every agent-loop iteration, so idle //! after busy is authoritative, not a lull. +//! - every prompt carries a client-chosen user message id (1.x `messageID`, +//! 2.x `id`, `msg_`-prefixed) and emits an [`AgentEvent::TurnRef`] naming +//! it: the engine records the ref on the turn's transcript entry, so a +//! fork can continue the provider session at exactly that turn +//! (t3-orchestration-v2 ticket 20). +//! - a native fork (ticket 20) forks the SOURCE provider session before +//! prompting — 1.x `POST /session/{id}/fork {messageID}`, 2.x +//! `POST /api/session/{id}/fork {before}` — both EXCLUSIVE at a message +//! id, so the cut is the NEXT turn's user message, resolved from the +//! session's message list; with no next turn the whole session is +//! copied. A refused or unresolvable fork falls back to a fresh session +//! with the engine's budgeted transcript pasted in front of the prompt. //! - reasoning streams as reasoning parts on both wires → //! [`AgentEvent::ReasoningDelta`], the thinking feed. //! @@ -1330,6 +1342,30 @@ fn new_message_id() -> String { uuid::Uuid::new_v4().to_string() } +/// A client-chosen user message id for a posted turn +/// (t3-orchestration-v2 ticket 20): both wires take one on the prompt (1.x +/// `messageID`, 2.x `id`), which becomes the message's server-side id — +/// the turn's provider ref, and the anchor a later fork cuts the session +/// at. 2.x requires the `msg_` prefix; a fresh uuid keeps it unique across +/// the server (2.x answers a repeat id in another session with a 409). +fn new_user_message_id() -> String { + format!("msg_{}", uuid::Uuid::new_v4().simple()) +} + +/// The provider turn reference for a prompt just posted (ticket 20): the +/// client-chosen user message id is the turn's ref — the engine stamps it +/// on the transcript entry the turn streams, so a fork can continue the +/// provider session at exactly that turn. +fn turn_ref_event(session_id: &str, user_message_id: &str) -> AgentEvent { + AgentEvent::TurnRef { + reference: roboco_proto::ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: session_id.to_owned(), + turn_id: user_message_id.to_owned(), + }, + } +} + /// Rotate the assistant message id; returns (previous, next). fn rotate(id: &mut String) -> (String, String) { let prev = std::mem::replace(id, new_message_id()); @@ -1510,41 +1546,77 @@ async fn run_session(session: Session) { .and_then(Value::as_str) .filter(|a| !a.is_empty()); - // ---- session create/resume ------------------------------------------- + // ---- session create/fork/resume -------------------------------------- + // The budgeted transcript a refused native fork falls back to (ticket + // 20): set when the fork fails, applied in front of the first prompt + // below. The interrupted-start path leaves it unset (nothing was + // attempted). + let mut fork_fallback: Option<String> = None; let setup = async { - let session_id = match &request.resume { - Some(resume) => { - // Sessions are durable server-side: resume = reuse the id. - match server.session_info(resume, dir).await { - Ok(info) => { - let id = info - .get("id") - .and_then(Value::as_str) - .unwrap_or(resume) - .to_owned(); - if server.protocol().await == Protocol::V2 - && let Some(agent) = agent - { - server - .post_json( - &format!("/api/session/{id}/agent"), - dir, - &json!({"agent": agent}), - ) - .await?; - } - id + let session_id = if let Some(fork) = request.provider_fork.clone() { + // A native fork (t3-orchestration-v2 ticket 20): fork the source + // provider session at the boundary turn instead of starting + // fresh — the copied history rides the provider session, so + // nothing is pasted into the prompt. A fork the server refuses + // (an unknown boundary, a call this build can't make) is a + // fallback, not an error: start fresh and deliver the budgeted + // history the engine attached. + match fork_session(&server, dir, &fork).await { + Ok(forked) => { + if server.protocol().await == Protocol::V2 + && let Some(agent) = agent + { + server + .post_json( + &format!("/api/session/{forked}/agent"), + dir, + &json!({"agent": agent}), + ) + .await?; } - Err(e) => { - tracing::debug!( - target: "roboco_harness::opencode", - "session resume failed (starting fresh): {e}" - ); - create_session(&server, dir, agent).await? + forked + } + Err(e) => { + tracing::debug!( + target: "roboco_harness::opencode", + "session fork refused (starting fresh with the budgeted history): {e}" + ); + fork_fallback = fork.fallback_history; + create_session(&server, dir, agent).await? + } + } + } else if let Some(resume) = &request.resume { + // Sessions are durable server-side: resume = reuse the id. + match server.session_info(resume, dir).await { + Ok(info) => { + let id = info + .get("id") + .and_then(Value::as_str) + .unwrap_or(resume) + .to_owned(); + if server.protocol().await == Protocol::V2 + && let Some(agent) = agent + { + server + .post_json( + &format!("/api/session/{id}/agent"), + dir, + &json!({"agent": agent}), + ) + .await?; } + id + } + Err(e) => { + tracing::debug!( + target: "roboco_harness::opencode", + "session resume failed (starting fresh): {e}" + ); + create_session(&server, dir, agent).await? } } - None => create_session(&server, dir, agent).await?, + } else { + create_session(&server, dir, agent).await? }; // Provider catalog: resolves the model's advertised reasoning @@ -1593,6 +1665,22 @@ async fn run_session(session: Session) { } }; + // A refused fork's fresh session still owes the chat its prior context: + // the budgeted transcript rides in front of the prompt, byte-identical + // to the paste a non-native fork delivers (the engine leaves OpenCode's + // canonical conversion to this driver, so the history converts here + // exactly as the prompt did in `run`). The prompt itself still governs + // native-command routing below — the engine attaches no fallback to a + // native command, so the concatenation can never hide one. + let delivered_prompt = match &fork_fallback { + Some(history) => format!( + "{}{}", + roboco_proto::invocation::harness_prompt(history, HarnessId::Opencode), + request.prompt + ), + None => request.prompt.clone(), + }; + let (mut model, mut variant) = model_selection(&providers, &request); let context_windows: HashMap<String, u64> = providers .all @@ -1686,13 +1774,15 @@ async fn run_session(session: Session) { let stall = stall_bound(); let (command_failure_tx, mut command_failure_rx) = mpsc::unbounded_channel(); let mut turn_generation = 0_u64; + let first_user_message_id = new_user_message_id(); if let Err(e) = post_prompt( &server, &bus_tx, + &event_tx, &session_id, dir, &commands, - &request.prompt, + &delivered_prompt, initial_native_command_selected, turn_generation, &command_failure_tx, @@ -1700,6 +1790,7 @@ async fn run_session(session: Session) { model: model.as_ref(), variant: variant.as_deref(), attachments: &request.attachments, + user_message_id: &first_user_message_id, }, ) .await @@ -1819,6 +1910,9 @@ async fn run_session(session: Session) { break $label; } let steer = texts.join("\n\n"); + // One posted prompt = one user message, whatever queued + // texts it merges — one turn, one provider ref (ticket 20). + let steer_user_message_id = new_user_message_id(); let adopted = match config { Some(next) => adopt_config(&server, &session_id, dir, &next) .await @@ -1829,6 +1923,7 @@ async fn run_session(session: Session) { Ok(()) => post_prompt( &server, &bus_tx, + &event_tx, &session_id, dir, &commands, @@ -1840,6 +1935,7 @@ async fn run_session(session: Session) { model: model.as_ref(), variant: variant.as_deref(), attachments: &attachments, + user_message_id: &steer_user_message_id, }, ) .await, @@ -2077,6 +2173,9 @@ async fn run_session(session: Session) { assistant_message_id: Some(prev), next_assistant_message_id: Some(next), }).await; + // One posted prompt — one user message, one + // provider ref (ticket 20). + let steer_user_message_id = new_user_message_id(); let adopted = match &steer.config { Some(next) => adopt_config(&server, &session_id, dir, next) .await @@ -2087,6 +2186,7 @@ async fn run_session(session: Session) { Ok(()) => post_prompt( &server, &bus_tx, + &event_tx, &session_id, dir, &commands, @@ -2098,6 +2198,7 @@ async fn run_session(session: Session) { model: model.as_ref(), variant: variant.as_deref(), attachments: &steer.attachments, + user_message_id: &steer_user_message_id, }, ) .await, @@ -2384,6 +2485,151 @@ async fn create_session( unreachable!("create_session retry loop returns from every path") } +/// Fork the source provider session for a native fork +/// (t3-orchestration-v2 ticket 20): OpenCode's fork is EXCLUSIVE at a +/// message id — 1.x `POST /session/{id}/fork {messageID}`, 2.x +/// `POST /api/session/{id}/fork {before}` (both shapes checked against +/// 1.18.31 and 2.0.11) — so keeping everything through the boundary turn +/// means cutting before the NEXT turn's user message; with no next turn +/// the whole session is copied (no boundary). `fork.last_turn` is the +/// boundary turn's own user message id (the provider turn ref recorded on +/// the boundary entry); the next one is resolved from the session's +/// message list. Any failure — the list, the boundary, the call — is the +/// caller's fallback (fresh session + the budgeted paste), never an error +/// the turn dies on. +async fn fork_session( + server: &Server, + dir: Option<&str>, + fork: &roboco_proto::ProviderFork, +) -> Result<String, HarnessError> { + let messages = session_messages(server, dir, &fork.session_id).await?; + let boundary = fork_cut(&messages, &fork.last_turn)?; + let (path, body) = match server.protocol().await { + Protocol::V1 => ( + format!("/session/{}/fork", fork.session_id), + boundary + .as_ref() + .map(|id| json!({ "messageID": id })) + .unwrap_or_else(|| json!({})), + ), + Protocol::V2 => ( + format!("/api/session/{}/fork", fork.session_id), + boundary + .as_ref() + .map(|id| json!({ "before": id })) + .unwrap_or_else(|| json!({})), + ), + }; + let created = server.post_json(&path, dir, &body).await?; + created + .pointer("/data/id") + .or_else(|| created.get("id")) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| { + HarnessError::Protocol("opencode session fork returned no id".into()) + }) +} + +/// One message-list entry reduced to what a fork cut needs: (id, role). +/// 1.x wraps the message in `{info: {id, role}, parts}`; 2.x items are +/// flat `{id, type, …}` — the role lives under either key. +fn message_role(item: &Value) -> Option<(String, String)> { + let info = item.get("info").unwrap_or(item); + let id = info.get("id").and_then(Value::as_str)?; + let role = info + .get("role") + .or_else(|| info.get("type")) + .and_then(Value::as_str)?; + Some((id.to_owned(), role.to_owned())) +} + +/// Whether a message starts a turn: a user prompt, or a 2.x synthetic +/// report the session answers as its next user input (both begin a new +/// execution; 1.x only ever has `user`/`assistant`). +fn starts_turn(role: &str) -> bool { + role == "user" || role == "synthetic" +} + +/// Resolve the exclusive fork cut from a session's (id, role) message list +/// in creation order: the id of the next turn-starting message after +/// `last_turn` (the boundary turn's own user message), `None` when nothing +/// follows (the whole session is copied). `Err` when the boundary message +/// is gone from the list — the session can no longer be cut there. +fn fork_cut( + messages: &[(String, String)], + last_turn: &str, +) -> Result<Option<String>, HarnessError> { + let pos = messages + .iter() + .position(|(id, _)| id == last_turn) + .ok_or_else(|| { + HarnessError::Protocol(format!( + "opencode session no longer holds message {last_turn}" + )) + })?; + Ok(messages[pos + 1..] + .iter() + .find(|(_, role)| starts_turn(role)) + .map(|(id, _)| id.clone())) +} + +/// The session's message list as (id, role) pairs in creation order — +/// 1.x `GET /session/{id}/message` (a raw array of `{info, parts}` +/// entries), 2.x `GET /api/session/{id}/message` (cursor-paginated +/// `{data, cursor}`; `order=asc` fixes oldest-first and the cursor alone +/// pages on — the spec forbids combining it with `order`). The cursor is +/// opaque, so it is percent-encoded into the query. +async fn session_messages( + server: &Server, + dir: Option<&str>, + session_id: &str, +) -> Result<Vec<(String, String)>, HarnessError> { + /// A page cap so a server that keeps feeding cursors cannot hang the + /// fork; hitting it refuses the fork (the budgeted paste) rather than + /// risking a cut resolved against an incomplete list. + const MAX_MESSAGE_PAGES: usize = 1024; + + let protocol = server.protocol().await; + let mut messages = Vec::new(); + let mut cursor: Option<String> = None; + for page in 0..MAX_MESSAGE_PAGES { + let path = match (protocol, cursor.as_deref()) { + (Protocol::V1, _) => format!("/session/{session_id}/message"), + (Protocol::V2, None) => format!("/api/session/{session_id}/message?order=asc"), + (Protocol::V2, Some(cursor)) => { + // `encode_directory` is a plain percent-encoder; it serves + // opaque query values as well as the header. + format!( + "/api/session/{session_id}/message?cursor={}", + encode_directory(cursor) + ) + } + }; + let response = server.get_json(&path, dir).await?; + let next = response + .pointer("/cursor/next") + .and_then(Value::as_str) + .filter(|cursor| !cursor.is_empty()) + .map(str::to_owned); + for item in unwrap_data(response).as_array().cloned().unwrap_or_default() { + if let Some(entry) = message_role(&item) { + messages.push(entry); + } + } + match (protocol, next) { + (Protocol::V1, _) | (Protocol::V2, None) => return Ok(messages), + (Protocol::V2, Some(_)) if page + 1 == MAX_MESSAGE_PAGES => { + return Err(HarnessError::Protocol( + "opencode message list never reached its end".into(), + )) + } + (Protocol::V2, Some(next)) => cursor = Some(next), + } + } + unreachable!("the loop returns or errors on its last page") +} + fn context_usage_event(info: &Value, context_windows: &HashMap<String, u64>) -> Option<AgentEvent> { let tokens = info.get("tokens")?; let counts: Vec<u64> = [ @@ -2490,12 +2736,15 @@ fn pick_variant( .map(str::to_owned) } -/// Build a `prompt_async` body: text part + attachment file parts. +/// Build a `prompt_async` body: text part + attachment file parts. The +/// client-chosen `messageID` rides the top level (1.x `PromptInput` takes +/// it; the server adopts it as the user message's id — ticket 20). fn prompt_body( prompt: &str, model: Option<(&str, &str)>, variant: Option<&str>, attachments: &[String], + message_id: &str, ) -> Value { let mut parts = vec![json!({ "type": "text", "text": prompt })]; for path in attachments { @@ -2511,6 +2760,10 @@ fn prompt_body( } let mut body = serde_json::Map::new(); body.insert("parts".into(), Value::Array(parts)); + body.insert( + "messageID".into(), + Value::String(message_id.to_owned()), + ); if let Some((provider, model)) = model { body.insert( "model".into(), @@ -2523,11 +2776,10 @@ fn prompt_body( Value::Object(body) } -/// 2.x prompt body: plain text plus `{uri, name}` file attachments. Model -/// and variant do NOT ride here — they were set on the session at run -/// start (`POST /api/session/{id}/model`). -fn prompt_body_v2(prompt: &str, attachments: &[String]) -> Value { - let files: Vec<Value> = attachments +/// 2.x file attachments: `{uri, name}` pairs shared by the prompt and +/// command bodies. +fn prompt_files_v2(attachments: &[String]) -> Vec<Value> { + attachments .iter() .map(|path| { json!({ @@ -2538,8 +2790,15 @@ fn prompt_body_v2(prompt: &str, attachments: &[String]) -> Value { .unwrap_or_default(), }) }) - .collect(); - json!({ "text": prompt, "files": files }) + .collect() +} + +/// 2.x prompt body: plain text plus file attachments, under the +/// client-chosen message `id` (the turn's provider ref — ticket 20; 2.x +/// requires the `msg_` prefix). Model and variant do NOT ride here — they +/// were set on the session at run start (`POST /api/session/{id}/model`). +fn prompt_body_v2(prompt: &str, attachments: &[String], message_id: &str) -> Value { + json!({ "id": message_id, "text": prompt, "files": prompt_files_v2(attachments) }) } fn mime_for(path: &str) -> &'static str { @@ -2560,10 +2819,14 @@ fn mime_for(path: &str) -> &'static str { /// What a posted turn carries besides its text (1.x folds model/variant /// into the prompt body; 2.x ignores them there — set on the session). +/// `user_message_id` is the client-chosen message id the turn travels +/// under (ticket 20): the turn's provider ref, and the anchor a later +/// fork cuts the provider session at. struct TurnSpec<'a> { model: Option<&'a (String, String)>, variant: Option<&'a str>, attachments: &'a [String], + user_message_id: &'a str, } /// Send a turn: a leading `/command` known to the agent routes through the @@ -2584,7 +2847,7 @@ fn command_body_v2( { let mut body = json!({"name": name, "text": text}); if !attachments.is_empty() { - body["files"] = prompt_body_v2(text, attachments)["files"].clone(); + body["files"] = Value::Array(prompt_files_v2(attachments)); } body } else { @@ -2595,6 +2858,7 @@ fn command_body_v2( async fn post_prompt( server: &Server, bus_tx: &mpsc::Sender<BusMsg>, + event_tx: &mpsc::Sender<Result<AgentEvent, HarnessError>>, session_id: &str, dir: Option<&str>, commands: &[SlashCommand], @@ -2608,6 +2872,7 @@ async fn post_prompt( model, variant, attachments, + user_message_id, } = spec; let protocol = server.protocol().await; if let Some((name, arguments)) = @@ -2619,15 +2884,21 @@ async fn post_prompt( .into(), )); } - // 1.x names the args `arguments`; 2.x `text`. - let (path, cmd_body) = match protocol { + // 1.x names the args `arguments` and takes the client message id + // (`messageID`, like a prompt — T3 sends it there too), so the + // command turn still gets its provider ref; 2.x `text`, and its + // command endpoint takes NO id (checked against the 2.0.11 + // schema), so that turn carries no ref. + let (path, cmd_body, id_carried) = match protocol { Protocol::V1 => ( format!("/session/{session_id}/command"), - json!({ "command": name, "arguments": arguments }), + json!({ "command": name, "arguments": arguments, "messageID": user_message_id }), + true, ), Protocol::V2 => ( format!("/api/session/{session_id}/command"), command_body_v2(server.version.get(), name, &arguments, attachments), + false, ), }; let server_base = server.base.clone(); @@ -2636,6 +2907,16 @@ async fn post_prompt( let path_owned = path.clone(); let protocol = server.protocol.clone(); let command_failure_tx = command_failure_tx.clone(); + // The command turn's provider ref (ticket 20), emitted before the + // endpoint's own request so it precedes every event the turn + // streams. 2.x commands carry no client id — no ref for them. + if id_carried + && !send(event_tx, turn_ref_event(session_id, user_message_id)).await + { + return Err(HarnessError::Protocol( + "opencode event consumer closed".into(), + )); + } tokio::spawn(async move { let server = Server { child: None, @@ -2679,13 +2960,23 @@ async fn post_prompt( model.map(|(provider, model)| (provider.as_str(), model.as_str())), variant, attachments, + user_message_id, ), ), Protocol::V2 => ( format!("/api/session/{session_id}/prompt"), - prompt_body_v2(prompt, attachments), + prompt_body_v2(prompt, attachments, user_message_id), ), }; + // The turn's provider ref (ticket 20): emitted before the prompt's own + // POST so it precedes every event the turn streams — the engine stamps + // it on the transcript entry the turn writes, and a fork of the chat + // cuts the provider session at it. + if !send(event_tx, turn_ref_event(session_id, user_message_id)).await { + return Err(HarnessError::Protocol( + "opencode event consumer closed".into(), + )); + } let server = Server { child: None, base: server.base.clone(), diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index 477d18ed4e..9deab90ac3 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -1057,16 +1057,19 @@ fn variants_only_ride_models_that_advertise_them() { } #[test] -fn prompt_body_carries_model_variant_and_attachments() { +fn prompt_body_carries_model_variant_attachments_and_message_id() { let body = prompt_body( "hello", Some(("anthropic", "claude-opus-5")), Some("high"), &["/tmp/shot.png".to_owned()], + "msg_unit", ); assert_eq!(body["model"]["providerID"], "anthropic"); assert_eq!(body["model"]["modelID"], "claude-opus-5"); assert_eq!(body["variant"], "high"); + // The client-chosen message id (ticket 20): the turn's provider ref. + assert_eq!(body["messageID"], "msg_unit"); assert_eq!(body["parts"][0]["type"], "text"); assert_eq!(body["parts"][0]["text"], "hello"); assert_eq!(body["parts"][1]["type"], "file"); @@ -1074,6 +1077,73 @@ fn prompt_body_carries_model_variant_and_attachments() { assert_eq!(body["parts"][1]["url"], "file:///tmp/shot.png"); } +#[test] +fn new_user_message_ids_fit_both_wires() { + // 2.x requires the `msg_` prefix on prompt ids and fork boundaries. + let id = new_user_message_id(); + assert!(id.starts_with("msg_"), "{id}"); + assert_ne!(id, new_user_message_id()); +} + +#[test] +fn message_role_reads_both_list_shapes() { + // 1.x: `{info: {id, role}, parts}`. 2.x: flat `{id, type}`. + let v1 = json!({ "info": { "id": "msg_1", "role": "user" }, "parts": [] }); + let v2 = json!({ "id": "msg_2", "type": "assistant", "time": { "created": 1 } }); + assert_eq!( + message_role(&v1), + Some(("msg_1".to_owned(), "user".to_owned())) + ); + assert_eq!( + message_role(&v2), + Some(("msg_2".to_owned(), "assistant".to_owned())) + ); + // A synthetic 2.x report is a turn start the fork must cut at too. + let synthetic = json!({ "id": "msg_3", "type": "synthetic" }); + assert_eq!( + message_role(&synthetic), + Some(("msg_3".to_owned(), "synthetic".to_owned())) + ); + assert_eq!(message_role(&json!({ "parts": [] })), None); +} + +#[test] +fn fork_cut_addresses_the_next_turns_user_message() { + // The boundary turn's own message starts the list; the cut is the NEXT + // user message (OpenCode's fork boundary is exclusive). + let messages: Vec<(String, String)> = [ + ("msg_first", "user"), + ("msg_first_reply", "assistant"), + ("msg_second", "user"), + ("msg_second_reply", "assistant"), + ] + .into_iter() + .map(|(id, role)| (id.to_owned(), role.to_owned())) + .collect(); + assert_eq!( + fork_cut(&messages, "msg_first").unwrap(), + Some("msg_second".to_owned()) + ); + // The boundary turn is the latest: nothing follows → copy the whole + // session (no cut). + assert_eq!(fork_cut(&messages, "msg_second").unwrap(), None); + // A synthetic report the session answers starts the next turn too. + let with_report: Vec<(String, String)> = [ + ("msg_first", "user"), + ("msg_report", "synthetic"), + ] + .into_iter() + .map(|(id, role)| (id.to_owned(), role.to_owned())) + .collect(); + assert_eq!( + fork_cut(&with_report, "msg_first").unwrap(), + Some("msg_report".to_owned()) + ); + // A boundary the session no longer holds refuses the fork (the caller + // falls back to the budgeted paste). + assert!(fork_cut(&messages, "msg_gone").is_err()); +} + fn feed_with_assistant(message: &str) -> SessionFeed { let mut feed = SessionFeed::default(); feed.assistant_messages.insert(message.into(), true); @@ -1648,8 +1718,9 @@ fn v2_model_list_folds_into_provider_catalog() { } #[test] -fn prompt_body_v2_carries_text_and_files_only() { - let body = prompt_body_v2("hello", &["/tmp/shot.png".to_owned()]); +fn prompt_body_v2_carries_text_files_and_the_message_id() { + let body = prompt_body_v2("hello", &["/tmp/shot.png".to_owned()], "msg_unit"); + assert_eq!(body["id"], "msg_unit"); assert_eq!(body["text"], "hello"); assert_eq!(body["files"][0]["uri"], "file:///tmp/shot.png"); assert_eq!(body["files"][0]["name"], "shot.png"); @@ -2078,7 +2149,7 @@ fn v2_command_bodies_follow_server_version() { let attachments = vec!["/workspace/image.png".into()]; assert_eq!( command_body_v2(Some(&version), "test", "args", &attachments)["files"], - prompt_body_v2("args", &attachments)["files"] + prompt_body_v2("args", &attachments, "msg_unit")["files"] ); } } diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index fdf26cc226..45622e4a56 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -16,7 +16,8 @@ use tokio::sync::{broadcast, mpsc, oneshot}; use roboco_harness::{ CancellationToken, Harness, HarnessError, OpencodeHarness, RunControls, SteerMessage, TurnControl}; use roboco_proto::{ - AgentEvent, DoneStatus, ReasoningLevel, RunRequest, SandboxLevel, ToolCall, UserInputAnswer, + AgentEvent, DoneStatus, HarnessId, ProviderFork, ProviderTurnRef, ReasoningLevel, RunRequest, + SandboxLevel, ToolCall, UserInputAnswer, }; // --------------------------------------------------------------------------- @@ -44,10 +45,35 @@ struct FakeOpencode { fail_session_creates: Arc<Mutex<u32>>, /// Answer `POST …/abort` with a 500 (a wedged server). fail_aborts: Arc<Mutex<bool>>, + /// Serve the 2.x `/api` surface instead: version-bearing `/api/info`, + /// `{data}`-wrapped responses, raw (unenveloped) SSE frames. + v2: bool, + /// Per-session message lists the fork's boundary resolution reads + /// (`GET /session/{id}/message`, `GET /api/session/{id}/message`). + /// Unlisted sessions 404 — the fork's "a call fails" fallback. + messages: Arc<Mutex<std::collections::HashMap<String, Vec<Value>>>>, + /// Fork outcomes by SOURCE session id: (status, response). An + /// unconfigured source 404s the fork (the refused-fork fallback). + forks: Arc< + Mutex< + std::collections::HashMap< + String, + (&'static str, Value), + >, + >, + >, } impl FakeOpencode { async fn start() -> Self { + Self::start_proto(false).await + } + + async fn start_v2() -> Self { + Self::start_proto(true).await + } + + async fn start_proto(v2: bool) -> Self { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); let (events, _) = broadcast::channel::<(u64, String)>(256); @@ -64,6 +90,9 @@ impl FakeOpencode { first_prompt_had_subscriber: Arc::new(Mutex::new(None)), fail_session_creates: Arc::new(Mutex::new(0)), fail_aborts: Arc::new(Mutex::new(false)), + v2, + messages: Arc::default(), + forks: Arc::default(), }; let accept = fake.clone(); tokio::spawn(async move { @@ -79,7 +108,8 @@ impl FakeOpencode { } /// Push one bus event (the driver accepts both the bare and the - /// `/global/event` envelope; the fake uses the enveloped form). + /// `/global/event` envelope; the fake uses the enveloped form on 1.x + /// and the raw 2.x frame shape on 2.x). fn emit(&self, payload: Value) { if payload["type"] == "session.status" && let Some(id) = payload["properties"]["sessionID"].as_str() @@ -89,10 +119,14 @@ impl FakeOpencode { .unwrap() .insert(id.to_owned(), payload["properties"]["status"].clone()); } - let framed = format!( - "data: {}\n\n", - json!({ "directory": "/", "payload": payload }) - ); + let framed = if self.v2 { + format!("data: {}\n\n", payload) + } else { + format!( + "data: {}\n\n", + json!({ "directory": "/", "payload": payload }) + ) + }; let mut backlog = self.backlog.lock().unwrap(); let seq = backlog.len() as u64; backlog.push((seq, framed.clone())); @@ -103,6 +137,24 @@ impl FakeOpencode { *self.providers.lock().unwrap() = providers; } + /// Install a session's message list (creation order) for the fork's + /// boundary resolution. + fn set_messages(&self, session: &str, messages: Vec<Value>) { + self.messages + .lock() + .unwrap() + .insert(session.to_owned(), messages); + } + + /// Install the fork outcome for a source session (status + response + /// body; 1.x returns the session info raw, 2.x wrapped in `{data}`). + fn set_fork(&self, source: &str, status: &'static str, response: Value) { + self.forks + .lock() + .unwrap() + .insert(source.to_owned(), (status, response)); + } + fn posts_to(&self, path: &str) -> Vec<Value> { self.posts .lock() @@ -155,7 +207,7 @@ impl FakeOpencode { let target = parts.next().unwrap_or_default().to_owned(); let path = target.split('?').next().unwrap_or_default().to_owned(); - if method == "GET" && path == "/global/event" { + if method == "GET" && (path == "/global/event" || path == "/api/event") { // Subscribe FIRST, then snapshot the backlog: frames landing // in between arrive on both channels and dedupe by sequence. let mut rx = self.events.subscribe(); @@ -190,7 +242,7 @@ impl FakeOpencode { } if method == "POST" { - if path.ends_with("/prompt_async") { + if path.ends_with("/prompt_async") || path.ends_with("/prompt") { let mut first = self.first_prompt_had_subscriber.lock().unwrap(); if first.is_none() { *first = Some(self.events.receiver_count() > 0); @@ -212,6 +264,40 @@ impl FakeOpencode { } fn route(&self, method: &str, path: &str) -> (&'static str, Value) { + if self.v2 { + return self.route_v2(method, path); + } + self.route_v1(method, path) + } + + /// The session id segment of a `/…/session/{id}/…` path. + fn session_id_of(path: &str, prefix: &str) -> Option<String> { + let rest = path.strip_prefix(prefix)?; + let id = rest.split('/').next()?; + (!id.is_empty()).then(|| id.to_owned()) + } + + /// The message list for a session's boundary resolution, when the fake + /// has one (unlisted sessions 404 — the fork's resolution fallback). + fn message_list(&self, path: &str, prefix: &str) -> Option<Value> { + let id = Self::session_id_of(path, prefix)?; + self.messages.lock().unwrap().get(&id).cloned() + } + + /// The configured fork outcome for a source session (unconfigured + /// sources 404 — the refused-fork fallback). + fn fork_outcome(&self, path: &str, prefix: &str) -> (&'static str, Value) { + let id = Self::session_id_of(path, prefix); + match id + .as_deref() + .and_then(|id| self.forks.lock().unwrap().get(id)) + { + Some((status, body)) => (*status, body.clone()), + None => ("404 Not Found", json!({})), + } + } + + fn route_v1(&self, method: &str, path: &str) -> (&'static str, Value) { match (method, path) { ("GET", "/global/health") => ("200 OK", json!({ "healthy": true })), ("GET", "/provider") => ("200 OK", self.providers.lock().unwrap().clone()), @@ -239,6 +325,19 @@ impl FakeOpencode { Value::Object(self.statuses.lock().unwrap().clone()), ), ("GET", "/session/ses_resume") => ("200 OK", json!({ "id": "ses_resume" })), + // The fork's boundary resolution: a raw array of + // `{info: {id, role}, parts}` entries (the 1.18.31 shape). + ("GET", p) if p.starts_with("/session/") && p.ends_with("/message") => { + match self.message_list(p, "/session/") { + Some(list) => ("200 OK", Value::Array(list)), + None => ("404 Not Found", json!({})), + } + } + // A native fork: `POST /session/{id}/fork {messageID}` answering + // the new session's info (raw, 1.x). + ("POST", p) if p.starts_with("/session/") && p.ends_with("/fork") => { + self.fork_outcome(p, "/session/") + } ("GET", p) if p.starts_with("/session/") => ("404 Not Found", json!({})), ("POST", p) if p.ends_with("/prompt_async") => ("204 No Content", json!({})), ("POST", p) if p.ends_with("/abort") && *self.fail_aborts.lock().unwrap() => ( @@ -252,6 +351,65 @@ impl FakeOpencode { _ => ("404 Not Found", json!({ "missing": path })), } } + + /// The 2.x `/api` surface: version-bearing info endpoints (protocol + /// detection), `{data}`-wrapped responses, and the fork/message routes + /// in their 2.x shapes. + fn route_v2(&self, method: &str, path: &str) -> (&'static str, Value) { + match (method, path) { + ("GET", "/api/info") | ("GET", "/api/health") | ("GET", "/api/status") => { + ("200 OK", json!({ "version": "2.0.11", "healthy": true })) + } + ("GET", "/api/model") => ( + "200 OK", + // Non-empty: the catalog-sync retry loop must not stall tests. + json!({ "data": [{ + "providerID": "test-provider", + "id": "muse", + "name": "Muse", + "limit": { "context": 100000 }, + "variants": [], + "enabled": true, + }] }), + ), + ("GET", "/api/command") => { + ("200 OK", json!({ "data": self.commands.lock().unwrap().clone() })) + } + ("POST", "/api/session") => { + let mut fails = self.fail_session_creates.lock().unwrap(); + if *fails > 0 { + *fails -= 1; + ("500 Internal Server Error", json!({ "name": "UnknownError" })) + } else { + ("200 OK", json!({ "data": { "id": "ses_test2" } })) + } + } + ("GET", "/api/session/active") => ("200 OK", json!({ "data": {} })), + // The fork's boundary resolution: `{data, cursor}` pages of + // flat `{id, type}` entries (the 2.0.11 shape). + ("GET", p) if p.starts_with("/api/session/") && p.ends_with("/message") => { + match self.message_list(p, "/api/session/") { + Some(list) => ("200 OK", json!({ "data": list, "cursor": {} })), + None => ("404 Not Found", json!({})), + } + } + // A native fork: `POST /api/session/{id}/fork {before}` + // answering `{data: {id}}`. + ("POST", p) if p.starts_with("/api/session/") && p.ends_with("/fork") => { + self.fork_outcome(p, "/api/session/") + } + ("GET", p) if p.starts_with("/api/session/") => { + let id = Self::session_id_of(p, "/api/session/").unwrap_or_default(); + ("200 OK", json!({ "data": { "id": id } })) + } + ("POST", p) if p.ends_with("/prompt") => ("200 OK", json!({})), + ("POST", p) if p.ends_with("/interrupt") => ("200 OK", json!(true)), + ("POST", p) if p.contains("/permission/") || p.contains("/question/") => { + ("200 OK", json!(true)) + } + _ => ("404 Not Found", json!({ "missing": path })), + } + } } // --------------------------------------------------------------------------- @@ -306,6 +464,23 @@ fn harness(fake: &FakeOpencode) -> OpencodeHarness { OpencodeHarness::new().with_base_url(fake.base.clone()) } +/// A request carrying a native-fork directive (what the engine's first +/// dispatch of a forked chat injects from the recorded intent). +fn fork_request( + prompt: &str, + session_id: &str, + last_turn: &str, + fallback_history: Option<&str>, +) -> RunRequest { + let mut request = request(prompt); + request.provider_fork = Some(ProviderFork { + session_id: session_id.into(), + last_turn: last_turn.into(), + fallback_history: fallback_history.map(str::to_owned), + }); + request +} + /// Emit the standard opening frames of an assistant turn. fn assistant_message(fake: &FakeOpencode, session: &str, message: &str) { fake.emit(json!({ @@ -325,6 +500,47 @@ fn idle(fake: &FakeOpencode, session: &str) { })); } +/// 2.x bus frames: an execution starts, the assistant message is fixed by +/// `session.step.started`, and text streams as text items on it. +fn v2_assistant_message(fake: &FakeOpencode, session: &str, message: &str) { + fake.emit(json!({ + "type": "session.execution.started", + "data": { "sessionID": session }, + })); + fake.emit(json!({ + "type": "session.step.started", + "data": { "sessionID": session, "assistantMessageID": message }, + })); + fake.emit(json!({ + "type": "session.text.started", + "data": { "sessionID": session, "assistantMessageID": message, "ordinal": 0, "text": "" }, + })); +} + +fn v2_text(fake: &FakeOpencode, session: &str, message: &str, text: &str) { + fake.emit(json!({ + "type": "session.text.delta", + "data": { + "sessionID": session, "assistantMessageID": message, "ordinal": 0, + "delta": text, + }, + })); + fake.emit(json!({ + "type": "session.text.ended", + "data": { + "sessionID": session, "assistantMessageID": message, "ordinal": 0, + "text": text, + }, + })); +} + +fn v2_idle(fake: &FakeOpencode, session: &str) { + fake.emit(json!({ + "type": "session.execution.succeeded", + "data": { "sessionID": session }, + })); +} + async fn next_event( stream: &mut (impl futures::Stream<Item = Result<AgentEvent, HarnessError>> + Unpin), ) -> AgentEvent { @@ -1354,3 +1570,475 @@ async fn dollar_selected_skill_uses_opencode_native_command_with_arguments() { idle(&fake, "ses_test"); drain_to_done(&mut stream).await; } + +// --------------------------------------------------------------------------- +// Provider turn refs and native forks (t3-orchestration-v2 ticket 20) +// --------------------------------------------------------------------------- + +/// The budgeted transcript the engine attaches as a fork's fallback — +/// what a refused fork must paste in front of the prompt. +const BUDGETED_HISTORY: &str = "Continue this side chat using the following prior transcript as context.\n\ +<transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n"; + +#[tokio::test] +async fn prompts_carry_a_client_message_id_and_each_turn_gets_its_ref() { + let fake = FakeOpencode::start().await; + let (controls, steer, _token) = controls(); + let mut stream = harness(&fake) + .run(request("hi"), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_test" + )); + let _commands = next_event(&mut stream).await; + + // The prompt travels under a client-chosen message id (the turn's + // provider ref — the anchor a fork cuts the session at). + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 1).await; + let first_id = prompts[0]["messageID"].as_str().unwrap().to_owned(); + assert!(first_id.starts_with("msg_"), "{first_id}"); + + // The ref rides the stream BEFORE the turn's content: the engine + // stamps it on the entry the turn is about to stream. + let turn_ref = next_event(&mut stream).await; + assert_eq!( + turn_ref, + AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: "ses_test".into(), + turn_id: first_id.clone(), + } + } + ); + + assistant_message(&fake, "ses_test", "msg_1"); + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_t", "messageID": "msg_1", "sessionID": "ses_test", + "type": "text", "text": "Hello", + }}, + })); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "Hello")); + idle(&fake, "ses_test"); + let first = drain_to_done(&mut stream).await; + assert_eq!( + first + .iter() + .filter(|ev| matches!(ev, AgentEvent::TurnRef { .. })) + .count(), + 0, + "the turn's one ref was delivered before its content: {first:?}" + ); + + // A steer at the turn boundary starts the next turn: a NEW id, a new + // ref — the turns stay separately addressable for a fork boundary. + steer + .send(SteerMessage { + prompt: "redirect".into(), + message_id: None, + attachments: Vec::new(), + config: None, + }) + .await + .expect("steer queued"); + let steered = next_event(&mut stream).await; + assert!(matches!(&steered, AgentEvent::Steered { .. })); + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 2).await; + let second_id = prompts[1]["messageID"].as_str().unwrap().to_owned(); + assert!(second_id.starts_with("msg_"), "{second_id}"); + assert_ne!(second_id, first_id); + let turn_ref = next_event(&mut stream).await; + assert_eq!( + turn_ref, + AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: "ses_test".into(), + turn_id: second_id, + } + } + ); + + assistant_message(&fake, "ses_test", "msg_2"); + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_t2", "messageID": "msg_2", "sessionID": "ses_test", + "type": "text", "text": "Redirected", + }}, + })); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "Redirected")); + idle(&fake, "ses_test"); + let second = drain_to_done(&mut stream).await; + assert_eq!( + second + .iter() + .filter(|ev| matches!(ev, AgentEvent::TurnRef { .. })) + .count(), + 0, + "the steer turn's one ref was delivered before its content: {second:?}" + ); +} + +#[tokio::test] +async fn v2_prompts_carry_a_client_message_id_and_emit_the_turn_ref() { + let fake = FakeOpencode::start_v2().await; + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(request("hi"), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_test2" + )); + let _commands = next_event(&mut stream).await; + + // 2.x spells the client-chosen id `id` (must be `msg_`-prefixed). + let prompts = wait_posts(&fake, "/api/session/ses_test2/prompt", 1).await; + let message_id = prompts[0]["id"].as_str().unwrap().to_owned(); + assert!(message_id.starts_with("msg_"), "{message_id}"); + + let turn_ref = next_event(&mut stream).await; + assert_eq!( + turn_ref, + AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: "ses_test2".into(), + turn_id: message_id, + } + } + ); + + v2_assistant_message(&fake, "ses_test2", "amsg_1"); + v2_text(&fake, "ses_test2", "amsg_1", "Hello"); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "Hello")); + v2_idle(&fake, "ses_test2"); + let events = drain_to_done(&mut stream).await; + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("ses_test2".into()), + }) + ); +} + +#[tokio::test] +async fn a_native_fork_cuts_the_source_session_at_the_next_turn() { + // OpenCode's fork boundary is exclusive: keeping the boundary turn + // means cutting before the NEXT turn's user message. + let fake = FakeOpencode::start().await; + fake.set_messages( + "ses_src", + vec![ + json!({ "info": { "id": "msg_a", "role": "user", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_a_reply", "role": "assistant", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_b", "role": "user", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_b_reply", "role": "assistant", "sessionID": "ses_src" } }), + ], + ); + fake.set_fork("ses_src", "200 OK", json!({ "id": "ses_fork" })); + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(fork_request("hi", "ses_src", "msg_a", None), controls) + .await + .expect("run starts"); + + // The run continues the FORKED session (the fork response's id)… + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_fork" + )); + let _commands = next_event(&mut stream).await; + // …cut at the next turn's user message, not at the boundary itself… + let forks = wait_posts(&fake, "/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({ "messageID": "msg_b" })); + // …and nothing is pasted: the prompt carries only the user text, and + // no fresh session was created behind the fork's back. + let prompts = wait_posts(&fake, "/session/ses_fork/prompt_async", 1).await; + assert_eq!(prompts[0]["parts"][0]["text"], "hi"); + assert_eq!(prompts[0]["parts"].as_array().map(Vec::len), Some(1)); + assert!(fake.posts_to("/session").is_empty(), "no fresh create"); + // The forked turn's ref names the forked session. + let message_id = prompts[0]["messageID"].as_str().unwrap().to_owned(); + let turn_ref = next_event(&mut stream).await; + assert_eq!( + turn_ref, + AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: "ses_fork".into(), + turn_id: message_id, + } + } + ); + + assistant_message(&fake, "ses_fork", "msg_1"); + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_t", "messageID": "msg_1", "sessionID": "ses_fork", + "type": "text", "text": "forked answer", + }}, + })); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "forked answer")); + idle(&fake, "ses_fork"); + let events = drain_to_done(&mut stream).await; + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("ses_fork".into()), + }) + ); +} + +#[tokio::test] +async fn a_fork_at_the_latest_turn_copies_the_whole_session() { + // No next turn: no boundary at all — the whole session is copied. + let fake = FakeOpencode::start().await; + fake.set_messages( + "ses_src", + vec![ + json!({ "info": { "id": "msg_a", "role": "user", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_a_reply", "role": "assistant", "sessionID": "ses_src" } }), + ], + ); + fake.set_fork("ses_src", "200 OK", json!({ "id": "ses_fork" })); + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(fork_request("hi", "ses_src", "msg_a", None), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_fork" + )); + let forks = wait_posts(&fake, "/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({})); + let prompts = wait_posts(&fake, "/session/ses_fork/prompt_async", 1).await; + assert_eq!(prompts[0]["parts"][0]["text"], "hi"); + + assistant_message(&fake, "ses_fork", "msg_1"); + idle(&fake, "ses_fork"); + drain_to_done(&mut stream).await; +} + +#[tokio::test] +async fn a_v2_native_fork_cuts_before_the_next_user_message() { + let fake = FakeOpencode::start_v2().await; + fake.set_messages( + "ses_src", + vec![ + json!({ "id": "msg_a", "type": "user", "time": { "created": 1 } }), + json!({ "id": "msg_a_reply", "type": "assistant", "time": { "created": 2 } }), + json!({ "id": "msg_b", "type": "user", "time": { "created": 3 } }), + json!({ "id": "msg_b_reply", "type": "assistant", "time": { "created": 4 } }), + ], + ); + fake.set_fork("ses_src", "200 OK", json!({ "data": { "id": "ses_fork2" } })); + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(fork_request("hi", "ses_src", "msg_a", None), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_fork2" + )); + let _commands = next_event(&mut stream).await; + // 2.x spells the exclusive boundary `before` and wraps the answer in + // `{data}` — the forked session id comes from there. + let forks = wait_posts(&fake, "/api/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({ "before": "msg_b" })); + // Nothing is pasted: the prompt carries only the user text. + let prompts = wait_posts(&fake, "/api/session/ses_fork2/prompt", 1).await; + assert_eq!(prompts[0]["text"], "hi"); + assert!(fake.posts_to("/api/session").is_empty(), "no fresh create"); + let message_id = prompts[0]["id"].as_str().unwrap().to_owned(); + let turn_ref = next_event(&mut stream).await; + assert_eq!( + turn_ref, + AgentEvent::TurnRef { + reference: ProviderTurnRef { + harness: HarnessId::Opencode, + session_id: "ses_fork2".into(), + turn_id: message_id, + } + } + ); + + v2_assistant_message(&fake, "ses_fork2", "amsg_1"); + v2_text(&fake, "ses_fork2", "amsg_1", "forked answer"); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "forked answer")); + v2_idle(&fake, "ses_fork2"); + let events = drain_to_done(&mut stream).await; + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("ses_fork2".into()), + }) + ); +} + +#[tokio::test] +async fn a_v2_fork_at_the_latest_turn_sends_no_boundary() { + let fake = FakeOpencode::start_v2().await; + fake.set_messages( + "ses_src", + vec![ + json!({ "id": "msg_a", "type": "user", "time": { "created": 1 } }), + json!({ "id": "msg_a_reply", "type": "assistant", "time": { "created": 2 } }), + ], + ); + fake.set_fork("ses_src", "200 OK", json!({ "data": { "id": "ses_fork2" } })); + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(fork_request("hi", "ses_src", "msg_a", None), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_fork2" + )); + let forks = wait_posts(&fake, "/api/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({})); + let prompts = wait_posts(&fake, "/api/session/ses_fork2/prompt", 1).await; + assert_eq!(prompts[0]["text"], "hi"); + + v2_assistant_message(&fake, "ses_fork2", "amsg_1"); + v2_idle(&fake, "ses_fork2"); + drain_to_done(&mut stream).await; +} + +#[tokio::test] +async fn a_refused_fork_falls_back_to_a_fresh_session_with_the_budgeted_history() { + let fake = FakeOpencode::start().await; + fake.set_messages( + "ses_src", + vec![ + json!({ "info": { "id": "msg_a", "role": "user", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_a_reply", "role": "assistant", "sessionID": "ses_src" } }), + json!({ "info": { "id": "msg_b", "role": "user", "sessionID": "ses_src" } }), + ], + ); + // No fork outcome configured: the POST 404s — the server refused. + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run( + fork_request("hi", "ses_src", "msg_a", Some(BUDGETED_HISTORY)), + controls, + ) + .await + .expect("run starts"); + + // The boundary resolved (the refused call still addressed the next + // turn)… + let forks = wait_posts(&fake, "/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({ "messageID": "msg_b" })); + // …but the turn then starts a FRESH session… + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_test" + )); + let _commands = next_event(&mut stream).await; + // The fresh session's turn still emits its ref (the entry the engine + // stamps is addressable for a LATER fork of this chat). + let _turn_ref = next_event(&mut stream).await; + assert!(matches!(&_turn_ref, AgentEvent::TurnRef { .. })); + assert_eq!(fake.posts_to("/session").len(), 1, "one fresh create"); + // …whose prompt carries the budgeted history in front of the user + // message, byte-identical to the non-native paste. + let prompts = wait_posts(&fake, "/session/ses_test/prompt_async", 1).await; + assert_eq!( + prompts[0]["parts"][0]["text"], + format!("{BUDGETED_HISTORY}hi") + ); + + assistant_message(&fake, "ses_test", "msg_1"); + fake.emit(json!({ + "type": "message.part.updated", + "properties": { "part": { + "id": "prt_t", "messageID": "msg_1", "sessionID": "ses_test", + "type": "text", "text": "fresh answer", + }}, + })); + let text = next_event(&mut stream).await; + assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "fresh answer")); + idle(&fake, "ses_test"); + let events = drain_to_done(&mut stream).await; + assert!(events.iter().all(|ev| !matches!(ev, AgentEvent::Error { .. }))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("ses_test".into()), + }) + ); +} + +#[tokio::test] +async fn a_fork_boundary_the_server_cannot_resolve_falls_back_to_the_paste() { + // The message list itself fails (the session is gone or unreadable): + // "a call fails" — same budgeted-paste fallback, and no fork call is + // ever made. + let fake = FakeOpencode::start_v2().await; + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run( + fork_request("hi", "ses_src", "msg_a", Some(BUDGETED_HISTORY)), + controls, + ) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_test2" + )); + assert_eq!(fake.posts_to("/api/session").len(), 1, "one fresh create"); + assert!( + fake.posts_to("/api/session/ses_src/fork").is_empty(), + "the fork is never called with an unresolvable boundary" + ); + let prompts = wait_posts(&fake, "/api/session/ses_test2/prompt", 1).await; + assert_eq!(prompts[0]["text"], format!("{BUDGETED_HISTORY}hi")); + + v2_assistant_message(&fake, "ses_test2", "amsg_1"); + v2_idle(&fake, "ses_test2"); + let events = drain_to_done(&mut stream).await; + assert!(events.iter().all(|ev| !matches!(ev, AgentEvent::Error { .. }))); +} From 9a0e359b4fed77c629a1eb93ecc74ec88889863e Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:20:00 +0700 Subject: [PATCH 58/96] feat(harness): forks of Pi chats continue the real Pi session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t3-orchestration-v2 ticket 19, spec stories 57 and 60: forking a Pi chat now continues the source's real pi session instead of pasting the copied transcript into a fresh one, with the budgeted transcript from ticket 01 as the fallback. Mirrors the Codex native-fork shape from ticket 17 (RunRequest.provider_fork + AgentEvent::TurnRef) — no wire changes: every type the Pi driver needs is already on the wire. - Turn refs: after each settle (with the settle probe quiescent, before the Done), the driver reads the session tree with get_entries (the previous capture's leaf as `since`, a full listing on a re-rooted branch) and emits AgentEvent::TurnRef naming the session file plus the LAST user entry the turn consumed — its steer when one landed mid-run, else its prompt, which for an unsteered turn is also the turn's first user entry. The last-consumed entry is what makes the exclusive fork boundary exact: pi's fork re-roots BEFORE the named user message, so "the user entry after the ref" is the next turn's start whether or not the boundary turn was steered. Best-effort: a failed read costs the ref (the fork falls back to the budgeted paste), never the turn. Unit tests cover the parse/walk/boundary helpers including cycle guards. - Fork launch: run_with_mode performs the native fork in a short-lived `pi --mode rpc --fork <source session file>` process in the run's cwd (no prompt, no MCP bridge). get_state reports the new session file (must differ from the source, or the main run would continue the source session itself); get_entries walks the copy's active branch; when the boundary is not the source's latest turn, the RPC `fork` re-roots the copy before the NEXT turn's user entry; get_state re-reads the file. The main spawn then resumes it with `--session`, the history rides the provider and nothing is pasted. Stop during the fork phase ends the run interrupted. - Fallbacks: a missing source file, a boundary the branch no longer holds, a cancelled or refused fork RPC, or a pi that answers with anything but a distinct new file, all fall back to a fresh session with the engine's budgeted transcript in front of the prompt (codex parity) plus a visible notice; the send never fails. The fork owns session continuity, so a plain resume never competes with it, and run_title clears the directive so the title sandbox never forks. - Fixture: the fake pi keeps a session entry tree (append-order ids and parentIds behind get_entries, `since` cursors), reports the resumed file from get_state, gains a fork-mode process (the copied tree, the exclusive fork RPC with markers, scripted cancel/refuse sources) and fork-turn / fork-fallback prompt scenarios that assert the resumed forked file, the bare prompt, and the pasted budget respectively. The settle-probe answering loops (reject-while-streaming and the dispatch loop) gained the get_entries round the driver now makes between the settle stats and the Done. Tests: harness pi.rs gains the turn-ref, steered-turn-ref, latest-turn fork, earlier-turn fork, stale/cancelled/missing-source fallback tests and a title-run smuggle of the fork directive; the engine pi_forks.rs (new, wired into ui-tests.yml) covers the fork RPC driving the real Pi driver end to end and the stale-boundary budgeted fallback. Not run locally (user override); CI on the PR is the signal. --- .github/workflows/ui-tests.yml | 2 +- crates/engine/tests/pi_forks.rs | 348 ++++++++++++ crates/harness/src/pi/mod.rs | 558 ++++++++++++++++++- crates/harness/tests/fixtures/fake-pi-rpc.sh | 141 ++++- crates/harness/tests/pi.rs | 312 +++++++++++ 5 files changed, 1350 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/pi_forks.rs diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 3a83ecddba..17f66573da 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -61,7 +61,7 @@ jobs: - name: Pi native driver integration tests run: | cargo test --locked -p roboco-harness --test pi - cargo test --locked -p roboco-engine --test pi_resume --test pi_steering + cargo test --locked -p roboco-engine --test pi_resume --test pi_steering --test pi_forks - name: Engine-local storage and recovery run: | cargo test --locked -p roboco-sync --lib diff --git a/crates/engine/tests/pi_forks.rs b/crates/engine/tests/pi_forks.rs new file mode 100644 index 0000000000..4de585c148 --- /dev/null +++ b/crates/engine/tests/pi_forks.rs @@ -0,0 +1,348 @@ +//! Pi native forks through the REAL engine surfaces (t3-orchestration-v2 +//! ticket 19): forking a Pi chat continues the source's real session. The +//! source turn's driver stamps a provider turn ref on the assistant entry +//! (the session file plus the turn's user entry id, `get_entries` after the +//! settle), the ForkSideChat RPC records the native-fork intent from it, +//! and the fork's first run performs the `pi --fork` launch plus the +//! exclusive fork RPC through the fixture's fork process — nothing is +//! pasted. A boundary the session tree no longer holds falls back to the +//! budgeted paste. Prior art: `pi_resume.rs` / `pi_steering.rs` drive the +//! real `PiHarness` against the fixture RPC script; `side_chats.rs` covers +//! the harness-agnostic fork RPC plumbing with the mock harness. +#![cfg(unix)] + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use roboco_doc::{MessagePart, MessageRole, SessionMessageEntry}; +use roboco_engine::{EngineCore, HarnessRegistry}; +use roboco_harness::PiHarness; +use roboco_proto::{HarnessId, SessionStatus}; + +const SRC: &str = "pi-fork-src"; +const SIDE: &str = "pi-fork-side"; + +fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../harness/tests/fixtures/fake-pi-rpc.sh") +} + +/// Assemble a full engine around the REAL Pi harness pointed at the fixture +/// RPC process (short interrupt graces so a stuck scenario fails fast +/// instead of hanging the suite). +fn assemble(dir: &std::path::Path) -> EngineCore { + let registry = HarnessRegistry::new(); + registry.register(Arc::new( + PiHarness::new() + .with_executable(fixture_path()) + .with_graces(Duration::from_millis(150), Duration::from_millis(300)), + )); + EngineCore::assemble(dir, Arc::new(registry), HarnessId::Pi).expect("engine core assembles") +} + +/// A pi chat in a real temp cwd (the fixture writes its session file and +/// runs there), pre-titled so the auto-titler never spawns a fixture run of +/// its own. +async fn create_chat(core: &EngineCore, chat: &str, cwd: &str) { + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ + "op": "createChat", + "chatId": chat, + "deviceId": core.device_id, + "cwd": cwd, + "config": { + "harness": "pi", + "model": null, + "reasoning": null, + "sandbox": "workspace-write", + }, + }), + ) + .await + .expect("createChat"); + core.workspace + .rename_chat(chat, "Pre-titled") + .expect("rename chat"); +} + +fn texts_of(core: &EngineCore, chat: &str, role: MessageRole) -> Vec<String> { + core.doc_host + .open(chat) + .expect("chat doc") + .doc() + .read_entries() + .unwrap_or_default() + .into_iter() + .filter(|entry| entry.role == role) + .map(|entry| { + entry + .parts + .iter() + .filter_map(|part| match part { + MessagePart::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::<Vec<_>>() + .join("") + }) + .collect() +} + +fn error_texts_of(core: &EngineCore, chat: &str) -> Vec<String> { + core.doc_host + .open(chat) + .expect("chat doc") + .doc() + .read_entries() + .unwrap_or_default() + .into_iter() + .flat_map(|entry| entry.parts) + .filter_map(|part| match part { + MessagePart::Error { message, .. } => Some(message), + _ => None, + }) + .collect() +} + +async fn wait_for<F>(mut predicate: F, what: &str) +where + F: FnMut() -> bool, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while !predicate() { + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {what}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_idle(core: &EngineCore, chat: &str) { + wait_for( + || { + core.sessions + .session_status(chat) + .map(|s| s.status) + .is_some_and(|status| status == SessionStatus::Idle) + }, + "the turn to settle", + ) + .await; +} + +async fn fork_side_chat(core: &EngineCore, side: &str, source: &str) { + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + roboco_rpc::methods::FORK_SIDE_CHAT, + serde_json::json!({ "chatId": side, "sourceChatId": source }), + ) + .await + .expect("the fork RPC succeeded"); +} + +/// A hand-built message entry (the fallback test writes a source doc the +/// driver never ran). +fn message(id: &str, role: MessageRole, text: &str) -> SessionMessageEntry { + SessionMessageEntry { + origin_chat_id: None, + duration_ms: None, + provider_turn: None, + id: id.into(), + role, + parts: vec![MessagePart::Text { + id: format!("{id}-text"), + text: text.into(), + }], + created_at: 1, + device_id: "device".into(), + status: Some(roboco_doc::MessageStatus::Complete), + continuation_of: None, + } +} + +/// Forking a Pi chat continues the source's REAL session: the source +/// turn's ref drives the fork, the fork process launches with `--fork` and +/// sends the exclusive fork RPC at the turn after the boundary (the +/// fixture asserts both through markers, plus that the main run resumes +/// the FORKED session file with a BARE prompt — a pasted history would +/// fail its scenario), and the forked chat's own turns then run normally +/// on the forked session. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pi_fork_continues_the_source_session_and_pastes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("chat"); + std::fs::create_dir_all(&cwd).unwrap(); + let core = assemble(&dir.path().join("data")); + create_chat(&core, SRC, &cwd.display().to_string()).await; + + // The source turn: the driver stamps the boundary ref on the reply. + core.doc_host + .queue_message(SRC, "hello", Vec::new()) + .expect("queue the source turn"); + wait_for( + || { + texts_of(&core, SRC, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:hello")) + }, + "the source reply to stream", + ) + .await; + wait_idle(&core, SRC).await; + let reference = core + .doc_host + .open(SRC) + .expect("chat doc") + .doc() + .read_entries() + .expect("entries") + .into_iter() + .find(|entry| entry.role == MessageRole::Assistant) + .and_then(|entry| entry.provider_turn) + .expect("the driver stamped a turn ref on the reply"); + assert_eq!(reference.harness, HarnessId::Pi); + assert!(std::path::Path::new(&reference.session_id).is_file(), "{reference:?}"); + assert_eq!(reference.turn_id, "u1"); + + // Fork it, then run the fork's first turn. + fork_side_chat(&core, SIDE, SRC).await; + core.doc_host + .queue_message(SIDE, "fork-turn", Vec::new()) + .expect("queue the fork turn"); + wait_for( + || { + texts_of(&core, SIDE, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:fork-turn")) + }, + "the forked reply to stream", + ) + .await; + wait_idle(&core, SIDE).await; + + // The fork process ran with --fork and sent the RPC fork at the turn + // AFTER the boundary (the source's first turn): the markers prove both. + let forked_path = cwd.join(".pi-fixture-forked.jsonl"); + let forked = std::fs::read_to_string(&forked_path).expect("the forked session file"); + assert!(forked.contains("fork-launch:"), "{forked}"); + assert!(forked.contains("fork-rpc:u2"), "{forked}"); + + // The copied boundary keeps its ref, and the fork's own reply carries a + // ref naming the FORKED session (its own process mints fresh entry ids). + let side_entries = core + .doc_host + .open(SIDE) + .expect("chat doc") + .doc() + .read_entries() + .expect("entries"); + assert!( + side_entries + .iter() + .any(|entry| entry.provider_turn.as_ref() == Some(&reference)), + "the copied boundary keeps its ref: {side_entries:?}" + ); + let own = side_entries + .into_iter() + .find(|entry| { + entry.role == MessageRole::Assistant + && entry + .provider_turn + .as_ref() + .is_some_and(|r| r.session_id.ends_with(".pi-fixture-forked.jsonl")) + }) + .expect("the forked turn stamped its own ref") + .provider_turn + .expect("ref"); + assert_eq!(own.turn_id, "u1"); + + // The intent is spent: the next turn is a normal turn on the forked + // session — no second fork launch (the marker count proves it). + core.doc_host + .queue_message(SIDE, "fork-turn", Vec::new()) + .expect("queue the second fork turn"); + wait_for( + || { + texts_of(&core, SIDE, MessageRole::Assistant) + .iter() + .filter(|t| t.as_str() == "reply:fork-turn") + .count() + == 2 + }, + "the second forked turn to stream", + ) + .await; + wait_idle(&core, SIDE).await; + let forked = std::fs::read_to_string(&forked_path).expect("the forked session file"); + assert_eq!(forked.matches("fork-launch:").count(), 1, "{forked}"); + assert_eq!(forked.matches("fork-rpc:").count(), 1, "{forked}"); + core.shutdown().await; +} + +/// A fork whose boundary ref names no entry the source session holds (a +/// stale boundary, an old chat or a re-rooted branch) never re-roots: the +/// fork falls back to a fresh session with the budgeted transcript in +/// front of the prompt (the fixture asserts the pasted history and refuses +/// a resume), the refusal surfaces as an error chip, and the turn still +/// completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pi_fork_without_a_usable_boundary_falls_back_to_the_budgeted_paste() { + let dir = tempfile::tempdir().unwrap(); + let cwd = dir.path().join("chat"); + std::fs::create_dir_all(&cwd).unwrap(); + let core = assemble(&dir.path().join("data")); + create_chat(&core, SRC, &cwd.display().to_string()).await; + + // A source doc the driver never ran: the boundary entry carries a ref + // whose turn id the fixture session tree does not hold. + let stale = cwd.join("stale-session.jsonl"); + std::fs::write(&stale, b"fixture session\n").unwrap(); + let source = core.doc_host.open(SRC).expect("chat doc"); + source + .doc() + .push_message(&message("u1", MessageRole::User, "Remember PINEAPPLE")) + .expect("user entry"); + let mut boundary = message("a1", MessageRole::Assistant, "I remember PINEAPPLE"); + boundary.provider_turn = Some(roboco_proto::ProviderTurnRef { + harness: HarnessId::Pi, + session_id: stale.display().to_string(), + turn_id: "u-gone".into(), + }); + source.doc().push_message(&boundary).expect("boundary"); + + fork_side_chat(&core, SIDE, SRC).await; + core.doc_host + .queue_message(SIDE, "fork-fallback", Vec::new()) + .expect("queue the fork turn"); + wait_for( + || { + texts_of(&core, SIDE, MessageRole::Assistant) + .iter() + .any(|t| t.contains("reply:fork-fallback")) + }, + "the fallback reply to stream", + ) + .await; + wait_idle(&core, SIDE).await; + + // The fork process DID run (the launch marker) but never re-rooted (no + // fork RPC marker): the stale boundary refused the fork. + let forked_path = cwd.join(".pi-fixture-forked.jsonl"); + let forked = std::fs::read_to_string(&forked_path).expect("the forked session file"); + assert!(forked.contains("fork-launch:"), "{forked}"); + assert!(!forked.contains("fork-rpc:"), "{forked}"); + + // The refusal surfaced as an error chip on the forked chat. + assert!( + error_texts_of(&core, SIDE) + .iter() + .any(|message| message.contains("pi could not fork")), + "the fork refusal surfaced: {:?}", + error_texts_of(&core, SIDE) + ); + core.shutdown().await; +} diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index e069492712..18be04d2f4 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -64,6 +64,24 @@ //! after each settle; `message_update.usage` rides per-turn `Usage` //! passthrough — first-party replacements for the pi-acp fork's //! usage_update/turn-error patches. +//! - **Native forks** (orchestration v2 ticket 19): the forked chat's +//! first run continues the SOURCE's real session instead of pasting +//! the copied transcript. A short-lived `pi --mode rpc --fork <source +//! file>` process in the run's cwd performs the fork — the CLI copies +//! the source session into a new file for the current project, and, +//! when the boundary is not the source's latest turn, the RPC `fork` +//! re-roots that copy BEFORE the next turn's user entry (pi's fork is +//! exclusive) — then the main spawn resumes the new file with +//! `--session` and the copied history rides the provider: nothing is +//! pasted. Transcript entries record a provider turn ref (the session +//! file plus the turn's user entry id, `get_entries` after each +//! settle): for an unsteered turn that is its FIRST user entry; a turn +//! a steer landed in names its LAST user entry instead, because the +//! fork boundary is "the user entry after the ref" and naming the last +//! consumed entry makes that the next turn's start exactly. A missing +//! ref, a stale boundary, or a refused/cancelled fork falls back to +//! the budgeted transcript in front of the prompt — never a failed +//! send. //! - **Extension UI**: blocking dialogs (`select`/`confirm`/`input`/`editor`) //! map to roboco's question flow and are answered with //! `extension_ui_response`; fire-and-forget methods are tolerated. @@ -90,7 +108,7 @@ mod mcp; mod normalize; mod wire; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -104,8 +122,8 @@ use tokio::io::AsyncBufReadExt; use tokio::sync::{mpsc, oneshot}; use roboco_proto::{ - AgentEvent, DoneStatus, HarnessId, Model, ReasoningLevel, RunRequest, SteeringMode, - UserInputAnswer, UserInputQuestion, + AgentEvent, DoneStatus, HarnessId, Model, ProviderFork, ProviderTurnRef, ReasoningLevel, + RunRequest, SteeringMode, UserInputAnswer, UserInputQuestion, }; use crate::acp::child; @@ -479,7 +497,7 @@ impl PiHarness { /// command construction below). async fn run_with_mode( &self, - request: RunRequest, + mut request: RunRequest, controls: RunControls, title_only: bool, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { @@ -490,11 +508,79 @@ impl PiHarness { // directory?" prompt — on stdin, the protocol channel. A pointer // that is not an existing file (a legacy ACP-era id, a moved // session) degrades to a fresh session with a visible notice. - let resume_path = request + let mut resume_path = request .resume .as_deref() .filter(|resume| Path::new(resume).is_file()) .map(str::to_owned); + // A native fork (t3-orchestration-v2 ticket 19): the forked chat's + // first run continues the SOURCE's real pi session instead of + // pasting the copied transcript into a fresh one. A short-lived + // `pi --mode rpc --fork <source file>` process performs the fork + // (see [`fork_source_session`]) and the main spawn below resumes + // the forked session file with `--session`, so the history rides + // the provider and nothing is pasted. Any failure — a missing + // source file, a stale boundary ref, a refused or cancelled fork, + // a pi that answers with anything but a distinct new file — falls + // back to a fresh session with the engine's budgeted transcript + // in front of the prompt: the send never fails. The fork owns + // session continuity, so a plain resume never competes with it + // (codex parity: `thread/fork` instead of `thread/resume`). + let mut fork_notice: Option<String> = None; + if !title_only + && let Some(fork) = request.provider_fork.clone() + { + request.resume = None; + resume_path = None; + let launch = if !Path::new(&fork.session_id).is_file() { + ForkLaunch::Failed("the source session file is gone".into()) + } else { + tokio::select! { + biased; + // Stop before the fork could start ends the run + // interrupted, exactly like a Stop during startup — a + // fork is not a fallback candidate after that. + _ = controls.interrupt.cancelled() => ForkLaunch::Interrupted, + launched = fork_source_session( + &exe, + &fork, + &request.cwd, + self.startup_timeout, + self.kill_grace, + ) => match launched { + Ok(file) => ForkLaunch::Session(file), + Err(reason) => ForkLaunch::Failed(reason), + }, + } + }; + match launch { + ForkLaunch::Session(file) => resume_path = Some(file), + ForkLaunch::Failed(reason) => { + // The budgeted transcript rides the prompt in front of + // the user message (codex parity: the refused-fork + // fallback), and a notice says what happened. + let history = fork.fallback_history.as_deref().unwrap_or(""); + request.prompt = format!("{history}{}", request.prompt); + fork_notice = Some(format!( + "pi could not fork the source session ({reason}); starting a new \ + session with a summary of the previous conversation" + )); + } + ForkLaunch::Interrupted => { + let (event_tx, event_rx) = + mpsc::channel::<Result<AgentEvent, HarnessError>>(256); + let _ = event_tx + .send(Ok(AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: None, + })) + .await; + return Ok(event_stream(event_rx)); + } + } + } let mut cmd = Command::new(&exe); cmd.arg("--mode").arg("rpc"); @@ -600,15 +686,24 @@ impl PiHarness { kill_grace: self.kill_grace, startup_timeout: self.startup_timeout, stderr_tail, + fork_notice, })); - Ok(futures::stream::unfold(event_rx, |mut rx| async move { - rx.recv().await.map(|ev| (ev, rx)) - }) - .boxed()) + Ok(event_stream(event_rx)) } } +/// The consumer's event stream: one channel receiver unfolded into a +/// boxed stream (the run task owns the sender side). +fn event_stream( + event_rx: mpsc::Receiver<Result<AgentEvent, HarnessError>>, +) -> BoxStream<'static, Result<AgentEvent, HarnessError>> { + futures::stream::unfold(event_rx, |mut rx| async move { + rx.recv().await.map(|ev| (ev, rx)) + }) + .boxed() +} + #[async_trait] impl Harness for PiHarness { fn id(&self) -> HarnessId { @@ -708,6 +803,10 @@ impl Harness for PiHarness { request.mcp = None; request.model_options.clear(); request.auto_approve = false; + // A native-fork directive must never reach the title sandbox: the + // one-shot never continues a session, so a fork would only spawn a + // doomed subprocess (its history belongs to a chat, not a title). + request.provider_fork = None; self.run_with_mode(request, controls, true).await } } @@ -736,6 +835,11 @@ struct Session { startup_timeout: Duration, /// Rolling stderr tail for the crash message on an unexpected exit. stderr_tail: crate::StderrTail, + /// A refused native fork's notice (ticket 19): the run starts fresh + /// with the budgeted transcript pasted in front of the prompt, and + /// this surfaces what happened to the fold (parity with the + /// lost-resume notice). + fork_notice: Option<String>, } fn new_message_id() -> String { @@ -945,6 +1049,290 @@ fn rearm_probe(client: &PiClient, delay: bool) -> BoxFuture<'static, ProbeRound> }) } +/// One `get_entries` listing parsed for the session-tree walks (ticket +/// 19): the entry map (id → parent + whether the entry is a user message) +/// and the current leaf id. Entries without an id are skipped — the walks +/// simply cannot pass through them; a listing without the entries array +/// fails outright. Only `type: "message"` entries carry a role; every +/// other entry type (compaction, model changes, extension entries) walks +/// as a pass-through node. +struct SessionEntries { + by_id: HashMap<String, (Option<String>, bool)>, + leaf: Option<String>, +} + +fn parse_entries(data: &Value) -> Option<SessionEntries> { + let entries = data.get("entries")?.as_array()?; + let mut by_id = HashMap::with_capacity(entries.len()); + for entry in entries { + let Some(id) = entry.get("id").and_then(Value::as_str) else { + continue; + }; + let parent = entry + .get("parentId") + .and_then(Value::as_str) + .map(str::to_owned); + let is_user = entry.pointer("/message/role").and_then(Value::as_str) == Some("user"); + by_id.insert(id.to_owned(), (parent, is_user)); + } + let leaf = data.get("leafId").and_then(Value::as_str).map(str::to_owned); + Some(SessionEntries { by_id, leaf }) +} + +/// The newest user entry on the active branch: walking the branch from +/// the leaf, the FIRST user entry encountered is the LAST user entry the +/// settling turn consumed — the turn's steer when one landed mid-run, +/// else its prompt (for an unsteered turn that is also the turn's FIRST +/// user entry, the ticket's spelling). That is the turn's provider turn +/// ref, because pi's exclusive fork boundary is "the user entry after the +/// ref" and naming the last consumed entry makes that the next turn's +/// start exactly — see [`fork_boundary`]. A walk that leaves the listing +/// (a since-bounded stretch with no user entry of its own — an +/// extension-initiated run) or reaches the root names nothing. +fn newest_user_entry(entries: &SessionEntries) -> Option<String> { + let mut cursor = entries.leaf.clone()?; + // A corrupt parent cycle cannot loop the walk: at most one pass per + // entry, then the walk gives up. + let mut steps = entries.by_id.len().saturating_add(1); + loop { + let Some((parent, is_user)) = entries.by_id.get(&cursor).cloned() else { + return None; + }; + if is_user { + return Some(cursor); + } + let Some(parent) = parent else { + return None; // the root: no user entry on this stretch. + }; + if steps == 0 { + return None; + } + steps -= 1; + cursor = parent; + } +} + +/// The user entry ids on the active branch, NEWEST first. The fork +/// boundary search walks the whole branch: the fork subprocess reads a +/// full `get_entries` listing, so the walk always reaches the root (and +/// the same cycle guard bounds a corrupt tree). +fn branch_user_entries(entries: &SessionEntries) -> Vec<String> { + let mut users = Vec::new(); + let mut cursor = entries.leaf.clone(); + let mut steps = entries.by_id.len().saturating_add(1); + while let Some(current) = cursor { + let Some((parent, is_user)) = entries.by_id.get(¤t).cloned() else { + break; + }; + if is_user { + users.push(current); + } + if steps == 0 { + break; + } + steps -= 1; + cursor = parent; + } + users +} + +/// The exclusive fork boundary for a native fork at `last_turn` (the +/// provider turn ref recorded on the boundary entry): the user entry id +/// of the turn AFTER the boundary. pi's RPC `fork` re-roots the session +/// BEFORE the named user message, so naming the next turn's user entry +/// keeps everything through the boundary turn — exactly the prefix the +/// fork copied. `Ok(None)` when the boundary IS the branch's newest turn +/// (the `--fork` copy already ends there: no RPC, fork the whole +/// session); `Err` when the ref names no entry on the active branch (a +/// stale ref, or one from a re-rooted/compacted-away branch) — the fork +/// then falls back to the budgeted paste rather than silently keeping +/// turns the boundary excluded. +fn fork_boundary(users: &[String], last_turn: &str) -> Result<Option<String>, ()> { + let Some(index) = users.iter().position(|id| id == last_turn) else { + return Err(()); + }; + // `users` is newest-first: index 0 is the branch's newest user entry, + // and one step towards the head is one turn newer. + Ok(index + .checked_sub(1) + .and_then(|newer| users.get(newer).cloned())) +} + +/// One bounded `get_entries` round: a request pi never answers counts as a +/// failed capture (the ref is skipped, never the turn). +async fn read_entries(client: &PiClient, params: Value) -> Result<Value, String> { + match tokio::time::timeout(SETTLE_TIMEOUT, client.request("get_entries", params)).await { + Ok(Ok(data)) => Ok(data), + Ok(Err(e)) => Err(e.to_string()), + Err(_) => Err("get_entries did not answer within the settle budget".into()), + } +} + +/// One settle's provider turn ref capture (ticket 19): read the session +/// tree and name the LAST user entry the turn consumed — the session file +/// plus that entry id (see [`newest_user_entry`] for why the last, not +/// the first). The previous capture's leaf rides as `since`, bounding the +/// listing to the turn's entries and the walk to the turn's stretch; a +/// cursor pi no longer knows (a re-rooted branch) or a slow read falls +/// back to one full listing — the walk's answer is the same either way, +/// so a failed bounded read costs only the round trip. Best-effort: any +/// failed read costs the ref, never the turn. Advances the cursor on +/// every successful listing, so the next settle bounds itself to the next +/// turn. +async fn capture_turn_ref( + client: &PiClient, + session_pointer: &str, + cursor: &mut Option<String>, +) -> Option<ProviderTurnRef> { + let params = match cursor.as_deref() { + Some(since) => json!({ "since": since }), + None => json!({}), + }; + let data = match read_entries(client, params).await { + Ok(data) => data, + Err(_) => read_entries(client, json!({})).await.ok()?, + }; + let listing = parse_entries(&data)?; + let reference = + newest_user_entry(&listing).map(|turn_id| ProviderTurnRef { + harness: HarnessId::Pi, + session_id: session_pointer.to_owned(), + turn_id, + }); + *cursor = listing.leaf.clone(); + reference +} + +/// The outcome of the fork-launch phase in [`PiHarness::run_with_mode`]: +/// the forked session file the main spawn resumes, the reason a fork +/// could not be performed (the budgeted-paste fallback), or a Stop that +/// landed before the fork could start. +enum ForkLaunch { + Session(String), + Failed(String), + Interrupted, +} + +/// Perform one native fork of a source pi session (ticket 19) in a +/// short-lived `pi --mode rpc --fork <source file>` process running in the +/// run's cwd: `--fork` files the new session under the CURRENT project — +/// the same place the main spawn below resumes from (an RPC +/// clone/switch_session alone would keep the source's cwd), and the +/// process never runs a prompt, so it needs no MCP bridge and no model +/// selection. The sequence is pi's own fork surface: the CLI copies the +/// source session into a new file; `get_entries` walks the copy's active +/// branch; when the boundary is not the source's latest turn, the RPC +/// `fork` re-roots the copy BEFORE the next turn's user entry (pi's fork +/// is exclusive); `get_state` reports the new session file. Returns that +/// file — never the source itself (a pi answering with the source would +/// have the main run CONTINUE the source session, mutating it) — or the +/// reason the fork failed (the caller falls back to the budgeted paste). +async fn fork_source_session( + exe: &Path, + fork: &ProviderFork, + cwd: &str, + startup_timeout: Duration, + kill_grace: Duration, +) -> Result<String, String> { + let source = Path::new(&fork.session_id); + let mut cmd = Command::new(exe); + cmd.arg("--mode").arg("rpc").arg("--fork").arg(source); + if !cwd.is_empty() { + cmd.current_dir(cwd); + } + child::configure(&mut cmd); + crate::compose_child_path(&mut cmd, exe); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let child = cmd + .spawn() + .map_err(|e| format!("could not spawn the pi fork process: {e}"))?; + let mut child = child::Child::new(child); + let stdin = child + .stdin + .take() + .ok_or_else(|| "the pi fork process has no stdin".to_owned())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "the pi fork process has no stdout".to_owned())?; + let (client, mut incoming) = PiClient::new(stdin, stdout); + // Events (notify noise) are drained so a chatty extension can never + // stall the reader. + tokio::spawn(async move { + while incoming.recv().await.is_some() {} + }); + let work = async { + // The forked copy's session file: must exist and be a DIFFERENT + // file from the source, or the main spawn would continue the + // source session itself. + let state = client + .request("get_state", json!({})) + .await + .map_err(|e| e.to_string())?; + let mut file = state + .pointer("/sessionFile") + .and_then(Value::as_str) + .filter(|file| !file.is_empty()) + .map(str::to_owned) + .ok_or_else(|| "pi reported no session file for the fork".to_owned())?; + if Path::new(&file) == source { + return Err("pi forked onto the source session file".to_owned()); + } + // The boundary: the copy's active branch tells whether the fork is + // already at the right place (the source's latest turn) or needs + // the exclusive re-root at the turn after the boundary. + let data = client + .request("get_entries", json!({})) + .await + .map_err(|e| e.to_string())?; + let listing = parse_entries(&data) + .ok_or_else(|| "pi returned an unreadable session tree".to_owned())?; + let users = branch_user_entries(&listing); + let boundary = fork_boundary(&users, &fork.last_turn).map_err(|_| { + format!( + "the boundary entry {} is no longer on the session branch", + fork.last_turn + ) + })?; + if let Some(entry_id) = boundary { + let data = client + .request("fork", json!({ "entryId": entry_id })) + .await + .map_err(|e| e.to_string())?; + if data + .get("cancelled") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Err("a pi extension cancelled the session fork".to_owned()); + } + // The re-rooted session file, re-read from `get_state` (the + // fork happens inside the copy, so this is the file the main + // spawn resumes): a failed re-read keeps the copy's file — the + // fork itself already succeeded. + if let Ok(state) = client.request("get_state", json!({})).await + && let Some(after) = state + .pointer("/sessionFile") + .and_then(Value::as_str) + .filter(|file| !file.is_empty()) + && Path::new(after) != source + { + file = after.to_owned(); + } + } + Ok(file) + }; + let result = tokio::time::timeout(startup_timeout, work) + .await + .map_err(|_| "the pi fork process did not answer in time".to_owned()) + .and_then(|result| result); + child.shutdown(kill_grace).await; + result +} + /// The per-run event loop: one task multiplexing pi events, the prompt /// acceptance, the settle sequence, the steering mailbox, the interrupt /// token, and consumer liveness. @@ -965,6 +1353,7 @@ async fn run_session(session: Session) { kill_grace, startup_timeout, stderr_tail, + fork_notice, } = session; let RunControls { execution_lease: _execution_lease, @@ -1113,6 +1502,20 @@ async fn run_session(session: Session) { ) .await; } + if let Some(notice) = &fork_notice { + // A refused native fork (ticket 19): the fresh session carries the + // budgeted transcript in front of the prompt (pasted in + // run_with_mode), so the notice says the context was summarized, + // not lost — parity with the lost-resume notice above. + let _ = send( + &event_tx, + AgentEvent::Error { + message: notice.clone(), + kind: None, + }, + ) + .await; + } // ---- main loop -------------------------------------------------------- // Agent activity since the last settle (extension-initiated runs deliver @@ -1141,6 +1544,14 @@ async fn run_session(session: Session) { // the counter; a probe result armed at a superseded generation is // stale (a prompt acknowledgement raced it) and re-arms. let mut probe_generation: u64 = 0; + // The session-tree cursor for provider turn refs (ticket 19): the leaf + // entry id of the last settle capture. Each settle's `get_entries` runs + // with it as `since`, bounding the listing — and the ref walk — to the + // entries the settling turn appended; a cursor pi no longer knows (a + // re-rooted branch) falls back to one full listing. `None` until the + // first capture: a fresh session's first walk simply runs to the root, + // and the first user entry it meets is this run's own prompt. + let mut ref_cursor: Option<String> = None; // Consecutive failed probe rounds (any success resets it). let mut probe_failures: u32 = 0; let mut generation: u64 = 0; @@ -1437,6 +1848,24 @@ async fn run_session(session: Session) { { break 'main; } + // The provider turn ref (ticket 19): `get_entries` + // now that the turn has settled — the session + // file plus the LAST user entry the turn + // consumed (its steer when one landed mid-run, + // else its prompt — for an unsteered turn that + // is also its first user entry, the ticket's + // spelling). The engine holds it for the stamp + // when the entry's segment finishes at the + // `Done` below. Best-effort: a failed read costs + // the ref — a later fork falls back to the + // budgeted paste — never the turn. + if let Some(reference) = + capture_turn_ref(&client, &session_pointer, &mut ref_cursor) + .await + && !send(&event_tx, AgentEvent::TurnRef { reference }).await + { + break 'main; + } let status = if turn_error.is_some() { DoneStatus::Errored } else { @@ -2323,6 +2752,9 @@ fn image_mime_type(path: &std::path::Path, bytes: &[u8]) -> Option<&'static str> #[cfg(test)] mod tests { use super::steering_mode_configured; + use super::{ + SessionEntries, branch_user_entries, fork_boundary, newest_user_entry, parse_entries, + }; use std::path::Path; fn write_settings(dir: &Path, body: &str) { @@ -2330,6 +2762,114 @@ mod tests { std::fs::write(dir.join("settings.json"), body).unwrap(); } + /// One session tree as a get_entries payload: `ids` chains the entries + /// in order (the bool marks user entries). + fn tree(ids: &[(&str, bool)], leaf: Option<&str>) -> Option<SessionEntries> { + let mut parent: Option<&str> = None; + let mut entries = Vec::new(); + for (id, user) in ids { + entries.push(serde_json::json!({ + "type": "message", + "id": id, + "parentId": parent, + "message": { "role": if *user { "user" } else { "assistant" } }, + })); + parent = Some(id); + } + parse_entries(&serde_json::json!({ + "entries": entries, + "leafId": leaf, + })) + } + + /// The ref walk names the newest user entry on the branch: the turn's + /// steer when one landed mid-run, else its prompt. + #[test] + fn the_ref_walk_names_the_last_user_entry_of_the_turn() { + // u1 → a1 → s2 (a steer) → a2: the turn consumed s2 last. + let entries = tree( + &[("u1", true), ("a1", false), ("s2", true), ("a2", false)], + Some("a2"), + ) + .expect("parseable"); + assert_eq!(newest_user_entry(&entries).as_deref(), Some("s2")); + // Without the steer, the prompt IS the turn's last user entry. + let entries = tree( + &[("u1", true), ("a1", false)], + Some("a1"), + ) + .expect("parseable"); + assert_eq!(newest_user_entry(&entries).as_deref(), Some("u1")); + // A stretch with no user entry of its own (an extension-initiated + // run) names nothing. + let entries = tree(&[("u1", true), ("a1", false), ("a2", false)], Some("a2")) + .expect("parseable"); + assert_eq!(newest_user_entry(&entries), None); + // A branch that walks off the listing (a since-bounded stretch) + // names nothing rather than an older turn's entry. + let entries = tree(&[("a2", false), ("a3", false)], Some("a3")).expect("parseable"); + assert_eq!(newest_user_entry(&entries), None); + // An empty session has no leaf to walk from. + let entries = tree(&[], None).expect("parseable"); + assert_eq!(newest_user_entry(&entries), None); + } + + /// A corrupt parent cycle cannot loop the walks. + #[test] + fn corrupt_parent_cycles_terminate() { + let data = serde_json::json!({ + "entries": [ + { "type": "message", "id": "x", "parentId": "y", "message": { "role": "user" } }, + { "type": "message", "id": "y", "parentId": "x", "message": { "role": "assistant" } }, + ], + "leafId": "y", + }); + let entries = parse_entries(&data).expect("parseable"); + assert_eq!(newest_user_entry(&entries), None); + assert!(branch_user_entries(&entries).len() <= 2); + } + + /// The branch walk lists the active branch's user entries, newest + /// first — the order the fork boundary search steps through. + #[test] + fn the_branch_walk_lists_user_entries_newest_first() { + let entries = tree( + &[ + ("u1", true), + ("a1", false), + ("u2", true), + ("a2", false), + ("u3", true), + ("a3", false), + ], + Some("a3"), + ) + .expect("parseable"); + assert_eq!( + branch_user_entries(&entries), + vec!["u3".to_owned(), "u2".to_owned(), "u1".to_owned()] + ); + } + + /// The fork boundary: the newest turn forks the whole session (no + /// RPC), an earlier one names the NEXT turn's user entry, and a ref + /// that is not on the branch refuses (the budgeted-paste fallback). + #[test] + fn the_fork_boundary_names_the_next_turn() { + let users: Vec<String> = ["u3", "u2", "u1"] + .iter() + .map(|id| id.to_string()) + .collect(); + // The boundary is the latest turn: Ok(None) — the --fork copy + // already ends there. + assert_eq!(fork_boundary(&users, "u3"), Ok(None)); + // An earlier boundary names the turn after it. + assert_eq!(fork_boundary(&users, "u2"), Ok(Some("u3".to_owned()))); + assert_eq!(fork_boundary(&users, "u1"), Ok(Some("u2".to_owned()))); + // A stale ref refuses rather than forking somewhere wrong. + assert_eq!(fork_boundary(&users, "gone"), Err(())); + } + /// No settings anywhere: the driver selects the mode itself. #[test] fn steering_mode_is_unconfigured_without_settings() { diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 8b0fa5dcba..1ff9094794 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -10,7 +10,12 @@ # clear_queue strand recovery — and the mid-turn steering surface # (orchestration v2, ticket 03): set_steering_mode all at session start, # steer bursts delivered together in order, images riding a steer, and -# clear_queue-before-abort when a Stop finds steers pi never consumed. +# clear_queue-before-abort when a Stop finds steers pi never consumed — +# and the native fork surface (orchestration v2, ticket 19): the session +# entry tree behind get_entries (the driver walks it after each settle to +# record provider turn refs) and the `--fork` fork process (the source +# copy plus the exclusive fork RPC when the boundary is not the latest +# turn). # NOTE: the python below is embedded in a sh single-quoted string - it must # never contain an apostrophe (a stray one splits the script into argv). exec python3 -u -c ' @@ -35,6 +40,7 @@ def reject(req, message): def user_message(text): emit({"type": "message_start", "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) emit({"type": "message_end", "message": {"role": "user", "content": [{"type": "text", "text": text}]}}) + append_entry("user") def assistant_chunks(chunks, thinking=False, stop="stop", error=None): emit({"type": "message_start", "message": {"role": "assistant", "content": []}}) @@ -47,6 +53,7 @@ def assistant_chunks(chunks, thinking=False, stop="stop", error=None): if error: message["errorMessage"] = error emit({"type": "message_end", "message": message}) + append_entry("assistant") def turn_and_settle(reply=None, thinking=False, stop="stop", error=None): emit({"type": "turn_start"}) @@ -106,6 +113,21 @@ else: assert "--extension" not in argv, argv session_file = None if "--no-session" in argv else os.path.join(os.getcwd(), ".pi-fixture-session.jsonl") +if resumed_session is not None: + # get_state reports the file the process was resumed with (pi answers + # with the session it actually holds), so a forked chat that resumes + # the copy keeps reporting THAT file as its session. + session_file = resumed_session +fork_source = None +if "--fork" in argv: + # The native-fork launch (ticket 19): a short-lived process that copies + # the source session and re-roots it at the boundary. It never runs a + # prompt, never resumes, and never loads the MCP bridge (--fork cannot + # combine with --session / --no-session anyway). + fork_source = argv[argv.index("--fork") + 1] + assert "--session" not in argv and "--no-session" not in argv, argv + assert os.path.isfile(fork_source), "fork source missing: " + fork_source + session_file = os.path.join(os.getcwd(), ".pi-fixture-forked.jsonl") if session_file is not None: with open(session_file, "a") as handle: # exists so the driver resume check passes handle.write("fixture\n") @@ -118,6 +140,54 @@ steering_queue = [] # flight (an extension-initiated run). streaming = False +# --- session entry tree (ticket 19) --------------------------------------- +# Every user and assistant message appends a {type: message, id, parentId} +# entry so get_entries can serve the tree the driver walks after each +# settle for provider turn refs: the leaf chain u1 -> a1 -> u2 ... with the +# walk bounded by the `since` cursor the driver passes. +entry_seq = 0 +leaf_id = None +entries = [] + +def append_entry(role): + global entry_seq, leaf_id + entry_seq += 1 + entry_id = ("u%d" if role == "user" else "a%d") % entry_seq + entries.append({ + "type": "message", + "id": entry_id, + "parentId": leaf_id, + "message": {"role": role}, + }) + leaf_id = entry_id + +def entries_data(command): + # The get_entries payload: append order, the entries strictly after + # `since`, and the current leaf (None for an empty session). An unknown + # `since` cursor fails (pi rejects it; the driver falls back to a full + # listing). + since = command.get("since") + if since is None: + return {"entries": list(entries), "leafId": leaf_id} + ids = [entry["id"] for entry in entries] + if since not in ids: + return None + return {"entries": entries[ids.index(since) + 1:], "leafId": leaf_id} + +# The fork source copied history (ticket 19): three turns. The boundary +# tests exercise both "the latest turn" (last_turn u3: no RPC fork — the +# copy already ends there) and "an earlier turn" (last_turn u2 / u1: the +# exclusive fork RPC at the NEXT turn user entry, u3 / u2). +FORK_TREE = [ + {"type": "message", "id": "u1", "parentId": None, "message": {"role": "user"}}, + {"type": "message", "id": "a1", "parentId": "u1", "message": {"role": "assistant"}}, + {"type": "message", "id": "u2", "parentId": "a1", "message": {"role": "user"}}, + {"type": "message", "id": "a2", "parentId": "u2", "message": {"role": "assistant"}}, + {"type": "message", "id": "u3", "parentId": "a2", "message": {"role": "user"}}, + {"type": "message", "id": "a3", "parentId": "u3", "message": {"role": "assistant"}}, +] + + def state_data(): return { "model": {"provider": "mock", "id": "mock/model", "name": "Mock", "contextWindow": 200000}, @@ -517,6 +587,11 @@ def run_prompt(req): stats = read_command() assert stats["type"] == "get_session_stats", stats response(stats, stats_data()) + # The turn-ref capture (ticket 19) rides between the settle stats + # and the Done: answer it before the streamed window opens. + refs = read_command() + assert refs["type"] == "get_entries", refs + response(refs, entries_data(refs)) time.sleep(0.3) # the driver parks: the turn Done is out streaming = True emit({"type": "agent_start"}) @@ -540,6 +615,10 @@ def run_prompt(req): response(command, state_data()) elif command["type"] == "get_session_stats": response(command, stats_data()) + elif command["type"] == "get_entries": + data = entries_data(command) + assert data is not None, command + response(command, data) elif command["type"] == "prompt": assert command.get("streamingBehavior") == "steer", command response(command, {"disposition": "started"}) @@ -589,10 +668,61 @@ def run_prompt(req): assert probe["type"] == "get_state", probe reject(probe, "wedged") return + if text == "fork-turn": + # The forked chat first turn (ticket 19): the main process must + # RESUME the session file the fork process created (never a fresh + # session, never the source), and the prompt must arrive BARE — a + # pasted history in front of it means the fork fell back. + assert resumed_session is not None, "the forked session was not resumed" + assert resumed_session.endswith(".pi-fixture-forked.jsonl"), resumed_session + assert text == "fork-turn", text + user_message(text) + turn_and_settle("reply:" + text) + return + if text == "fork-fallback": + # The refused/stale fork fallback (ticket 19): a FRESH session (no + # --session) with the budgeted history pasted in front of the + # prompt. + assert resumed_session is None, "the fallback must start a fresh session" + assert "PINEAPPLE" in text, text + assert text.endswith("fork-fallback"), text + user_message(text) + turn_and_settle("reply:fork-fallback") + return # default happy path user_message(text) turn_and_settle("reply:" + text) +if fork_source is not None: + # fork-mode process: get_state (the copy session file, never the + # source), get_entries (the copied tree), the exclusive fork RPC when + # the boundary is not the latest turn, get_state again, then EOF (the + # driver shuts the process down). Markers land in the copy session + # file so tests can prove which steps ran. A source file named with + # "cancel" or "refuse" scripts the fork RPC failing those two ways. + with open(session_file, "a") as handle: + handle.write("fork-launch:" + fork_source + "\n") + while True: + command = read_command() + kind = command["type"] + if kind == "get_state": + response(command, dict(state_data(), sessionFile=session_file, sessionId="pi-fixture-forked")) + elif kind == "get_entries": + response(command, {"entries": [dict(entry) for entry in FORK_TREE], "leafId": FORK_TREE[-1]["id"]}) + elif kind == "fork": + assert command["entryId"] in ("u2", "u3"), command + with open(session_file, "a") as handle: + handle.write("fork-rpc:" + command["entryId"] + "\n") + base = os.path.basename(fork_source) + if "cancel" in base: + response(command, {"text": "the boundary prompt", "cancelled": True}) + elif "refuse" in base: + reject(command, "fork refused") + else: + response(command, {"text": "the boundary prompt", "cancelled": False}) + else: + reject(command, "unexpected fork command: " + kind) + while True: command = read_command() kind = command["type"] @@ -654,6 +784,15 @@ while True: with open(session_file, "a") as handle: handle.write("steering-mode:" + command["mode"] + "\n") response(command) + elif kind == "get_entries": + # The settle capture (ticket 19): the driver walks the session + # tree after each settle to name the turn provider ref. An unknown + # `since` cursor is rejected (the driver re-reads the full tree). + data = entries_data(command) + if data is None: + reject(command, "unknown since cursor: " + str(command.get("since"))) + else: + response(command, data) elif kind == "get_session_stats": response(command, stats_data()) elif kind == "clear_queue": diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 13035ca6e2..f4166ef317 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -458,6 +458,11 @@ async fn title_runs_spawn_an_isolated_one_shot_and_answer_with_a_title() { // A hostile caller smuggles interaction surfaces; run_title must clear // every one of them before spawning. req.resume = Some("some-old-session".into()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "some-old-session.jsonl".into(), + last_turn: "u1".into(), + fallback_history: None, + }); req.attachments = vec!["/etc/passwd".into()]; req.auto_approve = true; let mut stream = harness().run_title(req, ctl).await.unwrap(); @@ -477,6 +482,8 @@ async fn title_runs_spawn_an_isolated_one_shot_and_answer_with_a_title() { // No session file in the scratch dir: the title sandbox is ephemeral // and never resumes. (The fixture asserts the spawn flags itself.) assert!(!scratch.path().join(".pi-fixture-session.jsonl").exists()); + // The smuggled fork directive never spawned a fork process either. + assert!(!scratch.path().join(".pi-fixture-forked.jsonl").exists()); // pi participates in device-local title generation. assert!(roboco_harness::supports_titles(HarnessId::Pi)); } @@ -1399,3 +1406,308 @@ async fn a_burst_of_steers_is_delivered_in_order() { assert_eq!(steered, 3, "{events:?}"); assert!(text_of(&events).contains("reply-to-burst"), "{events:?}"); } + +// --------------------------------------------------------------------------- +// Provider turn refs + native forks (t3-orchestration-v2 ticket 19) +// --------------------------------------------------------------------------- + +/// The session-tree helpers: the driver names the turn's ref after each +/// settle, the engine stamps it on the assistant entry, and a fork reads +/// the boundary off it. The unit level (walks, boundaries) lives in the +/// driver module; this proves the wire shape end to end. +fn session_file_of(events: &[AgentEvent]) -> String { + events + .iter() + .find_map(|e| match e { + AgentEvent::SessionStarted { session_id, .. } => Some(session_id.clone()), + _ => None, + }) + .expect("SessionStarted") +} + +fn turn_refs_of(events: &[AgentEvent]) -> Vec<roboco_proto::ProviderTurnRef> { + events + .iter() + .filter_map(|e| match e { + AgentEvent::TurnRef { reference } => Some(reference.clone()), + _ => None, + }) + .collect() +} + +/// Each settled turn emits its provider turn ref: the session file plus +/// the turn's user entry id — the first turn's prompt, and the second +/// turn's prompt once the parked session runs another message (the `since` +/// cursor bounds the second capture to the second turn's entries). +#[tokio::test] +async fn turn_refs_name_the_session_file_and_each_turns_user_entry() { + let cwd = tempfile::tempdir().unwrap(); + let cwd = cwd.path().display().to_string(); + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request_cwd("first", &cwd), controls).await.unwrap(); + let mut events = Vec::new(); + until_done(&mut stream, &mut events, Duration::from_secs(15)).await; + // The parked session runs a second turn on the same process. + steer.send(SteerMessage::text("second")).await.unwrap(); + let mut tail = Vec::new(); + until_done(&mut stream, &mut tail, Duration::from_secs(15)).await; + events.extend(tail); + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None); 2], "{events:?}"); + let session = session_file_of(&events); + assert_eq!( + turn_refs_of(&events), + vec![ + roboco_proto::ProviderTurnRef { + harness: HarnessId::Pi, + session_id: session.clone(), + turn_id: "u1".into(), + }, + roboco_proto::ProviderTurnRef { + harness: HarnessId::Pi, + session_id: session.clone(), + turn_id: "u2".into(), + }, + ], + "{events:?}" + ); + // The refs name the file the runs actually continue. + assert!(session.ends_with(".pi-fixture-session.jsonl"), "{session}"); + assert!(std::path::Path::new(&session).is_file()); +} + +/// A turn a steer landed in names its LAST user entry — the steer — as the +/// ref: pi's exclusive fork boundary is "the user entry after the ref", so +/// naming the last consumed entry keeps the whole turn (prompt, steer and +/// reply) when a fork cuts at the next turn's start. +#[tokio::test] +async fn a_steered_turn_names_its_last_user_entry() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("steer-flow"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "first part ") + && !sent; + events.push(event); + if steer_now { + sent = true; + steer.send(SteerMessage::text("steered text")).await.unwrap(); + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + let session = session_file_of(&events); + assert_eq!( + turn_refs_of(&events), + vec![roboco_proto::ProviderTurnRef { + harness: HarnessId::Pi, + session_id: session, + // u1 = the prompt, u2 = the steer: the turn consumed the steer + // last, so that is the ref. + turn_id: "u2".into(), + }], + "{events:?}" + ); +} + +/// A native fork at the source's LATEST turn: the fork process launches +/// with `--fork` and sends no RPC fork (the copy already ends at the +/// boundary), the run resumes the FORKED session file, and nothing is +/// pasted — the fake refuses the turn if the budgeted history reaches the +/// prompt or the run resumes anything but the forked file. +#[tokio::test] +async fn a_native_fork_at_the_latest_turn_copies_the_whole_session() { + let cwd = tempfile::tempdir().unwrap(); + let source = cwd.path().join("source-session.jsonl"); + std::fs::write(&source, b"fixture session\n").unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-turn", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: source.display().to_string(), + // The fake fork tree's NEWEST user entry: forking there keeps the + // whole copied session — no RPC fork. + last_turn: "u3".into(), + // What the engine would have pasted for a fresh session — the + // driver must NOT use it when the fork succeeds. + fallback_history: Some( + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // The run continues the FORKED session file, never the source. + let session = session_file_of(&events); + assert!(session.ends_with(".pi-fixture-forked.jsonl"), "{session}"); + assert!(std::path::Path::new(&session).is_file()); + assert_ne!(session, source.display().to_string()); + // The fork process ran with --fork and sent NO fork RPC: the markers + // in the forked session file prove both. + let forked = std::fs::read_to_string(cwd.path().join(".pi-fixture-forked.jsonl")).unwrap(); + assert!(forked.contains("fork-launch:"), "{forked}"); + assert!(!forked.contains("fork-rpc:"), "{forked}"); + assert_eq!(text_of(&events), "reply:fork-turn"); +} + +/// A native fork at an EARLIER turn also sends the RPC `fork` with the +/// user entry id of the turn AFTER the boundary: pi's fork is exclusive +/// (it re-roots before the named user message), so the forked session ends +/// exactly at the boundary. Nothing is pasted (the fake refuses it). +#[tokio::test] +async fn a_native_fork_at_an_earlier_turn_forks_at_the_next_turns_user_entry() { + let cwd = tempfile::tempdir().unwrap(); + let source = cwd.path().join("source-session.jsonl"); + std::fs::write(&source, b"fixture session\n").unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-turn", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: source.display().to_string(), + // The fake fork tree's SECOND user entry: the boundary is turn 2, + // so the RPC fork must name turn 3's user entry (u3). + last_turn: "u2".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let session = session_file_of(&events); + assert!(session.ends_with(".pi-fixture-forked.jsonl"), "{session}"); + // The RPC fork named the turn AFTER the boundary. + let forked = std::fs::read_to_string(cwd.path().join(".pi-fixture-forked.jsonl")).unwrap(); + assert!(forked.contains("fork-rpc:u3"), "{forked}"); + assert_eq!(text_of(&events), "reply:fork-turn"); +} + +/// A stale boundary ref (an entry the session branch no longer holds) +/// refuses the fork: the run starts a FRESH session with the budgeted +/// transcript in front of the prompt — never a failed send. The fake +/// asserts the pasted history and refuses a --session resume. +#[tokio::test] +async fn a_stale_fork_boundary_falls_back_to_the_budgeted_paste() { + let cwd = tempfile::tempdir().unwrap(); + let source = cwd.path().join("source-session.jsonl"); + std::fs::write(&source, b"fixture session +").unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: source.display().to_string(), + // Not on the fake tree branch: the driver must refuse the fork. + last_turn: "u-gone".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context. + <transcript> +user: Remember PINEAPPLE +</transcript> + +" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + // A fresh session served the turn (the fake refuses the fork-turn + // scenario on a resume). + let session = session_file_of(&events); + assert!(session.ends_with(".pi-fixture-session.jsonl"), "{session}"); + assert_eq!(text_of(&events), "reply:fork-fallback"); + // The refusal surfaced. + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, .. } if message.contains("pi could not fork") + )), + "{events:?}" + ); + // No fork RPC ever ran (the marker proves the refusal came first). + let forked = cwd.path().join(".pi-fixture-forked.jsonl"); + if forked.exists() { + let forked = std::fs::read_to_string(&forked).unwrap(); + assert!(!forked.contains("fork-rpc:"), "{forked}"); + } +} + +/// A fork a pi extension cancels falls back the same way: fresh session, +/// budgeted transcript, visible notice. +#[tokio::test] +async fn a_cancelled_fork_falls_back_to_the_budgeted_paste() { + let cwd = tempfile::tempdir().unwrap(); + let source = cwd.path().join("fork-cancel-source.jsonl"); + std::fs::write(&source, b"fixture session +").unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: source.display().to_string(), + // An earlier-turn boundary, so the cancelled RPC actually fires + // (the source name scripts the cancellation). + last_turn: "u2".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context. + <transcript> +user: Remember PINEAPPLE +</transcript> + +" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let session = session_file_of(&events); + assert!(session.ends_with(".pi-fixture-session.jsonl"), "{session}"); + assert_eq!(text_of(&events), "reply:fork-fallback"); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, .. } if message.contains("pi could not fork") + )), + "{events:?}" + ); +} + +/// A fork whose source session file is gone never spawns the fork process: +/// the run starts fresh with the budgeted paste (the same fallback as a +/// lost resume pointer). +#[tokio::test] +async fn a_fork_with_a_missing_source_file_falls_back_to_the_budgeted_paste() { + let cwd = tempfile::tempdir().unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: cwd.path().join("no-such-session.jsonl").display().to_string(), + last_turn: "u1".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context. + <transcript> +user: Remember PINEAPPLE +</transcript> + +" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert_eq!(text_of(&events), "reply:fork-fallback"); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, .. } if message.contains("pi could not fork") + )), + "{events:?}" + ); + // No fork process ever ran. + assert!(!cwd.path().join(".pi-fixture-forked.jsonl").exists()); +} From 927af1a4325244cc159258fe3ecebafac4fb84cc Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:41:25 +0700 Subject: [PATCH 59/96] fix(harness): review pass on the Pi native fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from the two-axis pass on 9a0e359b4: - Fold the duplicated leaf walk: newest_user_entry is now the first entry of branch_user_entries (the shared walk carries the cycle guard); the unit tests are corrected with it — a full listing of a stretch with no user entry of its own names the OLDER turn entry (the since cursor is what scopes the walk), and a parent cycle meets the user entry before the guard trips, so both tests asserted the wrong values. - An interrupted turn keeps its provider turn ref: the abort() settle (agent_settled with interrupted) runs the same settle capture before its Done, so a fork from an interrupted reply continues natively instead of always pasting (spec story 59 — errored replies already did). - The refused fork RPC fallback is now exercised (the fixture scenario existed but no test drove it): same budgeted-paste shape as the cancelled/stale/missing-source cases, with the fork-rpc marker proving the RPC actually fired. - The fallback-history literals in the last three pi tests use the same escaped + line-continuation spelling as the first two (raw newlines compiled but carried indentation), and the redundant text == fork-turn assert inside its own branch is gone (the default happy path answering a pasted prompt is the real oracle). --- crates/harness/src/pi/mod.rs | 68 +++++++++---------- crates/harness/tests/fixtures/fake-pi-rpc.sh | 5 +- crates/harness/tests/pi.rs | 69 +++++++++++++------- 3 files changed, 85 insertions(+), 57 deletions(-) diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index 18be04d2f4..81e38918e3 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -1090,32 +1090,17 @@ fn parse_entries(data: &Value) -> Option<SessionEntries> { /// (a since-bounded stretch with no user entry of its own — an /// extension-initiated run) or reaches the root names nothing. fn newest_user_entry(entries: &SessionEntries) -> Option<String> { - let mut cursor = entries.leaf.clone()?; - // A corrupt parent cycle cannot loop the walk: at most one pass per - // entry, then the walk gives up. - let mut steps = entries.by_id.len().saturating_add(1); - loop { - let Some((parent, is_user)) = entries.by_id.get(&cursor).cloned() else { - return None; - }; - if is_user { - return Some(cursor); - } - let Some(parent) = parent else { - return None; // the root: no user entry on this stretch. - }; - if steps == 0 { - return None; - } - steps -= 1; - cursor = parent; - } + // The first entry of the branch walk (newest first) is exactly the + // newest user entry; the shared walk carries the cycle guard. + branch_user_entries(entries).into_iter().next() } /// The user entry ids on the active branch, NEWEST first. The fork /// boundary search walks the whole branch: the fork subprocess reads a /// full `get_entries` listing, so the walk always reaches the root (and -/// the same cycle guard bounds a corrupt tree). +/// the same cycle guard bounds a corrupt tree). A walk that leaves the +/// listing stops — the entries after a `since` cursor bound it to one +/// turn's stretch. fn branch_user_entries(entries: &SessionEntries) -> Vec<String> { let mut users = Vec::new(); let mut cursor = entries.leaf.clone(); @@ -2277,7 +2262,21 @@ async fn run_session(session: Session) { generation += 1; if interrupted { // abort() landed: close out as Interrupted - // without waiting for anything else. + // without waiting for anything else. The + // settle capture still runs — pi is at rest + // (it just answered agent_settled), and the + // interrupted reply keeps its provider turn + // ref so a fork from it continues natively + // instead of pasting (spec story 59: + // interrupted replies are fork boundaries + // too). Best-effort, as at every settle. + if let Some(reference) = + capture_turn_ref(&client, &session_pointer, &mut ref_cursor) + .await + && !send(&event_tx, AgentEvent::TurnRef { reference }).await + { + break 'main; + } done_current = true; done_after_interrupt = true; let _ = send( @@ -2794,19 +2793,19 @@ mod tests { .expect("parseable"); assert_eq!(newest_user_entry(&entries).as_deref(), Some("s2")); // Without the steer, the prompt IS the turn's last user entry. + let entries = tree(&[("u1", true), ("a1", false)], Some("a1")).expect("parseable"); + assert_eq!(newest_user_entry(&entries).as_deref(), Some("u1")); + // A full listing of a stretch with no user entry of its own (an + // extension-initiated run) degrades to the OLDER turn's entry — + // the since cursor is what scopes the walk to one turn. let entries = tree( - &[("u1", true), ("a1", false)], - Some("a1"), + &[("u1", true), ("a1", false), ("a2", false)], + Some("a2"), ) .expect("parseable"); assert_eq!(newest_user_entry(&entries).as_deref(), Some("u1")); - // A stretch with no user entry of its own (an extension-initiated - // run) names nothing. - let entries = tree(&[("u1", true), ("a1", false), ("a2", false)], Some("a2")) - .expect("parseable"); - assert_eq!(newest_user_entry(&entries), None); - // A branch that walks off the listing (a since-bounded stretch) - // names nothing rather than an older turn's entry. + // A since-bounded listing (the stretch ends at the cursor) names + // nothing: the walk leaves the listing before any user entry. let entries = tree(&[("a2", false), ("a3", false)], Some("a3")).expect("parseable"); assert_eq!(newest_user_entry(&entries), None); // An empty session has no leaf to walk from. @@ -2814,7 +2813,8 @@ mod tests { assert_eq!(newest_user_entry(&entries), None); } - /// A corrupt parent cycle cannot loop the walks. + /// A corrupt parent cycle cannot loop the walks: they terminate (the + /// guard bounds the pass) even though the parent chain never ends. #[test] fn corrupt_parent_cycles_terminate() { let data = serde_json::json!({ @@ -2825,7 +2825,9 @@ mod tests { "leafId": "y", }); let entries = parse_entries(&data).expect("parseable"); - assert_eq!(newest_user_entry(&entries), None); + // The walk from the leaf meets the user entry before the cycle + // guard trips, and never loops past it. + assert_eq!(newest_user_entry(&entries).as_deref(), Some("x")); assert!(branch_user_entries(&entries).len() <= 2); } diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 1ff9094794..99e68c1648 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -672,10 +672,11 @@ def run_prompt(req): # The forked chat first turn (ticket 19): the main process must # RESUME the session file the fork process created (never a fresh # session, never the source), and the prompt must arrive BARE — a - # pasted history in front of it means the fork fell back. + # pasted history in front of it would not match this scenario at + # all, so the default happy path answering a pasted prompt is the + # oracle for the paste. assert resumed_session is not None, "the forked session was not resumed" assert resumed_session.endswith(".pi-fixture-forked.jsonl"), resumed_session - assert text == "fork-turn", text user_message(text) turn_and_settle("reply:" + text) return diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index f4166ef317..51178fd324 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -1594,8 +1594,7 @@ async fn a_native_fork_at_an_earlier_turn_forks_at_the_next_turns_user_entry() { async fn a_stale_fork_boundary_falls_back_to_the_budgeted_paste() { let cwd = tempfile::tempdir().unwrap(); let source = cwd.path().join("source-session.jsonl"); - std::fs::write(&source, b"fixture session -").unwrap(); + std::fs::write(&source, b"fixture session\n").unwrap(); let (controls, steer, _token) = controls(); drop(steer); let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); @@ -1604,12 +1603,8 @@ async fn a_stale_fork_boundary_falls_back_to_the_budgeted_paste() { // Not on the fake tree branch: the driver must refuse the fork. last_turn: "u-gone".into(), fallback_history: Some( - "Continue this side chat using the following prior transcript as context. - <transcript> -user: Remember PINEAPPLE -</transcript> - -" + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" .into(), ), }); @@ -1642,8 +1637,7 @@ user: Remember PINEAPPLE async fn a_cancelled_fork_falls_back_to_the_budgeted_paste() { let cwd = tempfile::tempdir().unwrap(); let source = cwd.path().join("fork-cancel-source.jsonl"); - std::fs::write(&source, b"fixture session -").unwrap(); + std::fs::write(&source, b"fixture session\n").unwrap(); let (controls, steer, _token) = controls(); drop(steer); let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); @@ -1653,12 +1647,8 @@ async fn a_cancelled_fork_falls_back_to_the_budgeted_paste() { // (the source name scripts the cancellation). last_turn: "u2".into(), fallback_history: Some( - "Continue this side chat using the following prior transcript as context. - <transcript> -user: Remember PINEAPPLE -</transcript> - -" + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" .into(), ), }); @@ -1689,12 +1679,8 @@ async fn a_fork_with_a_missing_source_file_falls_back_to_the_budgeted_paste() { session_id: cwd.path().join("no-such-session.jsonl").display().to_string(), last_turn: "u1".into(), fallback_history: Some( - "Continue this side chat using the following prior transcript as context. - <transcript> -user: Remember PINEAPPLE -</transcript> - -" + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" .into(), ), }); @@ -1711,3 +1697,42 @@ user: Remember PINEAPPLE // No fork process ever ran. assert!(!cwd.path().join(".pi-fixture-forked.jsonl").exists()); } + +/// A fork RPC pi REFUSES (success: false) falls back the same way: fresh +/// session, budgeted transcript, visible notice. +#[tokio::test] +async fn a_refused_fork_falls_back_to_the_budgeted_paste() { + let cwd = tempfile::tempdir().unwrap(); + let source = cwd.path().join("fork-refuse-source.jsonl"); + std::fs::write(&source, b"fixture session\n").unwrap(); + let (controls, steer, _token) = controls(); + drop(steer); + let mut req = request_cwd("fork-fallback", &cwd.path().display().to_string()); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: source.display().to_string(), + // An earlier-turn boundary, so the refused RPC actually fires + // (the source name scripts the refusal). + last_turn: "u2".into(), + fallback_history: Some( + "Continue this side chat using the following prior transcript as context.\n\ + <transcript>\nuser: Remember PINEAPPLE\n</transcript>\n\n" + .into(), + ), + }); + let events = run_to_end(&harness(), req, controls).await; + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let session = session_file_of(&events); + assert!(session.ends_with(".pi-fixture-session.jsonl"), "{session}"); + assert_eq!(text_of(&events), "reply:fork-fallback"); + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::Error { message, .. } if message.contains("pi could not fork") + )), + "{events:?}" + ); + // The refused RPC still ran (its marker proves the refusal, not a + // silent skip). + let forked = std::fs::read_to_string(cwd.path().join(".pi-fixture-forked.jsonl")).unwrap(); + assert!(forked.contains("fork-rpc:u3"), "{forked}"); +} From b8d4b5eab1bf631b08ab23554ac5e0a6b8324dd8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:42:18 +0700 Subject: [PATCH 60/96] fix(opencode): review pass on the native fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - v2_model_selection_and_prompt_use_the_documented_bodies asserted the exact 2.x prompt body, which now carries the client-chosen message `id` — assert the documented fields plus the id's `msg_` shape instead (the id is a fresh uuid per run). - Extract Server::adopt_agent: the fork arm had copied the resume arm's 2.x agent re-apply verbatim. - Cover the 2.x message-list pagination the fork's boundary resolution walks: a cursor-paged fixture (boundary turn on page one, the next user message on page two) proves the driver follows the cursor before it cuts. --- crates/harness/src/opencode/mod.rs | 49 ++++++++------- crates/harness/src/opencode/tests.rs | 30 +++++---- crates/harness/tests/opencode.rs | 93 ++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 43 deletions(-) diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index 78eb8e724c..af7214b839 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -973,6 +973,27 @@ impl Server { .await .map(|_| ()) } + + /// 2.x: the agent rides the session too (a create carries it in the + /// body; a resumed or forked session re-applies it here). A no-op on + /// 1.x, which has no session-scoped agent route — the run's agent + /// rides the prompt wire there or stays the server default. + async fn adopt_agent( + &self, + session_id: &str, + agent: Option<&str>, + directory: Option<&str>, + ) -> Result<(), HarnessError> { + if self.protocol().await == Protocol::V2 + && let Some(agent) = agent + { + let path = format!("/api/session/{session_id}/agent"); + self.post_json(&path, directory, &json!({ "agent": agent })) + .await + .map(|_| ())?; + } + Ok(()) + } } fn http_client() -> reqwest::Client { @@ -1563,17 +1584,7 @@ async fn run_session(session: Session) { // history the engine attached. match fork_session(&server, dir, &fork).await { Ok(forked) => { - if server.protocol().await == Protocol::V2 - && let Some(agent) = agent - { - server - .post_json( - &format!("/api/session/{forked}/agent"), - dir, - &json!({"agent": agent}), - ) - .await?; - } + server.adopt_agent(&forked, agent, dir).await?; forked } Err(e) => { @@ -1594,17 +1605,7 @@ async fn run_session(session: Session) { .and_then(Value::as_str) .unwrap_or(resume) .to_owned(); - if server.protocol().await == Protocol::V2 - && let Some(agent) = agent - { - server - .post_json( - &format!("/api/session/{id}/agent"), - dir, - &json!({"agent": agent}), - ) - .await?; - } + server.adopt_agent(&id, agent, dir).await?; id } Err(e) => { @@ -2534,7 +2535,7 @@ async fn fork_session( /// One message-list entry reduced to what a fork cut needs: (id, role). /// 1.x wraps the message in `{info: {id, role}, parts}`; 2.x items are /// flat `{id, type, …}` — the role lives under either key. -fn message_role(item: &Value) -> Option<(String, String)> { +fn message_id_and_role(item: &Value) -> Option<(String, String)> { let info = item.get("info").unwrap_or(item); let id = info.get("id").and_then(Value::as_str)?; let role = info @@ -2613,7 +2614,7 @@ async fn session_messages( .filter(|cursor| !cursor.is_empty()) .map(str::to_owned); for item in unwrap_data(response).as_array().cloned().unwrap_or_default() { - if let Some(entry) = message_role(&item) { + if let Some(entry) = message_id_and_role(&item) { messages.push(entry); } } diff --git a/crates/harness/src/opencode/tests.rs b/crates/harness/src/opencode/tests.rs index 9deab90ac3..f46a7f5408 100644 --- a/crates/harness/src/opencode/tests.rs +++ b/crates/harness/src/opencode/tests.rs @@ -1086,25 +1086,25 @@ fn new_user_message_ids_fit_both_wires() { } #[test] -fn message_role_reads_both_list_shapes() { +fn message_id_and_role_reads_both_list_shapes() { // 1.x: `{info: {id, role}, parts}`. 2.x: flat `{id, type}`. let v1 = json!({ "info": { "id": "msg_1", "role": "user" }, "parts": [] }); let v2 = json!({ "id": "msg_2", "type": "assistant", "time": { "created": 1 } }); assert_eq!( - message_role(&v1), + message_id_and_role(&v1), Some(("msg_1".to_owned(), "user".to_owned())) ); assert_eq!( - message_role(&v2), + message_id_and_role(&v2), Some(("msg_2".to_owned(), "assistant".to_owned())) ); // A synthetic 2.x report is a turn start the fork must cut at too. let synthetic = json!({ "id": "msg_3", "type": "synthetic" }); assert_eq!( - message_role(&synthetic), + message_id_and_role(&synthetic), Some(("msg_3".to_owned(), "synthetic".to_owned())) ); - assert_eq!(message_role(&json!({ "parts": [] })), None); + assert_eq!(message_id_and_role(&json!({ "parts": [] })), None); } #[test] @@ -1961,14 +1961,18 @@ async fn v2_model_selection_and_prompt_use_the_documented_bodies() { .1, json!({"model":{"providerID":"opencode","id":"muse","variant":"low"}}) ); - assert_eq!( - posts - .iter() - .find(|(p, _)| p == "/api/session/fixture/prompt") - .unwrap() - .1, - json!({"text":"first","files":[]}) - ); + let prompt = posts + .iter() + .find(|(p, _)| p == "/api/session/fixture/prompt") + .unwrap() + .1 + .clone(); + assert_eq!(prompt["text"], "first"); + assert_eq!(prompt["files"], json!([])); + // The client-chosen message id (ticket 20) rides `id` — a dynamic + // `msg_`-prefixed uuid, so only its shape is asserted here. + let message_id = prompt["id"].as_str().unwrap_or_default(); + assert!(message_id.starts_with("msg_"), "{message_id}"); } #[tokio::test] diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index 45622e4a56..6093466446 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -52,6 +52,10 @@ struct FakeOpencode { /// (`GET /session/{id}/message`, `GET /api/session/{id}/message`). /// Unlisted sessions 404 — the fork's "a call fails" fallback. messages: Arc<Mutex<std::collections::HashMap<String, Vec<Value>>>>, + /// Cursor-paginated 2.x message fixtures by session: whole pages, + /// served one `cursor=` step at a time (the pagination path of the + /// fork's boundary resolution). + paged_messages: Arc<Mutex<std::collections::HashMap<String, Vec<Vec<Value>>>>>, /// Fork outcomes by SOURCE session id: (status, response). An /// unconfigured source 404s the fork (the refused-fork fallback). forks: Arc< @@ -92,6 +96,7 @@ impl FakeOpencode { fail_aborts: Arc::new(Mutex::new(false)), v2, messages: Arc::default(), + paged_messages: Arc::default(), forks: Arc::default(), }; let accept = fake.clone(); @@ -146,6 +151,15 @@ impl FakeOpencode { .insert(session.to_owned(), messages); } + /// Install a session's message list as 2.x cursor pages (creation + /// order across the pages). + fn set_messages_paged(&self, session: &str, pages: Vec<Vec<Value>>) { + self.paged_messages + .lock() + .unwrap() + .insert(session.to_owned(), pages); + } + /// Install the fork outcome for a source session (status + response /// body; 1.x returns the session info raw, 2.x wrapped in `{data}`). fn set_fork(&self, source: &str, status: &'static str, response: Value) { @@ -205,7 +219,10 @@ impl FakeOpencode { let mut parts = start.split_whitespace(); let method = parts.next().unwrap_or_default().to_owned(); let target = parts.next().unwrap_or_default().to_owned(); - let path = target.split('?').next().unwrap_or_default().to_owned(); + let (path, query) = match target.split_once('?') { + Some((path, query)) => (path.to_owned(), query.to_owned()), + None => (target, String::new()), + }; if method == "GET" && (path == "/global/event" || path == "/api/event") { // Subscribe FIRST, then snapshot the backlog: frames landing @@ -250,7 +267,7 @@ impl FakeOpencode { } self.posts.lock().unwrap().push((path.clone(), body)); } - let (status, payload) = self.route(&method, &path); + let (status, payload) = self.route(&method, &path, &query); let body = payload.to_string(); let resp = format!( "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n\ @@ -263,9 +280,9 @@ impl FakeOpencode { } } - fn route(&self, method: &str, path: &str) -> (&'static str, Value) { + fn route(&self, method: &str, path: &str, query: &str) -> (&'static str, Value) { if self.v2 { - return self.route_v2(method, path); + return self.route_v2(method, path, query); } self.route_v1(method, path) } @@ -355,7 +372,7 @@ impl FakeOpencode { /// The 2.x `/api` surface: version-bearing info endpoints (protocol /// detection), `{data}`-wrapped responses, and the fork/message routes /// in their 2.x shapes. - fn route_v2(&self, method: &str, path: &str) -> (&'static str, Value) { + fn route_v2(&self, method: &str, path: &str, query: &str) -> (&'static str, Value) { match (method, path) { ("GET", "/api/info") | ("GET", "/api/health") | ("GET", "/api/status") => { ("200 OK", json!({ "version": "2.0.11", "healthy": true })) @@ -386,8 +403,29 @@ impl FakeOpencode { } ("GET", "/api/session/active") => ("200 OK", json!({ "data": {} })), // The fork's boundary resolution: `{data, cursor}` pages of - // flat `{id, type}` entries (the 2.0.11 shape). + // flat `{id, type}` entries (the 2.0.11 shape). A paged fixture + // answers one page per `cursor=` step; the driver's first page + // rides `order=asc` (no cursor). ("GET", p) if p.starts_with("/api/session/") && p.ends_with("/message") => { + let session = Self::session_id_of(p, "/api/session/"); + let pages = session + .as_deref() + .and_then(|id| self.paged_messages.lock().unwrap().get(id)) + .cloned(); + if let Some(pages) = pages { + let index = query + .split('&') + .find_map(|kv| kv.strip_prefix("cursor=")) + .and_then(|cursor| cursor.parse::<usize>().ok()) + .unwrap_or(0); + let page = pages.get(index).cloned().unwrap_or_default(); + let next = (index + 1 < pages.len()).then(|| (index + 1).to_string()); + let cursor = match next { + Some(next) => json!({ "next": next }), + None => json!({}), + }; + return ("200 OK", json!({ "data": page, "cursor": cursor })); + } match self.message_list(p, "/api/session/") { Some(list) => ("200 OK", json!({ "data": list, "cursor": {} })), None => ("404 Not Found", json!({})), @@ -2042,3 +2080,46 @@ async fn a_fork_boundary_the_server_cannot_resolve_falls_back_to_the_paste() { let events = drain_to_done(&mut stream).await; assert!(events.iter().all(|ev| !matches!(ev, AgentEvent::Error { .. }))); } + +#[tokio::test] +async fn a_v2_fork_resolution_follows_the_message_list_cursor() { + // The boundary turn sits on the first page, the next user message on + // the second: the driver must follow the cursor before it cuts. + let fake = FakeOpencode::start_v2().await; + fake.set_messages_paged( + "ses_src", + vec![ + vec![ + json!({ "id": "msg_a", "type": "user", "time": { "created": 1 } }), + json!({ "id": "msg_a_reply", "type": "assistant", "time": { "created": 2 } }), + ], + vec![ + json!({ "id": "msg_b", "type": "user", "time": { "created": 3 } }), + json!({ "id": "msg_b_reply", "type": "assistant", "time": { "created": 4 } }), + ], + ], + ); + fake.set_fork("ses_src", "200 OK", json!({ "data": { "id": "ses_fork2" } })); + let (controls, _steer, _token) = controls(); + let mut stream = harness(&fake) + .run(fork_request("hi", "ses_src", "msg_a", None), controls) + .await + .expect("run starts"); + + let started = next_event(&mut stream).await; + assert!(matches!( + &started, + AgentEvent::SessionStarted { session_id, .. } if session_id == "ses_fork2" + )); + let _commands = next_event(&mut stream).await; + // The cut addresses the next turn's user message — which only exists + // on the second page, so the driver followed the cursor to find it. + let forks = wait_posts(&fake, "/api/session/ses_src/fork", 1).await; + assert_eq!(forks[0], json!({ "before": "msg_b" })); + let prompts = wait_posts(&fake, "/api/session/ses_fork2/prompt", 1).await; + assert_eq!(prompts[0]["text"], "hi"); + + v2_assistant_message(&fake, "ses_fork2", "amsg_1"); + v2_idle(&fake, "ses_fork2"); + drain_to_done(&mut stream).await; +} From 04faf316a47e7e34b045eaf8108ed5b0b1354eb9 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:44:35 +0700 Subject: [PATCH 61/96] fix(harness): settle the Claude fork compile slips --- crates/harness/src/claude/mod.rs | 2 +- crates/harness/src/claude/normalize.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index 996527b249..450cb28cb0 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -188,7 +188,7 @@ async fn session_last_message_uuid( Ok(0) | Err(_) => break, Ok(_) => {} } - if !line.contains(b"\"uuid\"") { + if !line.windows(6).any(|window| window == b"\"uuid\"") { continue; } let Ok(text) = std::str::from_utf8(&line) else { diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index f3a889a6c2..f426c5b32c 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -789,7 +789,7 @@ impl Normalizer { }, result: f.result, error: None, - session_id: f.session_id, + session_id: f.session_id.clone(), } } else { // Split the CLI's internal `[ede_diagnostic]` breadcrumbs @@ -838,7 +838,7 @@ impl Normalizer { }, result: None, error, - session_id: f.session_id, + session_id: f.session_id.clone(), } }; let mut out = Vec::new(); From 39667ff9fbaaa5fcec453cdcd56bfaa513f3e96d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:13:26 +0700 Subject: [PATCH 62/96] feat(web): fork from any reply, harness choice, and the harness switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 26 (t3-orchestration-v2): web parity for tickets 21, 22, 24 and 25, using the same engine RPCs the desktop drives. - "Fork from here" on any settled assistant reply (ticket 21): the row model stamps forkEntry on the entry's last settled row (errored and interrupted included, never user/system/streaming), and the hover strip beside Copy gains the branch-glyph action. Threaded onForkFromEntry through TranscriptView — the chat page and the side-chat surface open the dialog (forkSpecFor owns the sibling rule: a side chat's own fork lands under its parent); the subagent pane passes none and renders no action. - The fork dialog (ticket 22): both fork entry points — the files panel's classic button and the hover action — open a modal (the desktop's fork_dialog) defaulting to the source's own harness/model; confirming sends FORK_SIDE_CHAT with only the picks that differ (boundary, harness override, model override), then seeds the row and opens the pane surface. state/fork-dialog.ts owns the machine; the app shell mounts the layer. The shared harness chips / model rows live in ui/HarnessChoice.tsx (the components/README ui/ rule), served by lib/harness-choice.ts's dialog-local loads with the source device's catalog routing. - The unlocked harness picker (ticket 24): isHarnessLocked gains the harness_switchable gate (harnessSwitchable, lib/harness-switch.ts — idle chat, no send in flight), and a pick on an existing chat becomes the one-way switch — SwitchHarness carries the built draft (switchHarnessDraft: the new harness's remembered defaults, sandbox kept), never a bare setChatConfig; a foreign model row's pick rides the same switch; a re-pick of the chat's current row harness is a no-op. The "Switched to <harness>" seam renders as a harnessSwitchMarker divider row (lib/transcript + the component). - "Continue on another harness" on Limited chats (ticket 25): the banner's fourth chip (spec story 55) opens an anchored card listing the enabled harnesses minus the current one plus a model pick; confirming performs the one-way switch and only then fires the composer's fixed-prompt continuation WITH the switched picks — the resume handle's override (the desktop's apply_chat_config stamp: the chats-watch frame can trail the SwitchHarness reply, so the run carries the new harness deterministically), with the desktop's still_selected guard. A refused switch surfaces the reason on the notice line and keeps the banner up. No wire changes: the boundary/harness/model fork params, SwitchHarness and the harnessSwitch message part landed with tickets 21/22/24 — the web only consumes them (ADR 0005 additive). Tests: lib (fork-dialog pure halves + the spec builder, harness-switch, harness-choice, limited's continue set, side-chat-actions' new params, composer-draft's lock + switch draft), the headless fork dialog store, the mounted dialog layer, the mounted banner's Continue arm (switch-then- resume ordering, the switched picks, the refusal, the moved selection), the mounted composer's resume-with-override, and the transcript's seam rows + forkEntry stamping. Not run locally (the no-build override); CI on the PR is the signal. --- web/packages/app/src/components/app-shell.tsx | 9 + .../app/src/components/composer-pickers.tsx | 61 ++- web/packages/app/src/components/composer.tsx | 133 ++++-- .../components/files/explorer-sections.tsx | 61 +-- .../app/src/components/fork-dialog.tsx | 113 ++++++ .../app/src/components/limited-banner.tsx | 307 +++++++++++++- .../app/src/components/surface-registry.tsx | 23 +- .../app/src/components/transcript.tsx | 131 +++++- .../app/src/components/ui/HarnessChoice.tsx | 120 ++++++ web/packages/app/src/lib/composer-draft.ts | 34 +- web/packages/app/src/lib/fork-dialog.ts | 109 +++++ web/packages/app/src/lib/harness-choice.ts | 100 +++++ web/packages/app/src/lib/harness-switch.ts | 94 +++++ web/packages/app/src/lib/limited.ts | 17 + web/packages/app/src/lib/side-chat-actions.ts | 56 ++- web/packages/app/src/lib/transcript.ts | 84 +++- web/packages/app/src/routes/chat-page.tsx | 57 ++- web/packages/app/src/state/fork-dialog.ts | 334 ++++++++++++++++ web/packages/app/src/styles/app.css | 245 +++++++++++- web/packages/app/tests/composer-draft.test.ts | 59 +++ .../app/tests/composer-resume-switch.test.ts | 230 +++++++++++ .../app/tests/fork-dialog-layer.test.ts | 252 ++++++++++++ web/packages/app/tests/fork-dialog.test.ts | 377 ++++++++++++++++++ web/packages/app/tests/harness-choice.test.ts | 112 ++++++ web/packages/app/tests/harness-switch.test.ts | 113 ++++++ web/packages/app/tests/limited-banner.test.ts | 249 +++++++++++- web/packages/app/tests/limited.test.ts | 34 ++ .../app/tests/side-chat-actions.test.ts | 35 ++ .../app/tests/transcript-fork-seam.test.ts | 123 +++++- web/packages/engine-client/src/methods.ts | 24 +- 30 files changed, 3569 insertions(+), 127 deletions(-) create mode 100644 web/packages/app/src/components/fork-dialog.tsx create mode 100644 web/packages/app/src/components/ui/HarnessChoice.tsx create mode 100644 web/packages/app/src/lib/fork-dialog.ts create mode 100644 web/packages/app/src/lib/harness-choice.ts create mode 100644 web/packages/app/src/lib/harness-switch.ts create mode 100644 web/packages/app/src/state/fork-dialog.ts create mode 100644 web/packages/app/tests/composer-resume-switch.test.ts create mode 100644 web/packages/app/tests/fork-dialog-layer.test.ts create mode 100644 web/packages/app/tests/fork-dialog.test.ts create mode 100644 web/packages/app/tests/harness-choice.test.ts create mode 100644 web/packages/app/tests/harness-switch.test.ts diff --git a/web/packages/app/src/components/app-shell.tsx b/web/packages/app/src/components/app-shell.tsx index 9cd9631a70..d181d15407 100644 --- a/web/packages/app/src/components/app-shell.tsx +++ b/web/packages/app/src/components/app-shell.tsx @@ -85,6 +85,7 @@ import { toggleSettings } from "../lib/settings-close"; import { PaneSeam } from "./pane-seam"; import { RightPane, usePaneGlide } from "./right-pane"; import { FilesPaneColumn } from "./files/files-pane-column"; +import { ForkDialogLayer } from "./fork-dialog"; import { RightTabStrip } from "./right-tab-strip"; import { useConnectionState } from "./connection-state"; import { Titlebar, islandTarget } from "./titlebar"; @@ -991,6 +992,14 @@ export function AppShell() { } }} /> + {/* + The fork dialog (t3-orchestration-v2 tickets 21+22): one mount over + everything — both entry points (the files panel's fork button, the + transcript's "Fork from here" hover action) open it through the + module-level store, exactly like the sidebar owns the add-space + palette's single mount. + */} + <ForkDialogLayer /> </div> ); } diff --git a/web/packages/app/src/components/composer-pickers.tsx b/web/packages/app/src/components/composer-pickers.tsx index bd2f372948..d0a344e8bb 100644 --- a/web/packages/app/src/components/composer-pickers.tsx +++ b/web/packages/app/src/components/composer-pickers.tsx @@ -17,6 +17,7 @@ import { rememberModelOption, rememberReasoning, rememberReasoningForModel, + switchHarnessDraft, toggleModelFavorite, } from "../lib/composer-draft"; import { effectiveReasoningLadder, fastModeValues, fastTierOn, traitsCustomized, traitsSummary } from "../lib/traits-summary"; @@ -102,6 +103,23 @@ export interface ComposerPickersProps { readonly onDraft: (next: DraftConfig) => void; /** Persist the next config on an existing chat (`Mutate setChatConfig`). */ readonly onPersist: (next: DraftConfig) => void; + /** + * The one-way harness switch (t3-orchestration-v2 ticket 24): an + * EXISTING chat's unlocked harness pick hands the built draft here + * instead of `onPersist` — the SwitchHarness RPC owns the config write + * (it also writes the "Switched to <harness>" seam and drops the old + * harness session, none of which `setChatConfig` does). Absent: the + * switch is unavailable and the pick stays locked (the subagent-less + * hosts that never wired it). + */ + readonly onSwitchHarness?: (next: DraftConfig) => void; + /** + * Whether the chat's harness picker is unlocked for that one-way switch + * (ticket 24 — the desktop's `harness_switchable`): true only while the + * chat exists, no send is in flight, and no turn runs. The composer + * computes it; `isHarnessLocked` consumes it. + */ + readonly harnessSwitchable?: boolean; /** * Escape's focus return — the composer's textarea (`animate_close`, * pickers.rs:866): Base UI's escape-key dismissal routes it through @@ -130,7 +148,7 @@ export interface ComposerPickersProps { } export function ComposerPickers(props: ComposerPickersProps) { - const { catalog, draft, chatConfig, sideChatHarnessEditable = false, onDraft, onPersist, escapeFocusTarget, onOpenChange, newChat = false, openRequest } = props; + const { catalog, draft, chatConfig, sideChatHarnessEditable = false, onDraft, onPersist, onSwitchHarness, harnessSwitchable = false, escapeFocusTarget, onOpenChange, newChat = false, openRequest } = props; const engineLabel = props.engineLabel ?? "this engine"; const [open, setOpen] = useState(false); const setOpenAndNotify = useCallback( @@ -204,7 +222,7 @@ export function ComposerPickers(props: ComposerPickersProps) { useCallback(() => composerDefaults.getSnapshot(), []), ); - const locked = isHarnessLocked(chatConfig, sideChatHarnessEditable); + const locked = isHarnessLocked(chatConfig, sideChatHarnessEditable, harnessSwitchable); const effectiveHarness = draft.harness; const offered = useMemo(() => offeredHarnesses(harnesses.rows), [harnesses.rows]); // The tab strip's descriptor set: offered harnesses, the committed one @@ -300,9 +318,28 @@ export function ComposerPickers(props: ComposerPickersProps) { [draft, resolveModel, resolveDescriptor, onDraft, onPersist, chatConfig], ); + /** + * The one-way switch (ticket 24): an existing chat's harness pick — or a + * foreign model row's pick, which rides the same switch — hands the + * built draft to `onSwitchHarness` (the composer drives the RPC) and + * flips the draft optimistically, exactly the desktop's + * `switch_chat_harness` stamp. Model options reset to the new harness's + * defaults unless the user chose them (`switchHarnessDraft`). + */ + function switchTo(harness: HarnessId, model?: string): void { + if (chatConfig === null || onSwitchHarness === undefined) { + return; + } + rememberHarness(harness); + const next = switchHarnessDraft(chatConfig, harness, model ?? undefined); + onDraft(next); + onSwitchHarness(next); + } + function pickHarness(harness: HarnessId): void { if (locked) { - // Harness is locked once the chat exists (feature-inventory A1.7). + // Harness is locked once the chat works (feature-inventory A1.7) — + // an idle chat unlocks it for ticket 24's one-way switch. return; } if (draft.harness === harness) { @@ -326,6 +363,16 @@ export function ComposerPickers(props: ComposerPickersProps) { rememberHarness(harness); return; } + // An existing chat's pick is a one-way harness switch + // (t3-orchestration-v2 ticket 24): the engine writes the + // "Switched to <harness>" seam, drops the old harness session, and + // saves this harness with its default picks — the next send carries + // the budgeted history of everything above the seam. Unsaved side + // chats never reach here (their createChat still owns the harness). + if (chatConfig !== null && onSwitchHarness !== undefined) { + switchTo(harness); + return; + } rememberHarness(harness); // The remembered model for this harness takes over via the defaults // fallback; a foreign pick must not linger (pickers.rs:1380-1394). @@ -352,6 +399,14 @@ export function ComposerPickers(props: ComposerPickersProps) { if (locked) { return; } + if (chatConfig !== null && onSwitchHarness !== undefined) { + // Ticket 24: a foreign model row's pick IS the switch — the model + // rides the switch's own config write (a separate setChatConfig + // would race it, and the seam belongs to the switch). + rememberModel(harness, model.id, model.label); + switchTo(harness, model.id); + return; + } pickHarness(harness); } rememberModel(harness, model.id, model.label); diff --git a/web/packages/app/src/components/composer.tsx b/web/packages/app/src/components/composer.tsx index 7849539017..75fb3dacf7 100644 --- a/web/packages/app/src/components/composer.tsx +++ b/web/packages/app/src/components/composer.tsx @@ -59,6 +59,7 @@ import { sendRun, type DraftConfig, } from "../lib/composer-actions"; +import { switchHarness as switchChatHarness, harnessSwitchable } from "../lib/harness-switch"; import { attachmentStripHeight, COMPOSER_MAX_WIDTH, @@ -379,12 +380,15 @@ interface ComposerProps { readonly editFailureRef?: React.MutableRefObject<((message: string) => void) | null>; /** * The Limited banner's **Resume** handle (orchestration v2, tickets - * 06/10): assigned the composer's `resume_limited` — the fixed continue + * 06/10/25): assigned the composer's `resume_limited` — the fixed continue * prompt through the normal send path, the user's draft and staged files * handed back — so the status strip above the composer can trigger it. - * The `editCommitRef` pattern again. + * Called plainly for Resume, or with the switch's landed picks for + * "Continue on another harness" (ticket 25: the continuation carries the + * NEW harness deterministically — the desktop's `apply_chat_config` stamp + * before `resume_limited`). The `editCommitRef` pattern again. */ - readonly resumeRef?: React.MutableRefObject<(() => void) | null>; + readonly resumeRef?: React.MutableRefObject<((override?: Partial<DraftConfig>) => void) | null>; /** * The workspace commands' host surfaces (composer.rs's * `ComposerEvent::WorkspaceCommand` dispatch, web-shaped): the Roboco-local @@ -677,6 +681,14 @@ export function Composer({ ); const [expanded, setExpanded] = useState(false); const [busy, setBusy] = useState(false); + // The one-way switch's unlock (t3-orchestration-v2 ticket 24, the + // desktop's `harness_switchable`): the chat exists, no send of its own + // is still awaiting confirmation (`busy`), and no turn is in flight — + // Working or AwaitingInput both count as live. An unsaved side chat + // keeps its own editable facet (the pickers' `sideChatHarnessEditable`), + // which the lock already honors. + const switchable = + chat.id !== "" && !isUnsavedSideChat(chat.id) && harnessSwitchable(true, busy, runLive); // ── Ticket 15: the new-thread canvas branch ───────────────────────────── // `new_chat = selected_chat.is_none()` (composer.rs:7286) — the web's // canvas chat is the "" stub, so `expanded = expanded_mode || new_chat` @@ -2524,6 +2536,48 @@ export function Composer({ setDraft(next); }, []); + // ── The one-way harness switch (t3-orchestration-v2 ticket 24) ────── + // `Pickers::switch_chat_harness`: the pickers hand the built draft (the + // new harness with its default picks); the draft is already flipped + // optimistically, and the SwitchHarness RPC tells the engine, which + // writes the "Switched to <harness>" seam, drops the old harness + // session, and arms the budgeted history for the next send. A failed + // switch surfaces here in the failure notice — the engine's row stayed + // on the old harness, and the chats watch reverts the row the desktop + // stamped. A re-pick of the chat's CURRENT row harness is a UI no-op — + // nothing to seam (the desktop's guard; the engine would only refuse + // with "Chat is already on …"). One switch per chat at a time: a double + // click must not seam twice. + const switchInFlightRef = useRef<string | null>(null); + const switchHarness = useCallback( + (next: DraftConfig) => { + if (switchInFlightRef.current === chat.id) { + return; + } + if (chat.config?.harness === next.harness) { + return; + } + switchInFlightRef.current = chat.id; + void switchChatHarness(session.client, chat.id, next.harness, { + model: next.model, + reasoning: next.reasoning, + modelOptions: next.modelOptions, + }) + .catch((error: unknown) => { + setFailure({ + message: `Could not switch the harness: ${describeSendError(error)}`, + key: chat.id, + }); + }) + .finally(() => { + if (switchInFlightRef.current === chat.id) { + switchInFlightRef.current = null; + } + }); + }, + [session.client, chat.id, chat.config?.harness], + ); + // The composer's mid-session model / reasoning / options changes persist // through `Mutate setChatConfig` (pickers.rs:1474) — a picker change, never // a send. An UNSAVED side chat has no row to write yet: the first send's @@ -2557,8 +2611,15 @@ export function Composer({ interruptRef.current = interrupt; // ── The send path (`Composer::send`, composer.rs:6032-6705) ──────────── + // `runOverride`: a send whose run identity comes from OUTSIDE the draft + // — the Limited banner's "Continue on another harness" continuation + // (t3-orchestration-v2 ticket 25): the switch's landed picks, not the + // possibly-stale draft (the chats-watch frame can trail the switch's + // reply). The desktop's `confirm_continue_on_harness` stamps the row the + // same way so its `resolved()` reads the new harness; the web's stamp is + // this parameter, applied over the draft with the sandbox kept. const send = useCallback( - async (typed: string, queue: boolean): Promise<void> => { + async (typed: string, queue: boolean, runOverride?: Partial<DraftConfig>): Promise<void> => { // Existing busy chats always queue; compatibility was checked before // taking the draft (the capability gate below). if (queue) { @@ -2586,6 +2647,10 @@ export function Composer({ // stored cwd else `"."`. There is no error path — the desktop has no // guard for a projectless send, and neither does the web. const sendCwd = resolveSendCwd(chat.id === "", chat.cwd, chat.cwd); + // The run identity this send carries: the draft, with any override + // merged over it (the sandbox and anything un-overridden keep the + // draft's — the chat's own picks). + const runDraft: DraftConfig = { ...draft, ...(runOverride ?? {}) }; // New-chat mode mints the chat id on first send (shell.rs:5897-5899, // composer.rs:6249-6258): create the row, wait for it to land, and // hand the host the navigation BEFORE the wire call — the route @@ -2618,7 +2683,7 @@ export function Composer({ // projectless `"~"` NEVER rides here — it lives on the // RunRequest (composer.rs:6515-6521 inserts `cwd` only for the // worktree-reuse override). - config: buildChatConfig(draft), + config: buildChatConfig(runDraft), }, ); await waitForChatRow(session.cache, chatId); @@ -2652,7 +2717,7 @@ export function Composer({ session.client, unsavedRow, chatId, - buildChatConfig(draft), + buildChatConfig(runDraft), ); } catch (error) { setFailure({ @@ -2762,7 +2827,7 @@ export function Composer({ const sendResult = await sendRun( session.client, chatId, - draft, + runDraft, trimmed, sendCwd, { mintMessageId: () => messageId, worktree: runWorktree }, @@ -2865,7 +2930,7 @@ export function Composer({ [chat.id, chat.cwd, draft, session.client, session.engine.baseUrl, staged, engineSupports, checkReferenceDelivery, onNewThreadLaunched, commentCount], ); - // ── **Resume** on a Limited chat (orchestration v2, tickets 06/10) ──── + // ── **Resume** on a Limited chat (orchestration v2, tickets 06/10/25) ─ // `resume_limited` (composer.rs): send the fixed continue prompt as a // NORMAL run — same send path, same guards, same queue semantics as // typing it — never a special engine mode. The run clears the limit @@ -2874,26 +2939,36 @@ export function Composer({ // theirs, not part of the resume: the send clears the composer, so hand // the slots straight back — `send` takes its own copies, so restoring // them after it settles cannot leak into the sent message. - const resumeLimited = useCallback(() => { - const draftText = textRef.current; - const stagedSnapshot = staged; - void send(RESUME_CONTINUE_PROMPT, false).then(() => { - if (draftText.length > 0) { - setText(draftText); - chatDrafts.set(textDraftKey, draftText); - } - if (stagedSnapshot.length > 0) { - setStagedByChat((current) => { - const fresh = current[chat.id] ?? []; - const merged = [ - ...stagedSnapshot.filter((att) => !fresh.some((f) => f.id === att.id)), - ...fresh, - ]; - return { ...current, [chat.id]: merged }; - }); - } - }); - }, [chat.id, staged, send, textDraftKey]); + // + // `override` (ticket 25, "Continue on another harness"): the switch's + // landed picks — the continuation must carry the NEW harness and model + // deterministically (the chats-watch frame that would re-seed the draft + // can trail the SwitchHarness reply), so the banner hands them here and + // the send merges them over the draft, exactly the desktop's + // `apply_chat_config` stamp before `resume_limited`. + const resumeLimited = useCallback( + (override?: Partial<DraftConfig>) => { + const draftText = textRef.current; + const stagedSnapshot = staged; + void send(RESUME_CONTINUE_PROMPT, false, override).then(() => { + if (draftText.length > 0) { + setText(draftText); + chatDrafts.set(textDraftKey, draftText); + } + if (stagedSnapshot.length > 0) { + setStagedByChat((current) => { + const fresh = current[chat.id] ?? []; + const merged = [ + ...stagedSnapshot.filter((att) => !fresh.some((f) => f.id === att.id)), + ...fresh, + ]; + return { ...current, [chat.id]: merged }; + }); + } + }); + }, + [chat.id, staged, send, textDraftKey], + ); // The banner's handle assignment: the strip above the composer holds the // ref; `send`'s identity churn re-binds it without re-subscribing the @@ -3751,11 +3826,13 @@ export function Composer({ draft={draft} chatConfig={chat.config} sideChatHarnessEditable={isUnsavedSideChat(chat.id) && !busy} + harnessSwitchable={switchable} newChat={newChat} openRequest={modelPickerRequest} engineLabel={session.engine.label} onDraft={applyDraft} onPersist={persistDraft} + onSwitchHarness={switchHarness} escapeFocusTarget={() => textareaRef.current} onOpenChange={setPickersOpen} /> diff --git a/web/packages/app/src/components/files/explorer-sections.tsx b/web/packages/app/src/components/files/explorer-sections.tsx index ff488d1328..0b0741b42c 100644 --- a/web/packages/app/src/components/files/explorer-sections.tsx +++ b/web/packages/app/src/components/files/explorer-sections.tsx @@ -9,17 +9,13 @@ import { useFleetSnapshot } from "../../state/fleet"; import { useNow } from "../../state/hooks"; import { rightPaneStore } from "../../state/right-pane"; import { sidebarNotice } from "../../state/notice"; -import { beginUnsavedSideChat, seedPendingSideChat } from "../../state/side-chats"; +import { beginUnsavedSideChat } from "../../state/side-chats"; import { mintId } from "../../lib/id"; -import { - beginSideChatCreate, - endSideChatCreate, - forkSideChat, -} from "../../lib/side-chat-actions"; +import { forkSpecFor } from "../../lib/fork-dialog"; +import { forkDialogStore } from "../../state/fork-dialog"; import { bodyBudget, childChatRows, - childChatTitle, chromeHeight, contentHeight, effectiveShown, @@ -86,11 +82,10 @@ export function ExplorerSections({ chatId }: { chatId: string }) { subagents: 10, chats: 10, }); - // The one-at-a-time creation guard (`side_chat_creating`) — local so the - // header buttons' disabled state re-renders with it. Only the FORK arms it - // now (upstream #568): "New side chat" mints nothing up front, so there - // is no in-flight RPC to double-trigger. - const [creating, setCreating] = useState(false); + // The one-at-a-time creation guard (`side_chat_creating`) moved with the + // fork into the dialog (state/fork-dialog.ts): opening the dialog is + // instant, and its modal owns the in-flight window — the header buttons + // no longer dim for a fork under way. // The active chat's transcript — the SAME store the chat page renders // (one `WatchDocMessages` stream per open chat, from the session pool). @@ -202,37 +197,15 @@ export function ExplorerSections({ chatId }: { chatId: string }) { sidebarNotice.set("Start a conversation before creating a side chat."); return; } - if (!beginSideChatCreate()) { - return; - } - setCreating(true); - void (async () => { - try { - const chat = await forkSideChat(session.client, { - chatId: mintId(), - sourceChatId: parentChat.id, - parentChatId: parentChat.id, - targetDeviceId: parentChat.deviceId, - }); - // The reply is the ENGINE's row (raw id); the surface and the - // fleet rows speak the scoped form — and the seed (the desktop's - // `pending_side_chat`) bridges the beat before the registry frame - // lands the row. - const scoped = encodeScopedId(session.engine.baseUrl, chat.id); - seedPendingSideChat({ ...chat, id: scoped }); - rightPaneStore.addSideChatSurface(chatId, { - chatId: scoped, - title: childChatTitle(chat), - }); - } catch (error) { - sidebarNotice.set( - error instanceof Error ? error.message : "The side chat could not be created.", - ); - } finally { - endSideChatCreate(); - setCreating(false); - } - })(); + // Ticket 22 ("fork into another harness"): the fork dialog opens first + // so the harness and model can be chosen (defaulting to this + // conversation's own); its confirm performs the fork through the same + // RPC + seed + surface path the button used to drive inline — the + // one-at-a-time guard and the error surface moved with it + // (state/fork-dialog.ts). + forkDialogStore.open( + forkSpecFor(parentChat, chatId, null, session.client, session.engine.baseUrl), + ); }, [session, parentChat, chatId]); // ── The height budget (`render_sections`, sections.rs:384-449) ─────────── @@ -289,7 +262,6 @@ export function ExplorerSections({ chatId }: { chatId: string }) { className="files-section-action" aria-label="New side chat" title="New side chat" - disabled={creating} onClick={(event) => { event.stopPropagation(); newChildChat(); @@ -302,7 +274,6 @@ export function ExplorerSections({ chatId }: { chatId: string }) { className="files-section-action" aria-label="Fork this chat" title="Fork this chat" - disabled={creating} onClick={(event) => { event.stopPropagation(); forkChat(); diff --git a/web/packages/app/src/components/fork-dialog.tsx b/web/packages/app/src/components/fork-dialog.tsx new file mode 100644 index 0000000000..e64b310abb --- /dev/null +++ b/web/packages/app/src/components/fork-dialog.tsx @@ -0,0 +1,113 @@ +import { memo } from "react"; +import { RbDialogGlass } from "./base/dialog"; +import { HarnessChip, harnessChipsEmptyText, ModelList } from "./ui/HarnessChoice"; +import { forkDialogOffered, forkDialogStore, useForkDialog } from "../state/fork-dialog"; +import { dialogError, dialogLoading, dialogRows } from "../lib/harness-choice"; + +/** + * The fork dialog (t3-orchestration-v2 tickets 21+22) — the web peer of + * the desktop's `Shell::render_fork_dialog` (`shell/fork_dialog.rs`): + * the harness and model a fork runs on, chosen when the fork starts. + * + * Both fork entry points open it: the files panel's classic "Fork this + * chat" button and the transcript's "Fork from here" hover action (the + * ticket-21 boundary rides the spec, flipping the title/body). It + * defaults to the source's own harness and model (spec story 61), so + * confirming without touching anything forks exactly as before; picking + * another harness makes the fork a cross-harness one — the engine serves + * it with the budgeted history instead of a native session fork, and the + * note under the models says so. + * + * Headless state in `state/fork-dialog.ts` (the add-space palette's + * store shape); this layer is the only mount — `AppShell` renders it once, + * over everything, and the entry points call `forkDialogStore.open()`. + * The dialog is deliberately local to the fork flow: the composer's own + * harness/model picker (and its unlock in ticket 24's one-way switch) + * stays untouched, so the two features never share state. + */ + +/** The mounted fork dialog layer — null while closed. */ +export const ForkDialogLayer = memo(function ForkDialogLayer() { + const state = useForkDialog(); + if (!state.open || state.spec === null) { + return null; + } + const boundarySet = state.spec.boundary !== null; + const sourceHarness = state.spec.source.config?.harness ?? null; + const offered = forkDialogOffered(state); + const selectedHarness = state.harness; + const selectedModel = state.model; + const modelLoad = selectedHarness === null ? "idle" : state.models.get(selectedHarness) ?? "idle"; + const crossHarness = sourceHarness !== null && selectedHarness !== null && sourceHarness !== selectedHarness; + + return ( + <RbDialogGlass + open={state.open} + onOpenChange={(next) => { + if (!next) { + forkDialogStore.close(); + } + }} + ariaLabel={boundarySet ? "Fork from here" : "Fork this chat"} + overlaySource="fork-dialog" + cardClassName="fork-dialog-card" + > + <div className="fork-dialog-body"> + <div className="fork-dialog-title">{boundarySet ? "Fork from here" : "Fork this chat"}</div> + <div className="fork-dialog-body-text"> + {boundarySet + ? "A new side chat continues from this reply. Files are not rewound." + : "A new side chat continues from the latest reply. Files are not rewound."} + </div> + + <div className="harness-choice-heading">Harness</div> + <div className="harness-choice-chips"> + {offered.map((descriptor) => ( + <HarnessChip + key={descriptor.id} + descriptor={descriptor} + selected={selectedHarness === descriptor.id} + onPick={() => forkDialogStore.pickHarness(descriptor.id)} + /> + ))} + {offered.length === 0 && ( + <span className="harness-choice-empty"> + {harnessChipsEmptyText(state.harnesses, "No harnesses are available on this device.")} + </span> + )} + </div> + + <div className="harness-choice-heading">Model</div> + <ModelList + models={dialogRows(modelLoad)} + error={dialogError(modelLoad)} + loading={dialogLoading(modelLoad)} + selectedModel={selectedModel} + onPick={(model) => forkDialogStore.pickModel(model)} + /> + + {crossHarness && ( + <div className="harness-choice-note"> + A different harness starts fresh and receives a size-limited transcript of this chat, + not its full history. + </div> + )} + {state.error !== null && <div className="harness-choice-error">{state.error}</div>} + + <div className="fork-dialog-actions"> + <button type="button" className="btn btn-ghost" onClick={() => forkDialogStore.close()}> + Cancel + </button> + <button + type="button" + className="btn btn-solid" + disabled={state.creating || selectedHarness === null} + onClick={() => forkDialogStore.confirm()} + > + {state.creating ? "Forking…" : "Fork"} + </button> + </div> + </div> + </RbDialogGlass> + ); +}); diff --git a/web/packages/app/src/components/limited-banner.tsx b/web/packages/app/src/components/limited-banner.tsx index 3a24eec6ab..642854bc39 100644 --- a/web/packages/app/src/components/limited-banner.tsx +++ b/web/packages/app/src/components/limited-banner.tsx @@ -1,10 +1,19 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { EngineClient } from "@roboco/engine-client"; -import type { AgentAccount, ResumeAccountCandidates, ScheduledResume, SessionLimit } from "@roboco/proto"; +import type { + AgentAccount, + HarnessDescriptor, + HarnessId, + Model, + ResumeAccountCandidates, + ScheduledResume, + SessionLimit, +} from "@roboco/proto"; import { accountLabel } from "../lib/accounts"; import { cancelScheduledResume, candidateResetCaption, + continueOfferedHarnesses, failoverCardTitle, failoverListOffers, failoverOutcomeNotice, @@ -17,7 +26,18 @@ import { scheduleResumeAtReset, FAILOVER_LIST_TTL_MS, } from "../lib/limited"; +import { + dialogError, + dialogLoading, + dialogRows, + loadDialogHarnesses, + loadDialogModels, + type DialogLoad, +} from "../lib/harness-choice"; +import { switchHarness } from "../lib/harness-switch"; +import type { DraftConfig } from "../lib/composer-actions"; import { AccountUsageMeter } from "./account-usage"; +import { HarnessChip, harnessChipsEmptyText, ModelList } from "./ui/HarnessChoice"; import { PickerCard } from "./ui/PickerCard"; import { MenuHeading, MenuRowNav } from "./ui/MenuRows"; import { SignInAgainCard, useSignInAgainFlow } from "./sign-in-again"; @@ -37,10 +57,12 @@ import { SignInAgainCard, useSignInAgainFlow } from "./sign-in-again"; * - **Resume on another account** (ticket 09) — the failover picker: the * engine-ranked candidates with their usage meters and reset times, the * recommended one first, and the signed-out accounts with a "Sign in - * again" action instead of a switch. - * - * "Continue on another harness" (ticket 25) is ticket 26's web work — the - * desktop's fourth chip lands there, not here. + * again" action instead of a switch; + * - **Continue on another harness** (t3-orchestration-v2 tickets 25/26) — + * the escape hatch spec story 55 asks for: pick any OTHER enabled + * harness and a model; confirming performs ticket 24's one-way switch + * and then sends the same continue prompt, so the new agent picks the + * work up from the budgeted history above the "Switched to" seam. */ export function LimitedBanner({ chatId, @@ -49,6 +71,8 @@ export function LimitedBanner({ scheduled, now, onResume, + currentHarness, + targetDeviceId, }: { /** The chat's id as the page knows it (scoped; routing decodes either form). */ readonly chatId: string; @@ -57,8 +81,19 @@ export function LimitedBanner({ readonly limit: SessionLimit | null; readonly scheduled: ScheduledResume | null; readonly now: number; - /** The composer's fixed-prompt send ("Continue where you left off."). */ - readonly onResume: () => void; + /** + * The composer's fixed-prompt send ("Continue where you left off.") — + * called plainly for Resume, or with the switch's landed picks for + * "Continue on another harness" (ticket 25: the continuation carries the + * NEW harness deterministically, the desktop's row stamp). + */ + readonly onResume: (switched?: Partial<DraftConfig>) => void; + /** The harness the chat runs today (its row config) — never offered by the + * "Continue on another harness" picker (ticket 25). */ + readonly currentHarness: HarnessId | null; + /** The picker's catalog routing: the chat's host device when it differs + * from the connected engine's own (scoped), else null. */ + readonly targetDeviceId: string | null; }) { const label = limitedBannerLabel(limit, scheduled, now); const armable = resumeAtResetArmable(limit, scheduled, now); @@ -258,11 +293,269 @@ export function LimitedBanner({ )} </PickerCard> )} + {/* + CONTINUE ON ANOTHER HARNESS (orchestration v2 tickets 25/26): a + separate action — the escape hatch spec story 55 asks for. A small + picker card lists the enabled harnesses OTHER than the current one + (plus a model pick); confirming performs ticket 24's one-way switch + and then sends the same continue prompt, so the new agent picks the + work up from the budgeted history above the "Switched to" seam. + */} + <ContinueOnHarnessPicker + chatId={chatId} + client={client} + currentHarness={currentHarness} + targetDeviceId={targetDeviceId} + disabled={chipsDisabled} + onNotice={setNotice} + onResume={onResume} + /> {notice !== null && <span className="limited-banner-notice">{notice}</span>} </div> ); } +/** + * The "Continue on another harness" picker (t3-orchestration-v2 tickets + * 25/26) — the web peer of the desktop's `ContinueOnHarnessDialog` + * (`shell/continue_on_harness.rs`), held in the banner's own anchored-card + * idiom (the failover picker's shape): the harness chips (never the + * current one), the selected harness's models, and the Continue action. + * + * Confirming performs ticket 24's one-way switch FIRST — its seam is what + * arms the budgeted history and drops the limited harness's session — so + * the continue prompt is sent only after the RPC succeeds + * (`onResume`, the composer's fixed-prompt path). A failed switch leaves + * the chat untouched: still Limited, still on its old harness, the banner + * still up — the outcome line says why. A selection that moved while the + * switch was in flight never fires the continuation: the banner re-keys + * to the new chat (the props change, not a remount), so the guard compares + * the live chat id against the one the switch started for — the switched + * chat keeps its Limited banner and its own Resume, never sending another + * chat's continuation into it (the desktop's `still_selected` rule). + */ +function ContinueOnHarnessPicker({ + chatId, + client, + currentHarness, + targetDeviceId, + disabled, + onNotice, + onResume, +}: { + readonly chatId: string; + readonly client: EngineClient | null; + readonly currentHarness: HarnessId | null; + readonly targetDeviceId: string | null; + readonly disabled: boolean; + readonly onNotice: (message: string | null) => void; + readonly onResume: (switched?: Partial<DraftConfig>) => void; +}) { + const [open, setOpen] = useState(false); + const [harnesses, setHarnesses] = useState<DialogLoad<HarnessDescriptor>>("idle"); + const [models, setModels] = useState<ReadonlyMap<HarnessId, DialogLoad<Model>>>(new Map()); + const [harness, setHarness] = useState<HarnessId | null>(null); + const [model, setModel] = useState<string | null>(null); + const [switching, setSwitching] = useState(false); + // The freshness guards: the banner re-keys to a new chat through props + // (no remount), so the live chat id retires in-flight loads and the + // confirm's continuation alike; the load epoch retires loads from a + // closed card. + const chatIdRef = useRef(chatId); + chatIdRef.current = chatId; + const loadEpochRef = useRef(0); + + // Opening loads the catalog once per open (the desktop's dialog-local + // `ensure_continue_loads`); reopening retries. A chat change drops the + // picks (the current harness to exclude moved with it). + useEffect(() => { + if (!open) { + return; + } + loadEpochRef.current += 1; + const epoch = loadEpochRef.current; + setHarnesses("loading"); + setModels(new Map()); + setHarness(null); + setModel(null); + if (client === null) { + setHarnesses({ error: "Engine not connected" }); + return; + } + void loadDialogHarnesses(client, targetDeviceId) + .then((rows) => { + if (loadEpochRef.current !== epoch || chatIdRef.current !== chatId) { + return; + } + setHarnesses({ rows }); + // No pick yet: the first OFFERED harness (the current one is + // already excluded from the offered list) becomes the pick once + // the catalog lands — one click confirms (the desktop's default + // resolution). + const first = continueOfferedHarnesses(rows, currentHarness)[0]?.id ?? null; + if (first !== null) { + setHarness(first); + } + }) + .catch((cause: unknown) => { + if (loadEpochRef.current !== epoch || chatIdRef.current !== chatId) { + return; + } + setHarnesses({ error: cause instanceof Error ? cause.message : String(cause) }); + }); + }, [open, client, targetDeviceId, chatId, currentHarness]); + + // The picked harness's model list, loaded once per open per harness. + useEffect(() => { + if (!open || client === null || harness === null) { + return; + } + if (models.get(harness) !== undefined && models.get(harness) !== "idle") { + return; + } + const epoch = loadEpochRef.current; + setModels((current) => new Map(current).set(harness, "loading")); + void loadDialogModels(client, harness, targetDeviceId) + .then((rows) => { + if (loadEpochRef.current !== epoch || chatIdRef.current !== chatId) { + return; + } + setModels((current) => new Map(current).set(harness, { rows })); + }) + .catch((cause: unknown) => { + if (loadEpochRef.current !== epoch || chatIdRef.current !== chatId) { + return; + } + setModels((current) => + new Map(current).set(harness, { error: cause instanceof Error ? cause.message : String(cause) }), + ); + }); + }, [open, client, harness, models, targetDeviceId, chatId]); + + /** Pick the continuation's harness — a pick resets the model (the + * previous pick's model id is meaningless there; its own default + * applies at run time). */ + const pickHarness = (next: HarnessId): void => { + if (harness === next) { + return; + } + setHarness(next); + setModel(null); + }; + + /** Confirm: ticket 24's one-way switch, then the continue prompt — only + * once the engine accepts the switch (the desktop's ordering). The + * continuation carries the switch's landed picks explicitly: the + * chats-watch frame that would re-seed the composer's draft can trail + * the SwitchHarness reply, so the picks ride the resume handle (the + * desktop's `apply_chat_config` stamp). */ + const confirm = (): void => { + if (client === null || harness === null || switching) { + return; + } + const startedChatId = chatId; + const pickedModel = model; + setSwitching(true); + onNotice(null); + void switchHarness(client, chatId, harness, { model }) + .then(() => { + setSwitching(false); + setOpen(false); + // The selection must still be this chat: a moved selection never + // fires the continuation into whatever chat now owns the composer. + if (chatIdRef.current === startedChatId) { + onResume({ + harness, + model: pickedModel, + // The switch reset these to the new harness's defaults — the + // engine stored exactly this shape. + reasoning: null, + modelOptions: {}, + }); + } + }) + .catch((cause: unknown) => { + setSwitching(false); + // The switch never landed: the chat keeps its old harness and its + // Limited state — the continuation is never sent into the same wall. + onNotice( + `Could not switch the harness: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + }); + }; + + const offered = continueOfferedHarnesses(dialogRows(harnesses), currentHarness); + const modelLoad = harness === null ? "idle" : models.get(harness) ?? "idle"; + + return ( + <PickerCard + open={open} + onOpenChange={setOpen} + placement="anchorAboveEnd" + role="dialog" + ariaLabel="Continue on another harness" + width={400} + overlaySource="composer-pickers" + trigger={ + <button + type="button" + className="limited-banner-chip" + title="Switch this chat to another harness and continue there" + disabled={disabled} + > + Continue on another harness + </button> + } + > + <div className="continue-card"> + <MenuHeading>Continue on another harness</MenuHeading> + <div className="continue-card-body"> + This chat hit its usage limit. Switching harnesses is one-way: the chat runs on the one + you pick from here on, and the new agent receives a size-limited transcript of the + conversation, then the continue prompt. + </div> + <div className="harness-choice-heading">Harness</div> + <div className="harness-choice-chips"> + {offered.map((descriptor) => ( + <HarnessChip + key={descriptor.id} + descriptor={descriptor} + selected={harness === descriptor.id} + onPick={() => pickHarness(descriptor.id)} + /> + ))} + {offered.length === 0 && ( + <span className="harness-choice-empty"> + {harnessChipsEmptyText(harnesses, "No other harnesses are available on this device.")} + </span> + )} + </div> + <div className="harness-choice-heading">Model</div> + <ModelList + models={dialogRows(modelLoad)} + error={dialogError(modelLoad)} + loading={dialogLoading(modelLoad)} + selectedModel={model} + onPick={setModel} + /> + <div className="continue-card-actions"> + <button type="button" className="btn btn-ghost" onClick={() => setOpen(false)}> + Cancel + </button> + <button + type="button" + className="btn btn-solid" + disabled={switching || harness === null} + onClick={confirm} + > + {switching ? "Switching…" : "Continue"} + </button> + </div> + </div> + </PickerCard> + ); +} + /** * The failover picker body (shell.rs `failover_card`): the ranked candidate * rows (usage meters with their reset times, the recommended one first), diff --git a/web/packages/app/src/components/surface-registry.tsx b/web/packages/app/src/components/surface-registry.tsx index b86ccae576..5fcaffe05f 100644 --- a/web/packages/app/src/components/surface-registry.tsx +++ b/web/packages/app/src/components/surface-registry.tsx @@ -29,6 +29,8 @@ import { COMPOSER_MAX_WIDTH } from "../lib/composer-flip"; import { sidebarNotice } from "../state/notice"; import { markChatSeen } from "../lib/chat-actions"; import { childChatTitle } from "../lib/explorer-sections"; +import { forkSpecFor } from "../lib/fork-dialog"; +import { forkDialogStore } from "../state/fork-dialog"; import { chatRenameStore } from "../state/chat-rename"; import type { WorkspaceCommandActions } from "./composer"; import { ChangesSurface, ChangesToolbar, CommitDiffToolbar } from "../routes/changes-page"; @@ -41,7 +43,7 @@ import { SurfacePicker } from "./surface-picker"; import { Composer } from "./composer"; import { ChatTranscriptOutlet } from "./chat-transcript-outlet"; import { ContextUsageIndicator, hasWindow } from "./context-usage"; -import { JumpPill, TranscriptView, type JumpButtonState } from "./transcript"; +import { JumpPill, TranscriptView, type ForkFromEntryPayload, type JumpButtonState } from "./transcript"; import type { SubagentOpen } from "./tool-group"; /** @@ -431,6 +433,24 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str const onOpenSubagent = (payload: SubagentOpen): void => { rightPaneStore.addSubagentSurface(chatId, payload); }; + // "Fork from here" (t3-orchestration-v2 tickets 21/22/26): a settled + // reply in THIS side chat's transcript forks the side chat — the copy + // hangs under its PARENT (a sibling, the desktop's `ForkFromEntry` rule) + // and its surface opens in this pane, the pane the side chat itself + // lives in. The surface's row (fleet, seed, or unsaved local) is the + // source; an unsaved one has no replies yet, so nothing renders the + // action until it exists engine-side. + const onForkFromEntry = useCallback( + (payload: ForkFromEntryPayload) => { + if (session === null || effectiveChat === null) { + return; + } + forkDialogStore.open( + forkSpecFor(effectiveChat, chatId, payload.entryId, session.client, session.engine.baseUrl), + ); + }, + [session, effectiveChat, chatId], + ); const onRetryDelivery = (): void => { if (session.client.state !== "connected") { return; @@ -516,6 +536,7 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str indicator={indicator} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onForkFromEntry={onForkFromEntry} deliveryDegraded={deliveryDegraded} /> )} diff --git a/web/packages/app/src/components/transcript.tsx b/web/packages/app/src/components/transcript.tsx index 612f4ecaf0..6d1b2380d5 100644 --- a/web/packages/app/src/components/transcript.tsx +++ b/web/packages/app/src/components/transcript.tsx @@ -17,7 +17,7 @@ import { Icon } from "@roboco/icons"; import { motion } from "@roboco/theme"; import { cubicBezierEval, useBottomClearance } from "../state/layout"; import { DESKTOP_QUERY, useMediaQuery } from "../state/media"; -import type { ContextUsage, FetchToolBlobReply, ScheduledResume, SessionLimit, SessionMessageEntry } from "@roboco/proto"; +import type { ContextUsage, FetchToolBlobReply, HarnessId, ScheduledResume, SessionLimit, SessionMessageEntry } from "@roboco/proto"; import { echoStore, pendingSendStatus, @@ -92,6 +92,7 @@ import { ChatArrivalWindow } from "../lib/chat-arrival"; import { VeilTracker } from "../lib/veil"; import type { ChatIndicator } from "../lib/view"; import type { MessageBadge } from "../lib/badges"; +import type { DraftConfig } from "../lib/composer-actions"; import { MarkdownBlockView, MarkdownSurfaceProvider, CodeBlock, InlineRunView, type MarkdownSurface, type VeilChunk } from "./markdown"; import { MessageBadges } from "./badges"; import { MessageRail } from "./message-rail"; @@ -150,6 +151,7 @@ export const TranscriptView = memo(function TranscriptView({ turnStartedAt = null, store: sharedStore = null, onOpenSubagent, + onForkFromEntry, markdownSurface = null, deliveryDegraded = false, }: { @@ -199,6 +201,16 @@ export const TranscriptView = memo(function TranscriptView({ * (an unbound host can't open tabs). */ onOpenSubagent?: (payload: SubagentOpen) => void; + /** + * "Fork from here" (t3-orchestration-v2 ticket 21): the hover action on + * a settled assistant reply — completed, errored or interrupted — opens + * the fork dialog at exactly that reply. `chatId` is the doc the reply + * lives in; `entryId` is the boundary the fork copies up to, + * inclusive. Default: absent — the subagent pane's read-only view has no + * chat row to fork from, so its rows render without the action (the + * desktop's `doc_override` gate). + */ + onForkFromEntry?: (payload: ForkFromEntryPayload) => void; /** * The markdown host hooks (`RenderOptions` on the desktop): the chat's * workspace root and the internal-link action. Null (default, the subagent @@ -246,6 +258,7 @@ export const TranscriptView = memo(function TranscriptView({ indicator={indicator} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onForkFromEntry={onForkFromEntry} markdownSurface={markdownSurface} deliveryDegraded={deliveryDegraded} /> @@ -263,6 +276,7 @@ function TranscriptSurface({ indicator, turnStartedAt, onOpenSubagent, + onForkFromEntry, markdownSurface, deliveryDegraded, }: { @@ -276,6 +290,7 @@ function TranscriptSurface({ indicator: ChatIndicator | null; turnStartedAt: number | null; onOpenSubagent?: (payload: SubagentOpen) => void; + onForkFromEntry?: (payload: ForkFromEntryPayload) => void; markdownSurface: MarkdownSurface | null; deliveryDegraded: boolean; }) { @@ -556,6 +571,7 @@ function TranscriptSurface({ trailer={trailerState} onJumpChange={onJumpChange} onOpenSubagent={onOpenSubagent} + onForkFromEntry={onForkFromEntry} toolMotion={toolMotion} chatArrival={chatArrival} /> @@ -625,6 +641,8 @@ interface ScrollerProps { readonly onJumpChange?: (state: JumpButtonState) => void; /** A spawn chip's open — the host registers the right-pane tab. */ readonly onOpenSubagent?: (payload: SubagentOpen) => void; + /** A settled assistant reply's "Fork from here" — the host opens the fork dialog. */ + readonly onForkFromEntry?: (payload: ForkFromEntryPayload) => void; /** The surface's tool-group motion store (folds, reveals, blob fetches). */ readonly toolMotion: ToolGroupMotionStore; /** The surface's chat-switch arrival window (ticket 58's ONE predicate). */ @@ -666,6 +684,7 @@ function TranscriptScroller({ trailer, onJumpChange, onOpenSubagent, + onForkFromEntry, toolMotion, chatArrival, }: ScrollerProps) { @@ -1809,6 +1828,7 @@ function TranscriptScroller({ row={row} hovered={hoveredEntry?.entryId === row.entryId} onOpenSubagent={onOpenSubagent} + onForkFromEntry={onForkFromEntry} client={client} deviceId={deviceId} docId={docId} @@ -2097,6 +2117,7 @@ function RowContent({ row, hovered, onOpenSubagent, + onForkFromEntry, client, deviceId, docId, @@ -2112,6 +2133,8 @@ function RowContent({ row: TranscriptRow; hovered: boolean; onOpenSubagent?: (payload: SubagentOpen) => void; + /** The row's "Fork from here" open (absent = no action renders). */ + onForkFromEntry?: (payload: ForkFromEntryPayload) => void; client: EngineClient; deviceId: string | null; docId: string; @@ -2166,13 +2189,49 @@ function RowContent({ {kind.kind === "inputChip" && <InputChipRow header={kind.header} resolved={kind.resolved} />} {kind.kind === "errorChip" && <ErrorChipRow message={kind.message} />} {kind.kind === "forkMarker" && <ForkMarkerRow sourceTitle={kind.sourceTitle} />} - {row.timestamp !== null && <RowMeta row={row} visible={hovered} isUserRow={kind.kind === "user"} />} + {kind.kind === "harnessSwitchMarker" && <HarnessSwitchMarkerRow label={kind.label} />} + {row.timestamp !== null && ( + <RowMeta + row={row} + visible={hovered} + isUserRow={kind.kind === "user"} + docId={docId} + onForkFromEntry={onForkFromEntry} + /> + )} </> ); } -/** The hover strip under a settled entry's last row: timestamp + copy. */ -function RowMeta({ row, visible, isUserRow }: { row: TranscriptRow; visible: boolean; isUserRow: boolean }) { +/** + * "Fork from here" (t3-orchestration-v2 ticket 21, the desktop's + * `TranscriptEvent::ForkFromEntry`): the hover action's payload — `chatId` + * is the doc the reply lives in; `entryId` is the boundary the fork copies + * up to, inclusive. + */ +export interface ForkFromEntryPayload { + readonly chatId: string; + readonly entryId: string; +} + +/** + * The hover strip under a settled entry's last row: timestamp + copy + + * (ticket 21) "Fork from here" on assistant replies. + */ +function RowMeta({ + row, + visible, + isUserRow, + docId, + onForkFromEntry, +}: { + row: TranscriptRow; + visible: boolean; + isUserRow: boolean; + /** The doc the row's entry lives in — the fork's source chat id. */ + docId: string; + onForkFromEntry?: (payload: ForkFromEntryPayload) => void; +}) { const [copied, setCopied] = useState(false); const copy = (): void => { const text = row.copyText; @@ -2185,6 +2244,16 @@ function RowMeta({ row, visible, isUserRow }: { row: TranscriptRow; visible: boo setTimeout(() => setCopied(false), COPIED_CLEAR_MS); }); }; + // "Fork from here" (t3-orchestration-v2 ticket 21): every settled + // assistant reply — errored and interrupted ones included — offers a + // fork at exactly that reply, beside the copy action. A host that + // passed no callback (the subagent pane's read-only view) renders no + // button; the tooltip says what the fork does NOT do: files are not + // rewound. + const fork = + row.forkEntry != null && onForkFromEntry !== undefined + ? { entryId: row.forkEntry, open: onForkFromEntry } + : null; return ( // A RESERVED lane (always occupies its 32px) so the virtualizer never // shifts; only the contents' visibility flips. @@ -2205,6 +2274,20 @@ function RowMeta({ row, visible, isUserRow }: { row: TranscriptRow; visible: boo <Icon name={copied ? "check" : "copy"} size={14} /> </button> )} + {fork !== null && ( + <button + type="button" + className="row-meta-copy row-meta-fork" + onClick={(event) => { + event.stopPropagation(); + fork.open({ chatId: docId, entryId: fork.entryId }); + }} + aria-label="Fork from here" + title="Fork from here — a new side chat continues from this reply. Files are not rewound." + > + <Icon name="gitBranch" size={14} /> + </button> + )} </div> </div> ); @@ -2677,6 +2760,25 @@ function ForkMarkerRow({ sourceTitle }: { sourceTitle: string }) { ); } +/** + * A quiet harness-switch seam (t3-orchestration-v2 ticket 24, transcript.rs + * `harness_switch_marker`): the same divider treatment as the fork seam, one + * line — the label arrives pre-resolved ("Switched to Codex", the row model + * resolved it at build time) so nothing here needs a catalog. No message + * metadata lane. + */ +function HarnessSwitchMarkerRow({ label }: { label: string }) { + return ( + <div className="harness-switch-marker" role="separator" aria-label={label}> + <span className="fork-marker-rule" aria-hidden /> + <span className="harness-switch-marker-label" title={label}> + {label} + </span> + <span className="fork-marker-rule" aria-hidden /> + </div> + ); +} + // ── Shell chrome published from the transcript surface ────────────────────── /** @@ -2733,6 +2835,8 @@ export function StatusStrip({ scheduled={limited.scheduled} now={limited.now} onResume={limited.onResume} + currentHarness={limited.currentHarness} + targetDeviceId={limited.targetDeviceId} /> ) : status === "errored" ? ( <span className="status-strip-error">Run failed</span> @@ -2758,5 +2862,22 @@ export interface LimitedStripProps { readonly limit: SessionLimit | null; readonly scheduled: ScheduledResume | null; readonly now: number; - readonly onResume: () => void; + /** + * The composer's fixed-prompt send — plainly for Resume, or with the + * switch's landed picks for "Continue on another harness" (ticket 25). + */ + readonly onResume: (switched?: Partial<DraftConfig>) => void; + /** + * The harness the chat runs today (its row config) — the one the + * "Continue on another harness" picker (ticket 26) never offers: the + * continuation is a switch to ANOTHER harness, never a re-pick of the + * one that hit the limit. + */ + readonly currentHarness: HarnessId | null; + /** + * The catalog routing for that picker's loads: the chat's host device + * when it differs from the connected engine's own (scoped ids), else + * null — the chat page computes it, the picker's dialogs' rule. + */ + readonly targetDeviceId: string | null; } diff --git a/web/packages/app/src/components/ui/HarnessChoice.tsx b/web/packages/app/src/components/ui/HarnessChoice.tsx new file mode 100644 index 0000000000..747a3d12b4 --- /dev/null +++ b/web/packages/app/src/components/ui/HarnessChoice.tsx @@ -0,0 +1,120 @@ +import { harnessBrandIcon, Icon } from "@roboco/icons"; +import type { HarnessDescriptor, Model } from "@roboco/proto"; +import { dialogError, dialogLoading, type DialogLoad } from "../../lib/harness-choice"; + +/** + * The shared harness/model choice rows (t3-orchestration-v2 tickets 21/22/ + * 25) — the `ui/` composites both choice surfaces render: the fork dialog's + * modal body and the Limited banner's "Continue on another harness" card. + * Data + callbacks in, DOM out; no engine state (the components/README + * `ui/` rule). The desktop keeps the same row shapes in both dialogs + * (`shell/fork_dialog.rs` / `shell/continue_on_harness.rs`). + */ + +/** One offered harness's chip — the desktop dialogs' chip treatment. */ +export function HarnessChip({ + descriptor, + selected, + onPick, +}: { + readonly descriptor: HarnessDescriptor; + readonly selected: boolean; + readonly onPick: () => void; +}) { + const brand = harnessBrandIcon(descriptor.id); + return ( + <button + type="button" + className="harness-choice-chip" + data-selected={selected ? "1" : undefined} + onClick={onPick} + > + <Icon + name={brand.name} + size={13} + style={{ color: brand.tint ?? (selected ? "var(--rb-text)" : "var(--rb-text-muted)") }} + /> + <span className="harness-choice-chip-label">{descriptor.name}</span> + </button> + ); +} + +/** + * The selected harness's model list — the highlighted row is the pick, or + * the harness's default row (first) when nothing is picked (a null model + * means "the default at run time"). + */ +export function ModelList({ + models, + error, + loading, + selectedModel, + onPick, +}: { + readonly models: readonly Model[]; + readonly error: string | null; + readonly loading: boolean; + readonly selectedModel: string | null; + readonly onPick: (modelId: string) => void; +}) { + if (models.length === 0) { + return ( + <div className="harness-choice-empty harness-choice-models-empty"> + {modelListEmptyText(error, loading)} + </div> + ); + } + return ( + <div className="harness-choice-models"> + {models.map((model, ix) => { + const selected = selectedModel === model.id || (selectedModel === null && ix === 0); + const defaultRow = selectedModel === null && ix === 0; + const description = model.description?.trim() ?? ""; + return ( + <button + key={model.id} + type="button" + className="harness-choice-model-row" + data-selected={selected ? "1" : undefined} + onClick={() => onPick(model.id)} + > + <span className="harness-choice-model-main"> + <span className="harness-choice-model-label">{model.label}</span> + {defaultRow && <span className="harness-choice-model-default">Default</span>} + </span> + {description.length > 0 && ( + <span className="harness-choice-model-sub">{description}</span> + )} + </button> + ); + })} + </div> + ); +} + +/** + * The models list's empty text (the desktop dialog's arms): the load's + * failure, its in-flight word, or the honest "lists none". Pure. + */ +export function modelListEmptyText(error: string | null, loading: boolean): string { + return error !== null + ? `Couldn't load the models: ${error}` + : loading + ? "Loading models…" + : "This harness lists no models."; +} + +/** + * The harness chips row's empty text — the same three arms, with the + * caller's "none offered" word (the fork dialog says "No harnesses are + * available on this device.", the Continue card "No other harnesses are + * available on this device."). Pure. + */ +export function harnessChipsEmptyText(load: DialogLoad<unknown>, none: string): string { + const error = dialogError(load); + return error !== null + ? `Couldn't load the harnesses: ${error}` + : dialogLoading(load) + ? "Loading this device's harnesses…" + : none; +} diff --git a/web/packages/app/src/lib/composer-draft.ts b/web/packages/app/src/lib/composer-draft.ts index c8cd870762..b3900f1a88 100644 --- a/web/packages/app/src/lib/composer-draft.ts +++ b/web/packages/app/src/lib/composer-draft.ts @@ -143,13 +143,19 @@ export function draftFromChat( * a new side chat whose first send has not minted it yet * (`side_chat_harness_editable`, state.rs, upstream #590): its inherited * config must not pin the picked harness while the draft is still the - * only config carrier. + * only config carrier — and EXCEPT an existing chat that sits between + * turns (t3-orchestration-v2 ticket 24): its picker unlocks for a ONE-WAY + * switch, so `harnessSwitchable` (the caller's `harness_switchable` — + * `harnessSwitchable()` in lib/harness-switch.ts) re-opens the facet + * while the chat is idle, and a pick becomes the switch instead of a + * draft change. */ export function isHarnessLocked( chatConfig: ChatConfig | null, sideChatHarnessEditable = false, + harnessSwitchable = false, ): boolean { - return chatConfig !== null && !sideChatHarnessEditable; + return chatConfig !== null && !sideChatHarnessEditable && !harnessSwitchable; } /** Two drafts differ when any user-facing field changed. */ @@ -157,6 +163,30 @@ export function draftsEqual(a: DraftConfig, b: DraftConfig): boolean { return JSON.stringify(buildChatConfig(a)) === JSON.stringify(buildChatConfig(b)); } +/** + * The draft a one-way harness switch lands (t3-orchestration-v2 ticket 24, + * the desktop pickers' `default_picks` + the kept sandbox): the new + * harness's remembered defaults — model, reasoning, options — reset to + * "the new harness's defaults unless the user chose them", with the + * caller's explicit model (a pick that rode the switch itself, like a + * foreign model row) overriding the remembered one. The sandbox stays the + * chat's own pick: the switch never owns it. Pure over the sticky-picks + * store, like every remembered* helper here. + */ +export function switchHarnessDraft( + chatConfig: ChatConfig, + harness: HarnessId, + model: string | null = rememberedModelFor(harness)?.id ?? null, +): DraftConfig { + return { + harness, + model, + reasoning: rememberedReasoningFor(harness, model), + sandbox: chatConfig.sandbox, + modelOptions: model === null ? {} : rememberedModelOptions(harness, model), + }; +} + // --------------------------------------------------------------------------- // Sticky picks — the web peer of `settings/composer.rs::ComposerDefaults` // --------------------------------------------------------------------------- diff --git a/web/packages/app/src/lib/fork-dialog.ts b/web/packages/app/src/lib/fork-dialog.ts new file mode 100644 index 0000000000..6d5b664b2a --- /dev/null +++ b/web/packages/app/src/lib/fork-dialog.ts @@ -0,0 +1,109 @@ +import type { EngineClient } from "@roboco/engine-client"; +import type { Chat, HarnessDescriptor, HarnessId } from "@roboco/proto"; +import { offeredHarnesses } from "./model-rows"; + +/** + * The fork dialog's pure model — the web peer of the desktop + * `crates/ui/src/shell/fork_dialog.rs`'s free functions (tickets 21+22): + * the offered harness set, the confirm-time override projection, and the + * spec every entry point hands the dialog (state/fork-dialog.ts owns the + * state machine; components/fork-dialog.tsx renders it). + * + * Both fork entry points — the files panel's classic "Fork this chat" + * button and the transcript's "Fork from here" hover action — open the + * dialog with a spec; confirming performs the fork with only the picks + * that differ from the source's own, so untouched defaults fork exactly + * as the classic button always did. + */ + +/** + * The harnesses the fork dialog offers (fork_dialog.rs + * `fork_offered_harnesses`): the composer's offered set — installed and + * enabled on the listing device — plus the `fallback` harness (the + * source's own, or the current pick) whenever the catalog carries it: a + * chat can outlive its harness's enablement, and the default must stay + * pickable so "fork as-is" is always one click. + */ +export function forkOfferedHarnesses( + catalog: readonly HarnessDescriptor[], + fallback: HarnessId | null, +): HarnessDescriptor[] { + const offered = offeredHarnesses(catalog); + if (fallback === null || offered.some((descriptor) => descriptor.id === fallback)) { + return offered; + } + const row = catalog.find((descriptor) => descriptor.id === fallback); + return row === undefined ? offered : [...offered, row]; +} + +/** + * The overrides a dialog confirm sends (fork_dialog.rs + * `confirm_overrides`, ticket 22): only the picks that differ from the + * source's own. A confirm of the untouched defaults is therefore + * byte-for-byte the classic fork request; a model pick rides along + * whenever the harness does (a different harness needs the model spelled + * out, even an id that happens to match). Blank strings are absent picks. + */ +export function forkConfirmOverrides( + source: Chat, + harness: HarnessId | null, + model: string | null, +): { readonly harness: HarnessId | null; readonly model: string | null } { + const sourceHarness = source.config?.harness ?? null; + const sourceModel = source.config?.model ?? null; + const overrideHarness = harness !== null && harness !== sourceHarness ? harness : null; + const trimmed = model === null ? "" : model.trim(); + const overrideModel = + trimmed.length > 0 && (overrideHarness !== null || sourceModel !== trimmed) ? trimmed : null; + return { harness: overrideHarness, model: overrideModel }; +} + +/** + * What a fork dialog spec carries — everything the state machine and the + * confirm need, resolved once at the entry point: + * + * - `source` — the chat being forked (the fleet row the RPC copies); + * - `parentId` — where the copy hangs: the source, or the source's parent + * when a side chat forks itself (the copy lists as a sibling, the + * desktop's `ForkFromEntry` rule); + * - `boundary` — ticket 21's chosen assistant entry id (the copy stops at + * it, inclusive), null for the classic latest-reply fork; + * - `paneChatId` — the right-pane owner the fork's surface opens in; + * - `client` / `engineBaseUrl` — the source's engine client and its + * baseUrl (the reply row is the ENGINE's raw id; the surface and the + * fleet rows speak the scoped form). + */ +export interface ForkDialogSpec { + readonly source: Chat; + readonly parentId: string; + readonly boundary: string | null; + readonly paneChatId: string; + readonly client: EngineClient; + readonly engineBaseUrl: string; +} + +/** + * Build a spec at a fork entry point — the one home of the sibling rule + * (the desktop's `ForkFromEntry` handler): the copy hangs under the + * source, or under the source's PARENT when a side chat forks itself, so + * the copy lists as a sibling. `paneChatId` is the right-pane owner the + * fork's surface opens in; `client`/`engineBaseUrl` are the source's + * engine session (the reply row is the ENGINE's raw id; the surface and + * the fleet rows speak the scoped form). Pure. + */ +export function forkSpecFor( + source: Chat, + paneChatId: string, + boundary: string | null, + client: EngineClient, + engineBaseUrl: string, +): ForkDialogSpec { + return { + source, + parentId: source.parentChatId ?? source.id, + boundary, + paneChatId, + client, + engineBaseUrl, + }; +} diff --git a/web/packages/app/src/lib/harness-choice.ts b/web/packages/app/src/lib/harness-choice.ts new file mode 100644 index 0000000000..7377fd9607 --- /dev/null +++ b/web/packages/app/src/lib/harness-choice.ts @@ -0,0 +1,100 @@ +import { methods } from "@roboco/engine-client"; +import type { HarnessDescriptor, HarnessId, Model } from "@roboco/proto"; + +/** + * Harness/model choice for the dialog-shaped picks — the web peer of the + * desktop's two orchestration-v2 dialogs (`shell/fork_dialog.rs`, tickets + * 21+22, and `shell/continue_on_harness.rs`, ticket 25): each loads the + * SOURCE DEVICE's harness catalog once per dialog and one model list per + * picked harness, with a stale-response guard keyed to the dialog instance. + * + * This module is the half both dialogs share — the loadable shape, the + * catalog/model RPCs, and the routing rule for which device's catalog + * matters. The per-flow pure helpers live beside their owners + * (`lib/fork-dialog.ts`, `lib/limited.ts`), exactly like the desktop keeps + * `fork_offered_harnesses` and `continue_offered_harnesses` apart. + */ + +/** + * One dialog slot's load state — the desktop dialogs' `Loadable<Vec<_>>` + * as a tagged union: idle until kicked, loading while in flight, the rows + * or the error once it lands. + */ +export type DialogLoad<T> = "idle" | "loading" | { readonly rows: readonly T[] } | { readonly error: string }; + +/** The rows a load currently shows (empty while idle/loading/errored). */ +export function dialogRows<T>(load: DialogLoad<T>): readonly T[] { + return typeof load === "object" && "rows" in load ? load.rows : []; +} + +/** The error a load latched, when it did. */ +export function dialogError(load: DialogLoad<unknown>): string | null { + return typeof load === "object" && "error" in load ? load.error : null; +} + +/** True while a slot's first load is still in flight (no rows yet). */ +export function dialogLoading(load: DialogLoad<unknown>): boolean { + return load === "loading"; +} + +/** + * The catalog load's `targetDeviceId` (pickers' `fork_catalog_target` / + * `continue_catalog_target`): the harness catalogs must come from the + * device that RUNS the chat's agents — its CLIs are the ones that matter — + * so a chat hosted on another device names it; a locally-hosted chat rides + * nothing (the connected engine serves it directly). Both arguments are + * the fleet's SCOPED device ids; null own means the engine never published + * its device, so the chat's device is the honest target. + */ +export function catalogTargetDeviceId( + ownScopedDeviceId: string | null, + chatDeviceId: string | null, +): string | null { + if (chatDeviceId === null) { + return null; + } + return ownScopedDeviceId === null || ownScopedDeviceId !== chatDeviceId ? chatDeviceId : null; +} + +/** + * The caller shape the loads need — `EngineClient` satisfies it, and the + * tests drive a fake (the `lib/limited.ts` wrapper idiom). + */ +export interface HarnessChoiceCaller { + call<T>(method: string, params?: unknown): Promise<T>; +} + +/** + * One dialog's harness catalog: `ListHarnesses` against the chat's host + * device. Short-lived by design — loaded once per dialog, no refresh or + * retry (reopening the dialog retries), mirroring the desktop's + * `ensure_fork_harnesses` / `ensure_continue_harnesses`. + */ +export async function loadDialogHarnesses( + caller: HarnessChoiceCaller, + targetDeviceId: string | null, +): Promise<HarnessDescriptor[]> { + const rows = await caller.call<HarnessDescriptor[]>( + methods.LIST_HARNESSES, + targetDeviceId === null ? {} : { targetDeviceId }, + ); + return Array.isArray(rows) ? rows : []; +} + +/** + * One harness's model list: `ListModels` against the same device, loaded + * once per dialog per harness (`ensure_fork_models` / + * `ensure_continue_models`). + */ +export async function loadDialogModels( + caller: HarnessChoiceCaller, + harness: HarnessId, + targetDeviceId: string | null, +): Promise<Model[]> { + const rows = await caller.call<Model[]>(methods.LIST_MODELS, { + harness, + ...(targetDeviceId !== null ? { targetDeviceId } : {}), + }); + return Array.isArray(rows) ? rows : []; +} + diff --git a/web/packages/app/src/lib/harness-switch.ts b/web/packages/app/src/lib/harness-switch.ts new file mode 100644 index 0000000000..dd6ebdb6c3 --- /dev/null +++ b/web/packages/app/src/lib/harness-switch.ts @@ -0,0 +1,94 @@ +import { methods } from "@roboco/engine-client"; +import type { HarnessId, ReasoningLevel } from "@roboco/proto"; + +/** + * The one-way harness switch on the wire (t3-orchestration-v2 ticket 24) + * — the web peer of the desktop's `Pickers::switch_chat_harness` RPC half + * plus ticket 25's `continue_on_harness.rs` params builder. The ENGINE + * owns every rule (ADR 0006/0007): the SwitchHarness RPC writes the + * "Switched to <harness>" seam into the transcript, drops the chat's + * harness session, and saves the arrived picks; the sandbox is the one + * field the switch never owns — the engine reads it from the chat's row. + * + * Both web callers — the composer's unlocked harness pick (the desktop + * picker's flow) and the Limited banner's "Continue on another harness" + * (ticket 25, which switches first and then sends the continue prompt) — + * go through this one wrapper. + */ + +/** + * The caller shape the switch needs — `EngineClient` satisfies it, and + * the tests drive a fake (the `lib/limited.ts` wrapper idiom). + */ +export interface HarnessSwitchCaller { + call<T>(method: string, params?: unknown): Promise<T>; +} + +/** The picks a switch carries: only what the caller resolved. */ +export interface HarnessSwitchPicks { + /** The model the chat runs on after the switch; null = the harness's own default at run time. */ + readonly model?: string | null; + /** + * The reasoning level after the switch; null = the harness's default. + * "Model options reset to the new harness's defaults unless the user + * chose them" — a switch only carries what was explicitly resolved. + */ + readonly reasoning?: ReasoningLevel | null; + /** Per-model option picks, keyed by option id (the switch's own writes). */ + readonly modelOptions?: Readonly<Record<string, unknown>>; +} + +/** + * The SwitchHarness params (the desktop's `switch_chat_harness` payload, + * and `continue_on_harness.rs`'s `switch_params` shape): the chat, the + * chosen harness, and only the picks that were resolved — absent keys are + * the engine's "default" arms. The sandbox never rides: the RPC keeps the + * chat's own pick. Pure. + */ +export function switchHarnessParams( + chatId: string, + harness: HarnessId, + picks: HarnessSwitchPicks = {}, +): Record<string, unknown> { + const params: Record<string, unknown> = { chatId, harness }; + const model = picks.model?.trim() ?? ""; + if (model.length > 0) { + params.model = model; + } + if (picks.reasoning != null) { + params.reasoning = picks.reasoning; + } + if (picks.modelOptions !== undefined && Object.keys(picks.modelOptions).length > 0) { + params.modelOptions = { ...picks.modelOptions }; + } + return params; +} + +/** + * Perform the switch — `SwitchHarness` on the chat's host device (the + * client's request routing forwards it; the engine refuses a chat hosted + * elsewhere). The reply is empty: the engine's chats watch carries the + * row's new config, and the transcript stream carries the seam. A + * failure surfaces to the caller as a rejected promise (the composer's + * failure notice / the banner's outcome line). + */ +export async function switchHarness( + caller: HarnessSwitchCaller, + chatId: string, + harness: HarnessId, + picks: HarnessSwitchPicks = {}, +): Promise<void> { + await caller.call(methods.SWITCH_HARNESS, switchHarnessParams(chatId, harness, picks)); +} + +/** + * Whether an existing chat's harness picker is unlocked for a one-way + * switch (the desktop's `state.harness_switchable`): the chat exists, no + * send of its own is still awaiting confirmation, and no turn is in + * flight — Working or AwaitingInput both count as live. Pure over the + * composer's own inputs. + */ +export function harnessSwitchable(chatExists: boolean, busy: boolean, runLive: boolean): boolean { + return chatExists && !busy && !runLive; +} + diff --git a/web/packages/app/src/lib/limited.ts b/web/packages/app/src/lib/limited.ts index 4795d31568..6e801c83ee 100644 --- a/web/packages/app/src/lib/limited.ts +++ b/web/packages/app/src/lib/limited.ts @@ -1,12 +1,14 @@ import { methods } from "@roboco/engine-client"; import type { AgentAccount, + HarnessDescriptor, HarnessId, ResumeAccountCandidates, ResumeOnAccountOutcome, ScheduledResume, SessionLimit, } from "@roboco/proto"; +import { offeredHarnesses } from "./model-rows"; import { formatLimitReset, formatScheduledResume } from "./view"; /** @@ -46,6 +48,21 @@ export function failoverCardTitle(harness: HarnessId): string { : "Resume on another Claude account"; } +/** + * The harnesses the "Continue on another harness" picker offers + * (t3-orchestration-v2 ticket 25, the desktop's + * `continue_offered_harnesses`): the same offered set the composer's + * picker uses (installed and enabled on the listing device), minus the + * chat's CURRENT harness — a Limited chat escapes its limit by switching + * to ANOTHER harness, never by re-picking the one that hit it. Pure. + */ +export function continueOfferedHarnesses( + catalog: readonly HarnessDescriptor[], + current: HarnessId | null, +): HarnessDescriptor[] { + return offeredHarnesses(catalog).filter((descriptor) => descriptor.id !== current); +} + /** * The Limited banner's label — the window and its reset ("5-hour limit · * resets in 45m"), or the armed continuation's fire time instead diff --git a/web/packages/app/src/lib/side-chat-actions.ts b/web/packages/app/src/lib/side-chat-actions.ts index 16a00fd4be..296ca3ecfd 100644 --- a/web/packages/app/src/lib/side-chat-actions.ts +++ b/web/packages/app/src/lib/side-chat-actions.ts @@ -1,5 +1,5 @@ import { methods } from "@roboco/engine-client"; -import type { Chat, ChatConfig } from "@roboco/proto"; +import type { Chat, ChatConfig, HarnessId } from "@roboco/proto"; import { createChat, type MutateCaller } from "./chat-actions"; /** @@ -28,10 +28,20 @@ import { createChat, type MutateCaller } from "./chat-actions"; /** * `FORK_SIDE_CHAT` params — `{chatId, sourceChatId, parentChatId?, - * targetDeviceId?}`. The engine defaults `parentChatId` to the source; a - * side chat's own fork button passes the side chat's parent so the copy - * lists as a sibling. `targetDeviceId` rides for request routing like the - * desktop's send (the engine itself checks the source lives on it). + * boundary?, harness?, model?, targetDeviceId?}`. The engine defaults + * `parentChatId` to the source; a side chat's own fork button passes the + * side chat's parent so the copy lists as a sibling. `targetDeviceId` + * rides for request routing like the desktop's send (the engine itself + * checks the source lives on it). + * + * The t3-orchestration-v2 additions (tickets 21+22): `boundary` picks the + * settled assistant entry the copy stops at — any of them, errored and + * interrupted included, inclusive — where absent keeps the classic + * latest-complete-reply fork; `harness`/`model` override what the copy + * runs on. Each rides only when chosen (trimmed non-blank), so an engine + * predating the additions still reads its classic fork — the fork dialog + * (state/fork-dialog.ts) sends exactly the picks that differ from the + * source's own. */ export interface ForkSideChatParams { /** The minted id of the fork — idempotent by this key. */ @@ -40,25 +50,51 @@ export interface ForkSideChatParams { readonly sourceChatId: string; /** Where the fork hangs; defaults to the source (the engine's rule). */ readonly parentChatId?: string; + /** + * Ticket 21, "Fork from here": the settled assistant entry the copy + * stops at, inclusive — its provider turn ref becomes the native fork + * boundary. Absent = the latest complete reply (the classic fork). + */ + readonly boundary?: string; + /** + * Ticket 22: the harness the copy runs on instead of the source's (its + * wire name, "codex", "claude-code", …). A cross-harness pick never + * records the native-fork intent — its first send carries the budgeted + * history instead. + */ + readonly harness?: HarnessId; + /** + * Ticket 22: the model the copy runs on. Without a harness change it + * replaces only the model; into another harness it is all the copy gets + * (the old ids are meaningless there). + */ + readonly model?: string; /** The source's host device — forwarded-routing metadata, like the Rust. */ readonly targetDeviceId?: string; } /** - * Fork `sourceChatId` through its latest completed response into a new chat - * under `parentChatId`, and get the fresh `Chat` back. The engine rejects - * with "Wait for a completed response before starting a side chat" when the - * source has no completed turn yet — callers surface that verbatim (the - * desktop shows it in the composer). + * Fork `sourceChatId` through its latest completed response — or through + * `boundary`'s settled assistant reply when given (ticket 21) — into a new + * chat under `parentChatId`, on the source's own harness/model or the + * overrides (ticket 22), and get the fresh `Chat` back. The engine rejects + * with "Wait for a completed response before starting a side chat" when + * the source has no completed turn yet — callers surface that verbatim + * (the desktop shows it in the composer). */ export async function forkSideChat( caller: MutateCaller, params: ForkSideChatParams, ): Promise<Chat> { + const boundary = params.boundary?.trim() ?? ""; + const model = params.model?.trim() ?? ""; return caller.call<Chat>(methods.FORK_SIDE_CHAT, { chatId: params.chatId, sourceChatId: params.sourceChatId, ...(params.parentChatId !== undefined ? { parentChatId: params.parentChatId } : {}), + ...(boundary.length > 0 ? { boundary } : {}), + ...(params.harness !== undefined && params.harness !== null ? { harness: params.harness } : {}), + ...(model.length > 0 ? { model } : {}), ...(params.targetDeviceId !== undefined ? { targetDeviceId: params.targetDeviceId } : {}), }); } diff --git a/web/packages/app/src/lib/transcript.ts b/web/packages/app/src/lib/transcript.ts index b46266df5e..3acb56925c 100644 --- a/web/packages/app/src/lib/transcript.ts +++ b/web/packages/app/src/lib/transcript.ts @@ -10,6 +10,7 @@ */ import type { + HarnessId, MessagePart, SessionMessageEntry, ToolCall, @@ -19,6 +20,7 @@ import type { } from "@roboco/proto"; import type { IconName } from "@roboco/icons"; import { layout } from "@roboco/theme"; +import { providerName } from "./accounts"; import { bodyHeight, DIFF_LINE_HEIGHT, truncateFileLines, type FileDiff } from "./diff"; import { blockFlatText, parseMarkdown, type Block, type BlockTree, type InlineRun, type InlineStyle } from "./markdown"; import { parseUserMessageImages, type UserImageAttachment } from "./attachments"; @@ -1525,7 +1527,16 @@ export type TranscriptRowKind = * source title is stamped on the part at fork time, so a later rename or * delete of the source never rewrites the seam. */ - | { readonly kind: "forkMarker"; readonly sourceChatId: string; readonly sourceTitle: string }; + | { readonly kind: "forkMarker"; readonly sourceChatId: string; readonly sourceTitle: string } + /** + * The harness-switch seam (`RowKind::HarnessSwitchMarker`, transcript.rs + * :1050-1056, t3-orchestration-v2 ticket 24): a labeled divider under + * which the chat runs on the harness the switch named; everything above + * it belonged to the dropped provider session. The label arrives + * pre-resolved ("Switched to Codex") so nothing rendering it needs a + * catalog. + */ + | { readonly kind: "harnessSwitchMarker"; readonly label: string }; /** A transcript row: stable id + content version (diff key) + block payload. */ export interface TranscriptRow { @@ -1540,6 +1551,15 @@ export interface TranscriptRow { readonly timestamp: number | null; /** Text copied by the entry-level hover action (settled last row only). */ readonly copyText: string | null; + /** + * "Fork from here" (t3-orchestration-v2 ticket 21): the entry id a + * settled ASSISTANT reply's hover strip offers to fork at. Present on + * the same row as `copyText` — every settled assistant reply, errored + * and interrupted ones included — never on a user row, a seam row, or a + * reply still streaming. Absent = no fork action (the subagent pane + * renders the same rows with no host to fork from). + */ + readonly forkEntry?: string; /** Hidden while the named compact-work fold is closed. */ readonly compactFold: string | null; } @@ -1855,7 +1875,29 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): copyText: null, compactFold: null, }); + return; + } + if (part.kind === "harnessSwitch") { + // The one-way harness switch's seam (t3-orchestration-v2 ticket 24, + // transcript.rs:1621-1641): the divider line, resolved once at row + // build — the seam is fixed, so the row's version only moves when + // the label does. + const label = switchedToLabel(part.harness); + rows.push({ + id: `${entry.id}#${part.id}`, + version: fnv1a(label), + turnStart: false, + rowKind: { kind: "harnessSwitchMarker", label }, + entryId: entry.id, + timestamp: null, + copyText: null, + compactFold: null, + }); + return; } + // Generated images and the engine's completion-notice markers render no + // row here (the notice keeps its plain-text sibling part for older + // layouts); they persist in the doc and stay inert in this model. }); if (compact) { // Collapsed: one work header. Expanded: the same rows compact-off would @@ -1880,6 +1922,11 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): turnStart: false, timestamp: null, copyText: null, + // The synthetic work entry shares the original's role, so its + // recursive rows picked up a fork action too — the action belongs + // to the entry's own last settled row, not to a fold-hidden work + // row (transcript.rs:1692-1697). + forkEntry: undefined, compactFold: workId, })); const workedSecs = @@ -1913,25 +1960,38 @@ export function rowsForEntry(entry: SessionMessageEntry, options: RowsOptions): rows[0] = { ...rows[0]!, turnStart: true }; } // Timestamp strip under the entry's LAST row once the turn has settled - // ("No timestamp hover mid-stream"). The fork seam carries NO message - // metadata lane (transcript.rs:1674-1683 excludes ForkMarker — a quiet - // divider, never a hover target). - if ( - !streaming && - rows.length > 0 && - rows[rows.length - 1]!.rowKind.kind !== "forkMarker" - ) { - const last = rows[rows.length - 1]!; + // ("No timestamp hover mid-stream"). The fork seam and the harness-switch + // seam carry NO message metadata lane (transcript.rs:1674-1683 excludes + // ForkMarker and HarnessSwitchMarker — a quiet divider, never a hover + // target). + const lastRow = rows.length > 0 ? rows[rows.length - 1]! : null; + const lastIsSeam = + lastRow !== null && + (lastRow.rowKind.kind === "forkMarker" || lastRow.rowKind.kind === "harnessSwitchMarker"); + if (!streaming && lastRow !== null && !lastIsSeam) { rows[rows.length - 1] = { - ...last, + ...lastRow, timestamp: entry.createdAt, copyText: assistantCopyText(entry), - version: last.version ^ 0x40000000, + // "Fork from here" (ticket 21) rides the same settled row: every + // assistant reply — errored and interrupted ones included — offers + // the fork action. User and system entries never do. + forkEntry: entry.role === "assistant" ? entry.id : undefined, + version: lastRow.version ^ 0x40000000, }; } return rows; } +/** + * The harness-switch seam's divider line (transcript.rs:1628-1632): + * "Switched to <display name>", resolved once when the row is built — the + * label the harness renders as (`provider_name`, lib/accounts.ts). Pure. + */ +export function switchedToLabel(harness: HarnessId): string { + return `Switched to ${providerName(harness)}`; +} + /** * Compact mode's reply boundary: the index where the turn's TRAILING run of * non-empty text parts begins (`rows_for_entry`, transcript.rs:1319). Null diff --git a/web/packages/app/src/routes/chat-page.tsx b/web/packages/app/src/routes/chat-page.tsx index 1a6e641cd2..b9dd1f41fe 100644 --- a/web/packages/app/src/routes/chat-page.tsx +++ b/web/packages/app/src/routes/chat-page.tsx @@ -13,7 +13,13 @@ import { useNow, useWatchSnapshot } from "../state/hooks"; import { useTitlebar } from "../state/chrome"; import { emitShortcut } from "../state/shortcuts"; import { chatPageRow, effectiveIndicator, type ChatRow } from "../lib/view"; -import { JumpPill, StatusStrip, TranscriptView, type JumpButtonState } from "../components/transcript"; +import { + JumpPill, + StatusStrip, + TranscriptView, + type ForkFromEntryPayload, + type JumpButtonState, +} from "../components/transcript"; import { ChatTranscriptOutlet } from "../components/chat-transcript-outlet"; import type { SubagentOpen } from "../components/tool-group"; import { resolvePaneWidth, rightPaneStore, useRightPane } from "../state/right-pane"; @@ -68,6 +74,10 @@ import { QUEUE_EDIT_UNREACHABLE_MESSAGE, } from "../lib/queue-actions"; import { ATTACHMENT_ONLY_TEXT, uploadAttachments, type StagedAttachment } from "../lib/attachments"; +import { catalogTargetDeviceId } from "../lib/harness-choice"; +import type { DraftConfig } from "../lib/composer-actions"; +import { forkSpecFor } from "../lib/fork-dialog"; +import { forkDialogStore } from "../state/fork-dialog"; import { TerminalDock } from "../terminal/terminal-dock"; import { drawerTerminalStore } from "../terminal/store"; import { canvasTerminalKey } from "../terminal/session"; @@ -513,7 +523,7 @@ export function ConversationPage() { // back), and the status strip's Limited banner calls it. The banner lives // above the composer in the bottom stack; the ref is the one channel that // reaches the send path without re-mounting anything. - const resumeRef = useRef<(() => void) | null>(null); + const resumeRef = useRef<((override?: Partial<DraftConfig>) => void) | null>(null); // If the queue store reports the row disappeared while we were editing // (another device removed/sent it), drop the edit state so the composer @@ -1297,6 +1307,34 @@ export function ConversationPage() { [navigate, session], ); + /** + * "Fork from here" (t3-orchestration-v2 tickets 21/22/26, the desktop's + * `TranscriptEvent::ForkFromEntry`): the hover action on a settled + * assistant reply opens the fork dialog at exactly that reply. The + * reply lives in the selected chat's transcript, so the fork hangs + * under the source — a side chat's parent would make its own fork land + * as a sibling (its surface's callback computes that parent) — and the + * copy's surface opens in THIS chat's pane. + */ + const onForkFromEntry = useCallback( + (payload: ForkFromEntryPayload) => { + if (session === null) { + return; + } + const source = + snapshot.chats.loaded + ? snapshot.chats.rows.find((row) => row.id === payload.chatId) ?? null + : null; + if (source === null) { + return; + } + forkDialogStore.open( + forkSpecFor(source, chatId, payload.entryId, session.client, session.engine.baseUrl), + ); + }, + [session, snapshot, chatId], + ); + // The titlebar is the shell's; the route fills its identity and the `+`'s // handler. The right pane, its toggle, its surface tabs and its expand // control are all shell chrome — see `state/chrome.ts` for why they must @@ -1393,6 +1431,7 @@ export function ConversationPage() { indicator={row?.status ?? "idle"} turnStartedAt={turnStartedAt} onOpenSubagent={onOpenSubagent} + onForkFromEntry={onForkFromEntry} deliveryDegraded={deliveryDegraded} /> )} @@ -1429,7 +1468,19 @@ export function ConversationPage() { limit: sessionRow?.limit ?? null, scheduled: sessionRow?.scheduledResume ?? null, now, - onResume: () => resumeRef.current?.(), + onResume: (switched?: Partial<DraftConfig>) => resumeRef.current?.(switched), + // Ticket 26: the "Continue on another harness" picker's + // inputs — the harness to exclude (the chat's row config) + // and the catalog routing (the dialogs' target rule: the + // chat's host device when it differs from the connected + // engine's own, both in the fleet's scoped form). + currentHarness: chat?.config?.harness ?? null, + targetDeviceId: catalogTargetDeviceId( + session !== null && deviceId !== null + ? encodeScopedId(session.engine.baseUrl, deviceId) + : null, + chat?.deviceId ?? null, + ), } : null } diff --git a/web/packages/app/src/state/fork-dialog.ts b/web/packages/app/src/state/fork-dialog.ts new file mode 100644 index 0000000000..920f22e639 --- /dev/null +++ b/web/packages/app/src/state/fork-dialog.ts @@ -0,0 +1,334 @@ +import { useSyncExternalStore } from "react"; +import { encodeScopedId } from "@roboco/engine-client"; +import type { HarnessDescriptor, HarnessId, Model } from "@roboco/proto"; +import { childChatTitle } from "../lib/explorer-sections"; +import { + catalogTargetDeviceId, + dialogRows, + loadDialogHarnesses, + loadDialogModels, + type DialogLoad, +} from "../lib/harness-choice"; +import { + forkConfirmOverrides, + forkOfferedHarnesses, + type ForkDialogSpec, +} from "../lib/fork-dialog"; +import { mintId } from "../lib/id"; +import { beginSideChatCreate, endSideChatCreate, forkSideChat } from "../lib/side-chat-actions"; +import { rightPaneStore } from "./right-pane"; +import { seedPendingSideChat } from "./side-chats"; + +/** + * The fork dialog's state machine — the web peer of the desktop's + * `Shell::fork_dialog` / `shell/fork_dialog.rs` (t3-orchestration-v2 + * tickets 21+22). Both web fork entry points open it instead of forking + * immediately: the files panel's classic "Fork this chat" button and the + * transcript's "Fork from here" hover action (the entry points pass a + * `ForkDialogSpec`; see lib/fork-dialog.ts). + * + * The dialog defaults to the source's own harness and model, so confirming + * without touching anything forks exactly as the classic button always + * did (the confirm's override projection sends only what differs); picking + * another harness makes the fork a cross-harness one, which the engine + * serves with the budgeted history instead of a native session fork. + * + * Headless module state, mounted once by `components/fork-dialog.tsx` + * (the add-space palette's `addSpaceStore` shape): `open()` mints the + * spec, the loads ride a per-spec staleness guard (an in-flight catalog + * landing on a closed or reopened dialog no-ops), and `confirm()` performs + * the fork through the same `ForkSideChat` RPC + seed + right-pane surface + * the classic button used — the one-at-a-time side-chat guard + * (`side_chat_creating`) arms around it. A failed fork keeps the dialog + * open with the reason inline (the classic flow surfaced it in the + * sidebar's notice strip; a modal owns its own error line). + */ + +/** The dialog's snapshot — immutable per commit, like the add-space flow. */ +export interface ForkDialogState { + readonly open: boolean; + readonly spec: ForkDialogSpec | null; + /** + * The harness the fork will run on. Null until a default resolves — the + * source's own config first, the first offered harness when the source + * carries no config. Null at confirm time means "no override": the fork + * keeps the source's harness and its native fork behaviour. + */ + readonly harness: HarnessId | null; + /** The model the fork will run on; null = the harness's default at run time. */ + readonly model: string | null; + /** The source device's harness catalog, loaded once per dialog. */ + readonly harnesses: DialogLoad<HarnessDescriptor>; + /** Model lists per harness, loaded once per dialog per harness. */ + readonly models: ReadonlyMap<HarnessId, DialogLoad<Model>>; + /** True while the confirm's fork RPC is in flight. */ + readonly creating: boolean; + /** The footer's error line — a failed fork, live until the next try. */ + readonly error: string | null; +} + +const CLOSED: ForkDialogState = { + open: false, + spec: null, + harness: null, + model: null, + harnesses: "idle", + models: new Map(), + creating: false, + error: null, +}; + +export class ForkDialogStore { + #state: ForkDialogState = CLOSED; + readonly #listeners = new Set<() => void>(); + + getSnapshot(): ForkDialogState { + return this.#state; + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + /** + * Open the dialog for `spec` (the desktop's `open_fork_dialog`): the + * selection starts as the source's own harness and model — spec story 61 + * — and the catalog load kicks immediately. A confirm already in flight + * refuses the reopen (the desktop's `side_chat_creating` guard on open). + */ + open(spec: ForkDialogSpec): void { + if (this.#state.creating) { + return; + } + this.#state = { + open: true, + spec, + harness: spec.source.config?.harness ?? null, + model: spec.source.config?.model ?? null, + harnesses: "loading", + models: new Map(), + creating: false, + error: null, + }; + this.#commit(); + this.#ensureHarnesses(spec); + const harness = this.#state.harness; + if (harness !== null) { + this.#ensureModels(spec, harness); + } + } + + /** Close without forking (Cancel, scrim, Escape). */ + close(): void { + if (!this.#state.open) { + return; + } + this.#state = { ...CLOSED }; + this.#commit(); + } + + /** + * Pick the fork's harness (the desktop's `pick_fork_harness`): a pick of + * another harness resets the model (the source's model id is meaningless + * there; the new harness's own default applies at run time); a pick back + * onto the source's harness restores it. + */ + pickHarness(harness: HarnessId): void { + const { spec, harness: current, model } = this.#state; + if (spec === null || current === harness) { + return; + } + const sourceHarness = spec.source.config?.harness ?? null; + const sourceModel = spec.source.config?.model ?? null; + this.#state = { + ...this.#state, + harness, + model: sourceHarness === harness ? sourceModel : null, + }; + this.#commit(); + this.#ensureModels(spec, harness); + } + + /** Pick the fork's model on the selected harness. */ + pickModel(model: string): void { + if (this.#state.spec === null || this.#state.model === model) { + return; + } + this.#state = { ...this.#state, model }; + this.#commit(); + } + + /** + * Confirm: hand the dialog's fork — source, parent, boundary and the + * chosen harness/model — to the `ForkSideChat` RPC flow, then open the + * copy as a right-pane side-chat surface under the pane the fork started + * from. Only picks that differ from the source's own ride the wire, so + * untouched defaults fork byte-for-byte like the classic button. A + * failed fork keeps the dialog open with the reason inline; a successful + * one closes it (the surface opening is the feedback). + */ + confirm(): void { + const spec = this.#state.spec; + if (spec === null || this.#state.creating) { + return; + } + const { harness, model } = this.#state; + if (!beginSideChatCreate()) { + return; + } + this.#state = { ...this.#state, creating: true, error: null }; + this.#commit(); + const overrides = forkConfirmOverrides(spec.source, harness, model); + void forkSideChat(spec.client, { + chatId: mintId(), + sourceChatId: spec.source.id, + parentChatId: spec.parentId, + ...(spec.boundary !== null ? { boundary: spec.boundary } : {}), + ...(overrides.harness !== null ? { harness: overrides.harness } : {}), + ...(overrides.model !== null ? { model: overrides.model } : {}), + targetDeviceId: spec.source.deviceId, + }) + .then((chat) => { + // The reply is the ENGINE's row (raw id); the surface and the fleet + // rows speak the scoped form — and the seed (the desktop's + // `pending_side_chat`) bridges the beat before the registry frame + // lands the row. The pane the fork started from owns the surface + // (the desktop's `panel_key`). + const scoped = encodeScopedId(spec.engineBaseUrl, chat.id); + seedPendingSideChat({ ...chat, id: scoped }); + rightPaneStore.addSideChatSurface(spec.paneChatId, { + chatId: scoped, + title: childChatTitle(chat), + }); + endSideChatCreate(); + this.#state = { ...CLOSED }; + this.#commit(); + }) + .catch((cause: unknown) => { + endSideChatCreate(); + this.#state = { + ...this.#state, + creating: false, + error: cause instanceof Error ? cause.message : String(cause), + }; + this.#commit(); + }); + } + + /** + * Load the harness catalog once per dialog (the desktop's + * `ensure_fork_harnesses`): the fork runs on the SOURCE's device (the + * RPC refuses a source hosted elsewhere), so its installed harnesses are + * the ones that matter. Short-lived — no refresh-on-open, no retry; + * reopening retries. + */ + #ensureHarnesses(spec: ForkDialogSpec): void { + const target = catalogTargetDeviceId(this.#ownDeviceId(spec), spec.source.deviceId); + void loadDialogHarnesses(spec.client, target) + .then((rows) => { + if (this.#state.spec !== spec) { + return; // closed or reopened under us + } + this.#state = { ...this.#state, harnesses: { rows } }; + // A source with no config keeps no default of its own: the first + // offered harness becomes the pick once the catalog lands, and its + // model list is next. + const first = forkOfferedHarnesses(rows, this.#state.harness)[0]?.id ?? null; + if (this.#state.harness === null && first !== null) { + this.#state = { ...this.#state, harness: first }; + } + this.#commit(); + const picked = this.#state.harness; + if (picked !== null) { + this.#ensureModels(spec, picked); + } + }) + .catch((cause: unknown) => { + if (this.#state.spec !== spec) { + return; + } + this.#state = { + ...this.#state, + harnesses: { error: cause instanceof Error ? cause.message : String(cause) }, + }; + this.#commit(); + }); + } + + /** Load one harness's model list, once per dialog per harness. */ + #ensureModels(spec: ForkDialogSpec, harness: HarnessId): void { + const existing = this.#state.models.get(harness); + if (existing !== undefined && existing !== "idle") { + return; + } + const models = new Map(this.#state.models); + models.set(harness, "loading"); + this.#state = { ...this.#state, models }; + this.#commit(); + const target = catalogTargetDeviceId(this.#ownDeviceId(spec), spec.source.deviceId); + void loadDialogModels(spec.client, harness, target) + .then((rows) => { + if (this.#state.spec !== spec) { + return; + } + const next = new Map(this.#state.models); + next.set(harness, { rows }); + this.#state = { ...this.#state, models: next }; + this.#commit(); + }) + .catch((cause: unknown) => { + if (this.#state.spec !== spec) { + return; + } + const next = new Map(this.#state.models); + next.set(harness, { error: cause instanceof Error ? cause.message : String(cause) }); + this.#state = { ...this.#state, models: next }; + this.#commit(); + }); + } + + /** + * The connected engine's own device id, scoped — the catalog target's + * comparison base (the desktop's `fork_catalog_target`). Null when the + * engine never published its device, so the chat's device is the target. + */ + #ownDeviceId(spec: ForkDialogSpec): string | null { + const raw = spec.client.engineInfo?.deviceId ?? null; + return raw === null ? null : encodeScopedId(spec.engineBaseUrl, raw); + } + + #commit(): void { + for (const listener of [...this.#listeners]) { + listener(); + } + } +} + +/** + * The fork dialog's module-level singleton — the add-space palette's + * `addSpaceStore` shape. `open()` is the hook both entry points call. + */ +export const forkDialogStore = new ForkDialogStore(); + +const subscribe = (listener: () => void) => forkDialogStore.subscribe(listener); +const getSnapshot = () => forkDialogStore.getSnapshot(); + +/** The dialog's live state for the mounted layer. */ +export function useForkDialog(): ForkDialogState { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** The harnesses the open dialog offers (its offered-list projection). */ +export function forkDialogOffered(state: ForkDialogState): readonly HarnessDescriptor[] { + const spec = state.spec; + if (spec === null) { + return []; + } + return forkOfferedHarnesses( + dialogRows(state.harnesses), + state.harness ?? spec.source.config?.harness ?? null, + ); +} diff --git a/web/packages/app/src/styles/app.css b/web/packages/app/src/styles/app.css index e32b9968f2..1d67c0eff5 100644 --- a/web/packages/app/src/styles/app.css +++ b/web/packages/app/src/styles/app.css @@ -9174,6 +9174,36 @@ button.files-save-pill:hover { color: var(--rb-text-muted); } +/* + * "Continue on another harness" (t3-orchestration-v2 tickets 25/26) — the + * Limited banner's fourth chip's anchored card, the failover picker's + * idiom: the one-way warning body, the shared harness-choice chips and + * model rows, and the confirm pair. The body copy is the desktop dialog's + * (`continue_on_harness.rs`). + */ +.continue-card { + display: flex; + flex-direction: column; + gap: 2px; + padding-bottom: 4px; +} + +.continue-card-body { + margin: 2px 8px 4px; + font-size: 12px; + line-height: 17px; + color: var(--rb-text-muted); +} + +.continue-card-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 8px 8px 2px; + padding-top: 8px; + border-top: 1px solid rgb(var(--rb-hairline) / calc(0.07 * var(--rb-hairline-scale))); +} + /* * The inline "Sign in again" progress (the desktop's `sign_in_footer`): * a compact wash card under whichever rows offered the action — the @@ -10183,11 +10213,21 @@ button.files-save-pill:hover { transition: background-color var(--rb-motion-hover-fade) var(--rb-ease-ease-out); } -.row-meta-copy:hover { +/* + * "Fork from here" (t3-orchestration-v2 ticket 21): the quiet icon button + * beside the copy action — the same treatment, a branch glyph. + */ +.row-meta-fork { + color: var(--rb-text-muted); +} + +.row-meta-copy:hover, +.row-meta-fork:hover { background: rgb(var(--rb-ink) / 0.08); } -.row-meta-copy:focus-visible { +.row-meta-copy:focus-visible, +.row-meta-fork:focus-visible { background: rgb(var(--rb-ink) / 0.08); } @@ -11466,6 +11506,207 @@ button.files-save-pill:hover { color: var(--rb-text-muted); } +/* + * The harness-switch seam (t3-orchestration-v2 ticket 24, transcript.rs + * `harness_switch_marker`): the same divider treatment as the fork seam, + * ONE line — the label arrives pre-resolved ("Switched to Codex") so + * nothing here needs a catalog. No message metadata lane. + */ +.harness-switch-marker { + padding-block: 14px; + width: 100%; + min-width: 0; + overflow: hidden; + display: flex; + align-items: center; + gap: 10px; +} + +.harness-switch-marker-label { + flex: none; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 500; + color: var(--rb-text-muted); +} + +/* + * The fork dialog (t3-orchestration-v2 tickets 21+22, the desktop's + * `render_fork_dialog`): a 460px modal card — title, the one-line body, + * the shared harness-choice chips and model rows, the cross-harness note, + * the inline error, and the cancel/fork pair. + */ +.fork-dialog-card { + box-sizing: border-box; + width: 460px; +} + +.fork-dialog-body { + display: flex; + flex-direction: column; + padding: 16px 18px 14px; +} + +.fork-dialog-title { + font-size: 15px; + font-weight: 600; + color: var(--rb-text); +} + +.fork-dialog-body-text { + margin-top: 6px; + font-size: 12.5px; + line-height: 17px; + color: var(--rb-text-muted); +} + +.fork-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* + * The shared harness/model choice pieces (lib/harness-choice.ts's + * consumers): the fork dialog's modal body and the Limited banner's + * "Continue on another harness" card render the same rows — the + * desktop's two dialogs' chip and row treatments. + */ +.harness-choice-heading { + margin: 14px 0 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--rb-text-muted); +} + +.harness-choice-chips { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.harness-choice-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--rb-border); + background: transparent; + cursor: pointer; +} + +.harness-choice-chip[data-selected="1"] { + border-color: var(--rb-border-strong); + background: rgb(var(--rb-wash) / var(--rb-selected-wash-alpha)); +} + +.harness-choice-chip-label { + font-size: 12.5px; + color: var(--rb-text-muted); +} + +.harness-choice-chip[data-selected="1"] .harness-choice-chip-label { + color: var(--rb-text); +} + +.harness-choice-empty { + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--rb-border); + font-size: 12.5px; + color: var(--rb-text-muted); +} + +.harness-choice-models { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 216px; + overflow-y: auto; +} + +.harness-choice-models-empty { + border: none; + padding: 6px 0; +} + +.harness-choice-model-row { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 8px; + border-radius: 8px; + text-align: left; + background: transparent; + border: none; + cursor: pointer; +} + +.harness-choice-model-row[data-selected="1"] { + background: rgb(var(--rb-wash) / var(--rb-selected-wash-alpha)); + box-shadow: var(--rb-shadow-popover); +} + +.harness-choice-model-main { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; +} + +.harness-choice-model-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + font-weight: 500; + color: var(--rb-text); +} + +.harness-choice-model-default { + flex: none; + font-size: 10px; + color: var(--rb-text-muted); +} + +.harness-choice-model-sub { + width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: var(--rb-text-muted); +} + +.harness-choice-note { + margin-top: 12px; + font-size: 12px; + line-height: 17px; + color: var(--rb-text-muted); +} + +.harness-choice-error { + margin-top: 10px; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--rb-warning, var(--rb-accent)) 12%, transparent); + font-size: 11.5px; + color: var(--rb-text-muted); +} + /* ── Transcript responsive + reduced motion ─────────────────────────── */ @media (max-width: 768px) { diff --git a/web/packages/app/tests/composer-draft.test.ts b/web/packages/app/tests/composer-draft.test.ts index 3e99d8f97c..c85f2cf727 100644 --- a/web/packages/app/tests/composer-draft.test.ts +++ b/web/packages/app/tests/composer-draft.test.ts @@ -17,8 +17,10 @@ import { reconcileFreshDraftHarness, reconcileFreshDraftModel, rememberNoProject, + rememberModel, rememberReasoningForModel, rememberedReasoningFor, + switchHarnessDraft, } from "../src/lib/composer-draft"; import type { StorageLike } from "../src/lib/engine-store"; @@ -267,6 +269,63 @@ describe("isHarnessLocked", () => { // `side_chat_harness_editable` (state.rs). expect(isHarnessLocked(config, true)).toBe(false); }); + + it("unlocks for a one-way switch between turns (ticket 24's harness_switchable)", () => { + // An idle existing chat — no send in flight, no turn running — opens + // the facet for the one-way harness switch; a pick becomes the switch + // instead of a draft change. + expect(isHarnessLocked(config, false, true)).toBe(false); + // A busy or working chat keeps the lock: the switch runs between + // turns only. The default (no switchable) keeps the historical lock. + expect(isHarnessLocked(config, false, false)).toBe(true); + // The unlock never escapes an existing chat's config-less rows, and + // the editable side-chat facet outranks it either way. + expect(isHarnessLocked(null, false, true)).toBe(false); + expect(isHarnessLocked(config, true, true)).toBe(false); + }); +}); + +describe("switchHarnessDraft (ticket 24)", () => { + const config: ChatConfig = { + harness: "claude-code", + model: "sonnet", + reasoning: "low", + modelOptions: { effort: "high" }, + sandbox: "read-only", + }; + + it("resets to the new harness's remembered defaults; the sandbox stays the chat's own pick", () => { + rememberModel("codex", "gpt-5", "GPT-5"); + rememberReasoningForModel("codex", "gpt-5", "medium"); + const draft = switchHarnessDraft(config, "codex"); + expect(draft).toEqual({ + harness: "codex", + model: "gpt-5", + reasoning: "medium", + sandbox: "read-only", + // "Model options reset to the new harness's defaults unless the user + // chose them" — the old harness's ids are meaningless there. + modelOptions: {}, + }); + }); + + it("a harness with nothing remembered falls to its own defaults (model null, no options)", () => { + const draft = switchHarnessDraft(config, "pi"); + expect(draft).toEqual({ + harness: "pi", + model: null, + reasoning: null, + sandbox: "read-only", + modelOptions: {}, + }); + }); + + it("an explicit model rides the switch itself (a foreign model row's pick)", () => { + const draft = switchHarnessDraft(config, "codex", "gpt-6"); + expect(draft.model).toBe("gpt-6"); + // The old harness's reasoning never crosses either. + expect(draft.reasoning).toBeNull(); + }); }); describe("draftsEqual", () => { diff --git a/web/packages/app/tests/composer-resume-switch.test.ts b/web/packages/app/tests/composer-resume-switch.test.ts new file mode 100644 index 0000000000..db634beefa --- /dev/null +++ b/web/packages/app/tests/composer-resume-switch.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment jsdom + +/** + * The composer's **Resume** handle with a switched-picks override + * (t3-orchestration-v2 tickets 25/26): "Continue on another harness" + * switches the chat's harness through the engine and then fires the + * fixed-prompt continuation — and the continuation must carry the NEW + * harness and model deterministically, because the chats-watch frame that + * would re-seed the composer's draft can trail the SwitchHarness reply. + * The web's stamp is the resume handle's `override` parameter (the + * desktop's `apply_chat_config` row stamp before `resume_limited`). + * + * The mounted idiom follows composer-edit-failure.test.ts: no JSX, the + * session double, the matchMedia / ResizeObserver / scrollIntoView / rAF + * stubs; the assertion is the QueueCommand the engine sees. + */ + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Chat } from "@roboco/proto"; +import type { DraftConfig } from "../src/lib/composer-actions"; +import { RESUME_CONTINUE_PROMPT } from "../src/lib/limited"; +import { Composer } from "../src/components/composer"; +import { PickerCatalog } from "../src/state/picker-catalog"; +import type { EngineSession } from "../src/state/engine-session"; +import type { EngineClient } from "@roboco/engine-client"; + +// The composer's import graph resolves the new-thread artwork at module +// scope and prewarms its decode (appearance.ts `defaultPrewarm`) — jsdom's +// Image has no decode, and the unhandled rejection would poison the run. +vi.hoisted(() => { + if ( + typeof HTMLImageElement === "function" && + typeof HTMLImageElement.prototype.decode !== "function" + ) { + HTMLImageElement.prototype.decode = () => Promise.resolve(); + } +}); + +beforeAll(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = ((query: string) => ({ + matches: query.startsWith("(max-width"), + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + if (typeof Element.prototype.scrollIntoView !== "function") { + Element.prototype.scrollIntoView = () => {}; + } + if (typeof globalThis.requestAnimationFrame !== "function") { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 0; + }) as typeof requestAnimationFrame; + } +}); + +afterAll(() => { + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +// ── A recording engine client: every call resolves, the queue answers ──── + +const h = vi.hoisted(() => { + const calls: Array<{ method: string; params: Record<string, unknown> }> = []; + const client = { + engineInfo: { deviceId: "device-1" }, + state: "connected", + status: { state: "connected", info: null }, + onStatus(): () => void { + return () => {}; + }, + async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> { + calls.push({ method, params }); + if (method === "QueueCommand") { + return { commandId: "cmd-1" } as T; + } + return {} as T; + }, + } as unknown as EngineClient; + return { calls, client }; +}); + +const CATALOG = new PickerCatalog(h.client); + +const SESSION = { + engine: { baseUrl: "http://engine.test", credential: "cred", label: "test" }, + client: h.client, + cache: { + subscribe(): () => void { + return () => {}; + }, + getSnapshot(): null { + return null; + }, + }, + catalog: CATALOG, + transcripts: {}, +} as unknown as EngineSession; + +function limitedChat(id: string): Chat { + return { + id, + deviceId: "device-1", + title: null, + archived: false, + cwd: "/proj", + branch: null, + checkoutId: null, + // The chat runs on claude-code — the harness the limit hit on. + config: { + harness: "claude-code", + model: "sonnet", + reasoning: null, + sandbox: "workspace-write", + modelOptions: {}, + }, + lastMessagePreview: null, + lastMessageAt: null, + createdAt: new Date(0).toISOString(), + spaceId: null, + }; +} + +// ── The mounted composer harness (the resumeRef host is the page) ───────── + +const hostRef: { current: ((override?: Partial<DraftConfig>) => void) | null } = { + current: null, +}; + +const mounted: Array<() => void> = []; + +afterEach(() => { + while (mounted.length > 0) { + mounted.pop()!(); + } + hostRef.current = null; + h.calls.length = 0; + document.body.replaceChildren(); +}); + +function mountComposer(): void { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const chat = limitedChat("chat-limited"); + function Host() { + return createElement(Composer, { + session: SESSION, + chat, + catalog: CATALOG, + transcript: null, + availableWidth: 700, + resumeRef: hostRef, + }); + } + act(() => { + root.render(createElement(Host)); + }); + mounted.push(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); +} + +async function flush(): Promise<void> { + await act(async () => {}); +} + +function queueCommand(): Record<string, unknown> | undefined { + return h.calls.find((call) => call.method === "QueueCommand")?.params as + | Record<string, unknown> + | undefined; +} + +describe("resumeLimited with a switched-picks override (tickets 25/26)", () => { + it("the continuation carries the switched harness and model, not the stale draft", async () => { + mountComposer(); + await flush(); + expect(hostRef.current).not.toBeNull(); + act(() => { + hostRef.current!({ harness: "pi", model: "glm-5.3", reasoning: null, modelOptions: {} }); + }); + await flush(); + const params = queueCommand(); + expect(params).toBeDefined(); + expect(params!.chatId).toBe("chat-limited"); + const command = params!.command as Record<string, unknown>; + expect(command.kind).toBe("run"); + const request = command.request as Record<string, unknown>; + expect(request.prompt).toBe(RESUME_CONTINUE_PROMPT); + // The desktop's row stamp, verbatim: the NEW harness and its model, + // never the limited claude-code draft the chats watch has not yet + // replaced. + expect(request.harness).toBe("pi"); + expect(request.model).toBe("glm-5.3"); + expect(request.reasoning).toBeNull(); + // The sandbox is the one field the switch never owns — the chat's own. + expect(request.sandbox).toBe("workspace-write"); + }); + + it("a plain resume (no override) keeps the draft's run identity", async () => { + mountComposer(); + await flush(); + act(() => { + hostRef.current!(); + }); + await flush(); + const request = (queueCommand()!.command as Record<string, unknown>).request as Record< + string, + unknown + >; + expect(request.prompt).toBe(RESUME_CONTINUE_PROMPT); + expect(request.harness).toBe("claude-code"); + expect(request.model).toBe("sonnet"); + }); +}); diff --git a/web/packages/app/tests/fork-dialog-layer.test.ts b/web/packages/app/tests/fork-dialog-layer.test.ts new file mode 100644 index 0000000000..8eee966156 --- /dev/null +++ b/web/packages/app/tests/fork-dialog-layer.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment jsdom + +/** + * The fork dialog, mounted (t3-orchestration-v2 tickets 21+22): the layer + * the app shell owns, driven against a recording engine client double — + * the mounted-suite idiom (Base UI runs for real in jsdom, its portals + * into document.body and all; the limited-banner suite's set). The + * assertions are the click→wire chain: the modal opens with the source's + * defaults, the boundary flips the title, picking rides the store, and + * the Fork button fires FORK_SIDE_CHAT. No JSX (createElement). + */ + +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { encodeScopedId, type EngineClient } from "@roboco/engine-client"; +import type { Chat, HarnessDescriptor } from "@roboco/proto"; +import { ForkDialogLayer } from "../src/components/fork-dialog"; +import { forkDialogStore } from "../src/state/fork-dialog"; +import { clearPendingSideChat, pendingSideChat } from "../src/state/side-chats"; +import { rightPaneStore } from "../src/state/right-pane"; +import { resetSideChatCreate } from "../src/lib/side-chat-actions"; + +const ENGINE = "ws://engine.local"; +const OWN_DEVICE = encodeScopedId(ENGINE, "dev-1"); + +function descriptor(id: HarnessDescriptor["id"]): HarnessDescriptor { + return { + id, + name: id === "codex" ? "Codex" : id === "pi" ? "Pi" : id, + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }; +} + +const CATALOG = [descriptor("codex"), descriptor("pi")]; + +function source(fields: Partial<Chat> = {}): Chat { + return { + id: "source-1", + deviceId: OWN_DEVICE, + title: "The source chat", + archived: false, + cwd: "/repo", + branch: "main", + checkoutId: null, + config: { harness: "codex", model: "gpt-6-codex", reasoning: null, sandbox: "workspace-write", modelOptions: {} }, + lastMessagePreview: null, + lastMessageAt: null, + createdAt: "2026-10-16T10:00:00Z", + spaceId: null, + parentChatId: null, + ...fields, + }; +} + +const h = vi.hoisted(() => { + const calls: Array<{ method: string; params: Record<string, unknown> }> = []; + const client = { + engineInfo: { deviceId: "dev-1" }, + async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> { + calls.push({ method, params }); + switch (method) { + case "ListHarnesses": + return CATALOG as T; + case "ListModels": + return [{ id: "glm-5.3", label: "GLM 5.3", reasoningLevels: [], options: [] }] as T; + case "ForkSideChat": + return { + id: "fork-9", + parentChatId: "source-1", + title: "The forked chat", + } as T; + default: + return {} as T; + } + }, + } as unknown as EngineClient; + return { calls, client }; +}); + +beforeAll(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }; + if (typeof Element.prototype.scrollIntoView !== "function") { + Element.prototype.scrollIntoView = () => {}; + } + if (typeof globalThis.requestAnimationFrame !== "function") { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 0; + }) as typeof requestAnimationFrame; + } +}); + +afterAll(() => { + vi.restoreAllMocks(); + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; +}); + +const mounted: Array<() => void> = []; + +afterEach(() => { + while (mounted.length > 0) { + mounted.pop()!(); + } + document.body.replaceChildren(); + forkDialogStore.close(); + resetSideChatCreate(); + h.calls.length = 0; + clearPendingSideChat(encodeScopedId(ENGINE, "fork-9")); +}); + +function mountLayer(): void { + const host = document.createElement("div"); + document.body.appendChild(host); + const root: Root = createRoot(host); + act(() => { + root.render(createElement(ForkDialogLayer)); + }); + mounted.push(() => { + act(() => { + root.unmount(); + }); + host.remove(); + }); +} + +async function flush(): Promise<void> { + await act(async () => {}); +} + +/** A real press pair on `target`: pointerdown (marks the press) then click. */ +function press(target: Element): void { + act(() => { + target.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, button: 0 })); + target.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })); + }); +} + +function textOf(selector: string): string | null { + return document.querySelector<HTMLElement>(selector)?.textContent ?? null; +} + +function openDialog(boundary: string | null): void { + act(() => { + forkDialogStore.open({ + source: source(), + parentId: "source-1", + boundary, + paneChatId: "pane-1", + client: h.client, + engineBaseUrl: ENGINE, + }); + }); +} + +describe("ForkDialogLayer — mounted", () => { + it("renders nothing while the store is closed", async () => { + mountLayer(); + await flush(); + expect(document.querySelector(".fork-dialog-card")).toBeNull(); + }); + + it("opens on the classic entry: the latest-reply title, the source's harness chip selected", async () => { + mountLayer(); + openDialog(null); + await flush(); + expect(textOf(".fork-dialog-title")).toBe("Fork this chat"); + expect(textOf(".fork-dialog-body-text")).toBe( + "A new side chat continues from the latest reply. Files are not rewound.", + ); + // The catalog landed: both offered chips render, the source's selected. + const chips = Array.from(document.querySelectorAll<HTMLButtonElement>(".harness-choice-chip")); + expect(chips.map((chip) => chip.textContent)).toEqual(["Codex", "Pi"]); + expect(chips[0]!.getAttribute("data-selected")).toBe("1"); + expect(chips[1]!.getAttribute("data-selected")).toBeNull(); + // The model list for the default pick. + expect(Array.from(document.querySelectorAll(".harness-choice-model-row"))).toHaveLength(1); + }); + + it("the boundary flips the title and body (ticket 21's entry point)", async () => { + mountLayer(); + openDialog("a1"); + await flush(); + expect(textOf(".fork-dialog-title")).toBe("Fork from here"); + expect(textOf(".fork-dialog-body-text")).toBe( + "A new side chat continues from this reply. Files are not rewound.", + ); + }); + + it("picking another harness resets the model and shows the cross-harness note; the Fork button forks", async () => { + mountLayer(); + openDialog("a1"); + await flush(); + // Pick Pi: the model resets, the cross-harness note appears. + press(document.querySelectorAll<HTMLButtonElement>(".harness-choice-chip")[1]!); + await flush(); + expect(forkDialogStore.getSnapshot().harness).toBe("pi"); + expect(forkDialogStore.getSnapshot().model).toBeNull(); + expect(textOf(".harness-choice-note")).toContain("size-limited transcript"); + // Confirm: the RPC goes out with the boundary and the cross-harness + // override, the seed lands, and the dialog closes. + const fork = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Fork", + )!; + press(fork); + await flush(); + const call = h.calls.find((entry) => entry.method === "ForkSideChat"); + expect(call).toBeDefined(); + expect(call!.params).toMatchObject({ + sourceChatId: "source-1", + parentChatId: "source-1", + boundary: "a1", + harness: "pi", + targetDeviceId: OWN_DEVICE, + }); + expect(pendingSideChat(encodeScopedId(ENGINE, "fork-9"))).not.toBeNull(); + expect(document.querySelector(".fork-dialog-card")).toBeNull(); + }); + + it("Cancel closes without forking", async () => { + mountLayer(); + openDialog(null); + await flush(); + const cancel = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Cancel", + )!; + press(cancel); + await flush(); + expect(forkDialogStore.getSnapshot().open).toBe(false); + expect(h.calls.filter((entry) => entry.method === "ForkSideChat")).toEqual([]); + }); +}); diff --git a/web/packages/app/tests/fork-dialog.test.ts b/web/packages/app/tests/fork-dialog.test.ts new file mode 100644 index 0000000000..1e4347d6ed --- /dev/null +++ b/web/packages/app/tests/fork-dialog.test.ts @@ -0,0 +1,377 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { encodeScopedId, type EngineClient } from "@roboco/engine-client"; +import type { Chat, HarnessDescriptor } from "@roboco/proto"; +import { forkConfirmOverrides, forkOfferedHarnesses, forkSpecFor } from "../src/lib/fork-dialog"; +import { forkDialogStore } from "../src/state/fork-dialog"; +import { clearPendingSideChat, pendingSideChat } from "../src/state/side-chats"; +import { rightPaneStore } from "../src/state/right-pane"; +import { resetSideChatCreate } from "../src/lib/side-chat-actions"; + +/** + * The fork dialog (t3-orchestration-v2 tickets 21+22) — the pure offered + * set and confirm-time override projection, plus the headless state + * machine both web fork entry points open (the files panel's classic + * button and the transcript's "Fork from here"): defaults resolve from the + * source's own config, picks reset per the desktop's rules, and confirm + * performs the fork through the FORK_SIDE_CHAT RPC, the pending-side-chat + * seed, and the right-pane surface — only the picks that differ riding + * the wire. Names port `shell/fork_dialog.rs`'s tests. + */ + +const ENGINE = "ws://engine.local"; +/** The engine's own device, in the fleet's scoped id form. */ +const OWN_DEVICE = encodeScopedId(ENGINE, "dev-1"); + +function descriptor(id: HarnessDescriptor["id"], installed = true): HarnessDescriptor { + return { + id, + name: id, + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed, + canInstall: false, + enabled: true, + }; +} + +const CATALOG = [descriptor("codex"), descriptor("pi"), descriptor("claude-code")]; + +function chatRow(fields: Partial<Chat> = {}): Chat { + return { + id: "source-1", + deviceId: OWN_DEVICE, + title: "The source chat", + archived: false, + cwd: "/repo", + branch: "main", + checkoutId: null, + config: { + harness: "codex", + model: "gpt-6-codex", + reasoning: null, + sandbox: "workspace-write", + modelOptions: {}, + }, + lastMessagePreview: null, + lastMessageAt: null, + createdAt: "2026-10-16T10:00:00Z", + spaceId: "space-1", + parentChatId: null, + ...fields, + }; +} + +/** A recording engine client with catalog/model replies and a fork reply. */ +function fakeClient(forkReply: () => Chat | Promise<Chat>): { + client: EngineClient; + calls: { method: string; params: Record<string, unknown> }[]; +} { + const calls: { method: string; params: Record<string, unknown> }[] = []; + const client = { + engineInfo: { deviceId: "dev-1" }, + async call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> { + calls.push({ method, params }); + switch (method) { + case "ListHarnesses": + return CATALOG as T; + case "ListModels": + return [{ id: "glm-5.3", label: "GLM 5.3", reasoningLevels: [], options: [] }] as T; + case "ForkSideChat": + return (await forkReply()) as T; + default: + return {} as T; + } + }, + } as unknown as EngineClient; + return { client, calls }; +} + +/** Flush the store's promise chains (the loads and the confirm). */ +async function flush(times = 4): Promise<void> { + for (let ix = 0; ix < times; ix++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +const CLEANUPS: string[] = []; + +afterEach(() => { + forkDialogStore.close(); + resetSideChatCreate(); + for (const id of CLEANUPS.splice(0)) { + clearPendingSideChat(id); + } +}); + +function rememberSeed(id: string): void { + CLEANUPS.push(id); +} + +// ── The pure halves (fork_dialog.rs's free functions) ──────────────────── + +describe("forkSpecFor", () => { + it("hangs a top-level fork under the source, a side chat's own fork under its parent (the sibling rule)", () => { + const { client } = fakeClient(() => chatRow({ id: "fork-9" })); + const top = forkSpecFor(chatRow(), "pane-1", "a1", client, ENGINE); + expect(top.parentId).toBe("source-1"); + expect(top.boundary).toBe("a1"); + expect(top.paneChatId).toBe("pane-1"); + expect(top.client).toBe(client); + const side = chatRow({ id: "side-2", parentChatId: "source-1" }); + expect(forkSpecFor(side, "pane-1", null, client, ENGINE).parentId).toBe("source-1"); + }); +}); + +describe("forkOfferedHarnesses", () => { + it("keeps the source's harness pickable when the catalog carries it unoffered", () => { + const catalog = [descriptor("codex"), descriptor("pi"), descriptor("claude-code", false)]; + expect(forkOfferedHarnesses(catalog, descriptor("codex").id).map((row) => row.id)).toEqual([ + "codex", + "pi", + ]); + // The source's harness is not offered (its CLI is gone): it still rides + // the list, behind the offered ones. + expect(forkOfferedHarnesses(catalog, "claude-code").map((row) => row.id)).toEqual([ + "codex", + "pi", + "claude-code", + ]); + // Not in the catalog at all: nothing to append. + expect(forkOfferedHarnesses(catalog, "grok").map((row) => row.id)).toEqual([ + "codex", + "pi", + ]); + expect(forkOfferedHarnesses([], "pi")).toEqual([]); + // No fallback: the plain offered set. + expect(forkOfferedHarnesses(catalog, null).map((row) => row.id)).toEqual(["codex", "pi"]); + }); +}); + +describe("forkConfirmOverrides", () => { + it("sends only the picks that differ — untouched defaults are the classic fork", () => { + const source = chatRow(); + expect(forkConfirmOverrides(source, "codex", "gpt-6-codex")).toEqual({ + harness: null, + model: null, + }); + // Another harness: the harness rides, and a model equal to the + // source's still rides with it (the new harness needs it spelled out). + expect(forkConfirmOverrides(source, "pi", "gpt-6-codex")).toEqual({ + harness: "pi", + model: "gpt-6-codex", + }); + expect(forkConfirmOverrides(source, "pi", null)).toEqual({ harness: "pi", model: null }); + // Same harness, another model. + expect(forkConfirmOverrides(source, "codex", "gpt-6-mini")).toEqual({ + harness: null, + model: "gpt-6-mini", + }); + // A blank model pick counts as none. + expect(forkConfirmOverrides(source, "codex", " ")).toEqual({ harness: null, model: null }); + // A config-less source: any resolved pick is an override. + expect(forkConfirmOverrides(chatRow({ config: null }), "codex", null)).toEqual({ + harness: "codex", + model: null, + }); + // No pick at all: no overrides, whatever the source carries. + expect(forkConfirmOverrides(source, null, null)).toEqual({ harness: null, model: null }); + }); +}); + +// ── The state machine (Shell::fork_dialog / fork_chat) ─────────────────── + +describe("ForkDialogStore", () => { + it("opens with the source's own harness and model, and loads its device's catalog", async () => { + const { client, calls } = fakeClient(() => chatRow({ id: "fork-9" })); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-a", + client, + engineBaseUrl: ENGINE, + }); + let state = forkDialogStore.getSnapshot(); + expect(state.open).toBe(true); + expect(state.harness).toBe("codex"); + expect(state.model).toBe("gpt-6-codex"); + await flush(); + state = forkDialogStore.getSnapshot(); + // The source's device IS the engine's own (both scoped): no routing + // rides either load. + expect(calls[0]).toEqual({ method: "ListHarnesses", params: {} }); + expect(calls[1]).toEqual({ method: "ListModels", params: { harness: "codex" } }); + // The catalog landed and the model list loaded for the default pick. + expect(state.harnesses).toEqual({ rows: CATALOG }); + expect(state.models.get("codex")).toEqual({ + rows: [{ id: "glm-5.3", label: "GLM 5.3", reasoningLevels: [], options: [] }], + }); + }); + + it("routes the catalog to the source's device when it is not the engine's own", async () => { + const remote = encodeScopedId(ENGINE, "dev-2"); + const { client, calls } = fakeClient(() => chatRow({ id: "fork-9" })); + forkDialogStore.open({ + source: chatRow({ deviceId: remote }), + parentId: "source-1", + boundary: null, + paneChatId: "pane-b", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + expect(calls[0]).toEqual({ + method: "ListHarnesses", + params: { targetDeviceId: remote }, + }); + expect(calls[1]?.params).toMatchObject({ harness: "codex", targetDeviceId: remote }); + }); + + it("a configless source defaults to the first offered harness once the catalog lands", async () => { + const { client } = fakeClient(() => chatRow({ id: "fork-9" })); + forkDialogStore.open({ + source: chatRow({ config: null }), + parentId: "source-1", + boundary: null, + paneChatId: "pane-c", + client, + engineBaseUrl: ENGINE, + }); + expect(forkDialogStore.getSnapshot().harness).toBeNull(); + await flush(); + expect(forkDialogStore.getSnapshot().harness).toBe("codex"); + }); + + it("picking another harness resets the model; picking the source's back restores it", async () => { + const { client } = fakeClient(() => chatRow({ id: "fork-9" })); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-d", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + forkDialogStore.pickHarness("pi"); + expect(forkDialogStore.getSnapshot().model).toBeNull(); + forkDialogStore.pickModel("glm-5.3"); + expect(forkDialogStore.getSnapshot().model).toBe("glm-5.3"); + forkDialogStore.pickHarness("codex"); + expect(forkDialogStore.getSnapshot().model).toBe("gpt-6-codex"); + forkDialogStore.close(); + expect(forkDialogStore.getSnapshot().open).toBe(false); + }); + + it("confirm forks with only the differing picks, seeds the row, and opens the pane surface", async () => { + const reply = chatRow({ id: "fork-9", parentChatId: "source-1" }); + const { client, calls } = fakeClient(() => reply); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: "a1", + paneChatId: "pane-e", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + forkDialogStore.pickHarness("pi"); + forkDialogStore.pickModel("glm-5.3"); + forkDialogStore.confirm(); + expect(forkDialogStore.getSnapshot().creating).toBe(true); + await flush(); + + const fork = calls.find((call) => call.method === "ForkSideChat"); + expect(fork).toBeDefined(); + expect(fork!.params).toMatchObject({ + sourceChatId: "source-1", + parentChatId: "source-1", + boundary: "a1", + harness: "pi", + model: "glm-5.3", + targetDeviceId: OWN_DEVICE, + }); + expect(typeof fork!.params.chatId).toBe("string"); + // The seed bridges the beat before the registry frame lands the row — + // scoped to the engine that minted it. + const scoped = encodeScopedId(ENGINE, "fork-9"); + expect(pendingSideChat(scoped)?.parentChatId).toBe("source-1"); + rememberSeed(scoped); + // The pane the fork started from owns the surface, and the dialog is + // closed (the surface opening is the feedback). + const tabs = rightPaneStore.stateFor("pane-e").tabs.filter((tab) => tab.kind === "sidechat"); + expect(tabs).toHaveLength(1); + const opened = rightPaneStore.sideChatSurfaceOf((tabs[0] as { id: string }).id); + expect(opened).toEqual({ chatId: scoped, title: "The source chat" }); + expect(forkDialogStore.getSnapshot().open).toBe(false); + }); + + it("an untouched confirm sends the classic fork payload — no overrides ride", async () => { + const { client, calls } = fakeClient(() => chatRow({ id: "fork-10" })); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-f", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + forkDialogStore.confirm(); + await flush(); + const fork = calls.find((call) => call.method === "ForkSideChat")!; + expect(fork.params).not.toHaveProperty("boundary"); + expect(fork.params).not.toHaveProperty("harness"); + expect(fork.params).not.toHaveProperty("model"); + rememberSeed(encodeScopedId(ENGINE, "fork-10")); + }); + + it("a failed fork keeps the dialog open with the reason inline", async () => { + const { client } = fakeClient(() => { + throw new Error("Wait for a completed response before starting a side chat"); + }); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-g", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + forkDialogStore.confirm(); + await flush(); + const state = forkDialogStore.getSnapshot(); + expect(state.open).toBe(true); + expect(state.creating).toBe(false); + expect(state.error).toBe("Wait for a completed response before starting a side chat"); + // The guard settled: a retry is possible. + expect(forkDialogStore.getSnapshot().creating).toBe(false); + }); + + it("a reopen under an in-flight load drops the stale landing", async () => { + const { client } = fakeClient(() => chatRow({ id: "fork-11" })); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-h", + client, + engineBaseUrl: ENGINE, + }); + forkDialogStore.close(); + forkDialogStore.open({ + source: chatRow({ id: "source-2" }), + parentId: "source-2", + boundary: "b2", + paneChatId: "pane-h", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + const state = forkDialogStore.getSnapshot(); + expect(state.spec?.source.id).toBe("source-2"); + expect(state.harness).toBe("codex"); + }); +}); diff --git a/web/packages/app/tests/harness-choice.test.ts b/web/packages/app/tests/harness-choice.test.ts new file mode 100644 index 0000000000..14dd987960 --- /dev/null +++ b/web/packages/app/tests/harness-choice.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { EngineClient } from "@roboco/engine-client"; +import type { HarnessDescriptor } from "@roboco/proto"; +import { + catalogTargetDeviceId, + dialogError, + dialogLoading, + dialogRows, + loadDialogHarnesses, + loadDialogModels, +} from "../src/lib/harness-choice"; + +/** + * The shared harness/model choice loads (t3-orchestration-v2 tickets + * 21+22+25) — the loadable shape, the catalog routing rule (which device's + * CLIs matter), and the two RPCs the fork dialog and the Limited banner's + * "Continue on another harness" card fire. Ports the desktop dialogs' + * `fork_catalog_target` rule and the ListHarnesses/ListModels call shapes. + */ + +const HARNESSES: HarnessDescriptor[] = [ + { + id: "codex", + name: "Codex", + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }, +]; + +const MODELS = [ + { id: "glm-5.3", label: "GLM 5.3", reasoningLevels: [], options: [] }, +]; + +class FakeCaller { + readonly calls: { method: string; params: unknown }[] = []; + + async call<T>(method: string, params?: unknown): Promise<T> { + this.calls.push({ method, params }); + switch (method) { + case "ListHarnesses": + return HARNESSES as T; + case "ListModels": + return MODELS as T; + default: + return {} as T; + } + } +} + +describe("DialogLoad helpers", () => { + it("idle/loading expose no rows and no error; landing shapes do", () => { + expect(dialogRows("idle")).toEqual([]); + expect(dialogRows("loading")).toEqual([]); + expect(dialogError("idle")).toBeNull(); + expect(dialogLoading("loading")).toBe(true); + expect(dialogLoading("idle")).toBe(false); + expect(dialogRows({ rows: HARNESSES })).toBe(HARNESSES); + expect(dialogError({ error: "engine offline" })).toBe("engine offline"); + expect(dialogRows({ error: "engine offline" })).toEqual([]); + expect(dialogLoading({ rows: [] })).toBe(false); + }); +}); + +describe("catalogTargetDeviceId", () => { + it("names the chat's device unless it is the engine's own", () => { + // The chat runs on the local engine: no routing needed. + expect(catalogTargetDeviceId("e:dev-1", "e:dev-1")).toBeNull(); + // A chat hosted on another device: its CLIs are the ones that matter. + expect(catalogTargetDeviceId("e:dev-1", "e:dev-2")).toBe("e:dev-2"); + // The engine never published its own device: the chat's is the target. + expect(catalogTargetDeviceId(null, "e:dev-2")).toBe("e:dev-2"); + expect(catalogTargetDeviceId(null, null)).toBeNull(); + expect(catalogTargetDeviceId("e:dev-1", null)).toBeNull(); + }); +}); + +describe("loadDialogHarnesses", () => { + it("asks ListHarnesses, riding targetDeviceId only when routed", async () => { + const local = new FakeCaller(); + await expect(loadDialogHarnesses(local, null)).resolves.toBe(HARNESSES); + expect(local.calls).toEqual([{ method: "ListHarnesses", params: {} }]); + + const remote = new FakeCaller(); + await expect(loadDialogHarnesses(remote, "e:dev-2")).resolves.toBe(HARNESSES); + expect(remote.calls).toEqual([ + { method: "ListHarnesses", params: { targetDeviceId: "e:dev-2" } }, + ]); + }); +}); + +describe("loadDialogModels", () => { + it("asks ListModels for the harness, riding the same routing", async () => { + const caller = new FakeCaller(); + await expect(loadDialogModels(caller, "pi", null)).resolves.toBe(MODELS); + expect(caller.calls).toEqual([{ method: "ListModels", params: { harness: "pi" } }]); + + const routed = new FakeCaller(); + await expect(loadDialogModels(routed, "pi", "e:dev-2")).resolves.toBe(MODELS); + expect(routed.calls).toEqual([ + { method: "ListModels", params: { harness: "pi", targetDeviceId: "e:dev-2" } }, + ]); + }); +}); + +// A compile-time stand-in: the loads accept the real client's shape. +it("satisfies the EngineClient call surface", () => { + expect(typeof loadDialogHarnesses(new FakeCaller() as unknown as EngineClient, null)).toBe("object"); +}); diff --git a/web/packages/app/tests/harness-switch.test.ts b/web/packages/app/tests/harness-switch.test.ts new file mode 100644 index 0000000000..4866a066f1 --- /dev/null +++ b/web/packages/app/tests/harness-switch.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import type { EngineClient } from "@roboco/engine-client"; +import { + harnessSwitchable, + switchHarness, + switchHarnessParams, +} from "../src/lib/harness-switch"; + +/** + * The one-way harness switch on the wire (t3-orchestration-v2 ticket 24) — + * the params builder, the unlock gate, and the RPC wrapper the composer's + * unlocked picker pick and the Limited banner's "Continue on another + * harness" (ticket 25) both go through. The payload shapes port the + * desktop's `switch_chat_harness` / `continue_on_harness.rs` + * `switch_params` tests. + */ + +class FakeCaller { + readonly calls: { method: string; params: unknown }[] = []; + #fail = false; + + constructor(fail = false) { + this.#fail = fail; + } + + async call<T>(method: string, params?: unknown): Promise<T> { + this.calls.push({ method, params }); + if (this.#fail) { + throw new Error("The chat is working; switch harnesses between turns"); + } + return {} as T; + } +} + +describe("switchHarnessParams", () => { + it("carries the chat and the harness; the picks ride only when resolved", () => { + expect(switchHarnessParams("chat-1", "pi")).toEqual({ chatId: "chat-1", harness: "pi" }); + expect(switchHarnessParams("chat-1", "codex", { model: "gpt-6-codex" })).toEqual({ + chatId: "chat-1", + harness: "codex", + model: "gpt-6-codex", + }); + expect( + switchHarnessParams("chat-1", "pi", { + model: "glm-5.3", + reasoning: "high", + modelOptions: { context: "1m" }, + }), + ).toEqual({ + chatId: "chat-1", + harness: "pi", + model: "glm-5.3", + reasoning: "high", + modelOptions: { context: "1m" }, + }); + }); + + it("blank and null picks are the engine's default arms, never empty overrides", () => { + expect(switchHarnessParams("chat-1", "pi", { model: " " })).toEqual({ + chatId: "chat-1", + harness: "pi", + }); + expect(switchHarnessParams("chat-1", "pi", { model: null, reasoning: null })).toEqual({ + chatId: "chat-1", + harness: "pi", + }); + expect(switchHarnessParams("chat-1", "pi", { modelOptions: {} })).toEqual({ + chatId: "chat-1", + harness: "pi", + }); + // The sandbox never rides: the RPC keeps the chat's own pick. + expect(switchHarnessParams("chat-1", "pi")).not.toHaveProperty("sandbox"); + }); +}); + +describe("harnessSwitchable", () => { + it("unlocks only an idle existing chat (the desktop's harness_switchable)", () => { + expect(harnessSwitchable(true, false, false)).toBe(true); + // No chat to switch. + expect(harnessSwitchable(false, false, false)).toBe(false); + // A send of its own is still awaiting confirmation. + expect(harnessSwitchable(true, true, false)).toBe(false); + // A turn is in flight (Working or AwaitingInput both count). + expect(harnessSwitchable(true, false, true)).toBe(false); + expect(harnessSwitchable(true, true, true)).toBe(false); + }); +}); + +describe("switchHarness", () => { + it("calls the engine's SwitchHarness RPC with the built params", async () => { + const caller = new FakeCaller(); + await switchHarness(caller, "chat-1", "pi", { model: "glm-5.3" }); + expect(caller.calls).toEqual([ + { + method: "SwitchHarness", + params: { chatId: "chat-1", harness: "pi", model: "glm-5.3" }, + }, + ]); + }); + + it("surfaces a refusal (the engine's wording) to the caller's failure surface", async () => { + const caller = new FakeCaller(true); + await expect(switchHarness(caller, "chat-1", "pi")).rejects.toThrow( + "The chat is working; switch harnesses between turns", + ); + expect(caller.calls).toHaveLength(1); + }); +}); + +// A compile-time stand-in: the wrapper accepts the real client's shape. +it("satisfies the EngineClient call surface", () => { + expect(typeof switchHarness(new FakeCaller() as unknown as EngineClient, "c", "pi")).toBe("object"); +}); diff --git a/web/packages/app/tests/limited-banner.test.ts b/web/packages/app/tests/limited-banner.test.ts index a5df927728..c92317485a 100644 --- a/web/packages/app/tests/limited-banner.test.ts +++ b/web/packages/app/tests/limited-banner.test.ts @@ -30,6 +30,8 @@ const h = vi.hoisted(() => { let candidatesReply: ResumeAccountCandidates | null = null; /** The next ResumeOnAnotherAccount reply. */ let resumeReply: Record<string, unknown> = { type: "switched", accountId: "a2", accountEmail: "bea@example.com" }; + /** The next SwitchHarness reply's behavior (tickets 24/25). */ + let switchFails = false; const client = { async call<T>(method: string, params: Record<string, unknown>): Promise<T> { @@ -47,6 +49,39 @@ const h = vi.hoisted(() => { cliOpensBrowser: true, callbackPort: null, } as T; + case "ListHarnesses": + return [ + { + id: "claude-code", + name: "Claude Code", + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }, + { + id: "codex", + name: "Codex", + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }, + ] as T; + case "ListModels": + return [ + { id: "gpt-6-codex", label: "GPT-6 Codex", description: "the coder", reasoningLevels: [], options: [] }, + { id: "gpt-6-mini", label: "GPT-6 Mini", reasoningLevels: [], options: [] }, + ] as T; + case "SwitchHarness": + if (switchFails) { + throw new Error("The chat is working; switch harnesses between turns"); + } + return {} as T; default: return {} as T; } @@ -62,10 +97,14 @@ const h = vi.hoisted(() => { setResumeReply(next: Record<string, unknown>): void { resumeReply = next; }, + setSwitchFails(next: boolean): void { + switchFails = next; + }, reset(): void { calls.length = 0; candidatesReply = null; resumeReply = { type: "switched", accountId: "a2", accountEmail: "bea@example.com" }; + switchFails = false; }, }; }); @@ -124,8 +163,12 @@ interface MountedBanner { banner(): HTMLDivElement | null; /** The failover picker's chip (the trigger), when the list offers it. */ failoverChip(): HTMLButtonElement | null; + /** The "Continue on another harness" chip (tickets 25/26). */ + continueChip(): HTMLButtonElement | null; /** The failover card, wherever it portals (open only). */ card(): HTMLElement | null; + /** The continue picker's card, wherever it portals (open only). */ + continueCard(): HTMLElement | null; pressChip(label: string): void; unmount(): void; } @@ -134,19 +177,23 @@ function mountBanner( limit: SessionLimit | null, scheduled: ScheduledResume | null = null, onResume: () => void = () => {}, + fields: { currentHarness?: "claude-code" | "codex" | null; chatId?: string } = {}, ): MountedBanner { const host = document.createElement("div"); document.body.appendChild(host); const root: Root = createRoot(host); + const chatId = fields.chatId ?? "chat-1"; act(() => { root.render( createElement(LimitedBanner, { - chatId: "chat-1", + chatId, client: h.client, limit, scheduled, now: NOW, onResume, + currentHarness: fields.currentHarness ?? "claude-code", + targetDeviceId: null, }), ); }); @@ -157,14 +204,20 @@ function mountBanner( host.remove(); }; mounted.push(unmount); - const banner = (): HTMLDivElement | null => document.querySelector<HTMLDivElement>(".limited-banner"); + const banner = (): HTMLDivElement | null => + document.querySelector<HTMLDivElement>(".limited-banner"); return { banner, failoverChip: () => Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( (chip) => chip.textContent === "Resume on another account", ) ?? null, + continueChip: () => + Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( + (chip) => chip.textContent === "Continue on another harness", + ) ?? null, card: () => document.querySelector<HTMLElement>(".failover-card"), + continueCard: () => document.querySelector<HTMLElement>(".continue-card"), pressChip(label: string): void { const chip = Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( (candidate) => candidate.textContent === label, @@ -386,7 +439,195 @@ describe("LimitedBanner — Resume on another account", () => { }); }); -// ── The strip mounts the banner (the chat page's wiring) ─────────────────── +// ── "Continue on another harness" (tickets 25/26) ───────────────────── + +describe("LimitedBanner — Continue on another harness", () => { + it("offers the chip on every Limited chat, whatever the harness (story 55's escape hatch)", async () => { + const handle = mountBanner({ harness: "cursor", window: "usage", resetsAt: null, accountId: null }); + await flush(); + expect(handle.continueChip()).not.toBeNull(); + expect(handle.continueChip()!.title).toBe( + "Switch this chat to another harness and continue there", + ); + }); + + it("the card offers the enabled harnesses minus the current one, defaulting to the first", async () => { + const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); + await flush(); + press(handle.continueChip()!); + await flush(); + const card = handle.continueCard(); + expect(card).not.toBeNull(); + expect(card!.querySelector(".menu-heading")!.textContent).toBe("Continue on another harness"); + // Claude Code is the current harness: only Codex is offered, and it is + // the resolved default (the first offered once the catalog lands). + const chips = Array.from(card!.querySelectorAll<HTMLButtonElement>(".harness-choice-chip")); + expect(chips.map((chip) => chip.textContent)).toEqual(["Codex"]); + expect(chips[0]!.getAttribute("data-selected")).toBe("1"); + // The model list for the default pick, with the Default tag on the + // first row (a null model means the default at run time). + const rows = Array.from(card!.querySelectorAll(".harness-choice-model-row")); + expect(rows).toHaveLength(2); + expect(rows[0]!.textContent).toContain("Default"); + }); + + it("confirming switches the harness first, then hands the composer the switched picks for the continuation", async () => { + const resumed: unknown[][] = []; + const handle = mountBanner( + claudeLimit({ resetsAt: NOW + 60_000 }), + null, + (...args: unknown[]) => resumed.push(args), + ); + await flush(); + press(handle.continueChip()!); + await flush(); + // Pick a model so the switch's config write carries it. + press(Array.from(handle.continueCard()!.querySelectorAll<HTMLButtonElement>(".harness-choice-model-row"))[1]!); + await flush(); + const confirm = Array.from(handle.continueCard()!.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Continue", + )!; + press(confirm); + await flush(); + expect(h.calls.find((call) => call.method === "SwitchHarness")?.params).toEqual({ + chatId: "chat-1", + harness: "codex", + model: "gpt-6-mini", + }); + // The continuation went out AFTER the switch landed — through the + // composer's send path carrying the SWITCHED picks (the desktop's row + // stamp: the chats-watch frame can trail the switch's reply), so no + // other RPC fires from the banner. + expect(resumed).toEqual([ + [{ harness: "codex", model: "gpt-6-mini", reasoning: null, modelOptions: {} }], + ]); + expect(h.calls.filter((call) => call.method === "Mutate")).toEqual([]); + // The card closed (the switch's seam + the run are the feedback). + expect(handle.continueCard()).toBeNull(); + }); + + it("a refused switch never sends the continuation — the reason rides the notice line", async () => { + h.setSwitchFails(true); + const resumed: unknown[][] = []; + const handle = mountBanner( + claudeLimit({ resetsAt: NOW + 60_000 }), + null, + (...args: unknown[]) => resumed.push(args), + ); + await flush(); + press(handle.continueChip()!); + await flush(); + press( + Array.from(handle.continueCard()!.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Continue", + )!, + ); + await flush(); + expect(h.calls.find((call) => call.method === "SwitchHarness")).toBeDefined(); + expect(resumed).toEqual([]); + expect(handle.banner()!.querySelector(".limited-banner-notice")!.textContent).toBe( + "Could not switch the harness: The chat is working; switch harnesses between turns", + ); + // The banner stays up for another try — still Limited, still on its + // old harness. + expect(handle.continueChip()).not.toBeNull(); + }); + + it("a selection that moved while the switch was in flight never fires the continuation", async () => { + // The banner re-keys through props (no remount): the guard compares + // the live chat id against the one the switch started for — the + // switched chat keeps its banner and Resume, never sending another + // chat's continuation into it (the desktop's still_selected rule). + let release: (() => void) | null = null; + const slow = { + async call<T>(method: string, params: Record<string, unknown>): Promise<T> { + h.calls.push({ method, params }); + if (method === "SwitchHarness") { + await new Promise<void>((resolve) => { + release = resolve; + }); + return {} as T; + } + if (method === "ListHarnesses") { + return [ + { + id: "claude-code", + name: "Claude Code", + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }, + { + id: "codex", + name: "Codex", + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed: true, + canInstall: false, + enabled: true, + }, + ] as T; + } + return {} as T; + }, + } as unknown as EngineClient; + + const host = document.createElement("div"); + document.body.appendChild(host); + const root: Root = createRoot(host); + const resumed: string[] = []; + const render = (chatId: string): void => { + act(() => { + root.render( + createElement(LimitedBanner, { + chatId, + client: slow, + limit: claudeLimit({ resetsAt: NOW + 60_000 }), + scheduled: null, + now: NOW, + onResume: (switched?: { readonly harness?: string }) => { + // The override never fires into the moved selection either. + resumed.push(switched?.harness ?? chatId); + }, + currentHarness: "claude-code", + targetDeviceId: null, + }), + ); + }); + }; + render("chat-1"); + mounted.push(() => { + act(() => { + root.unmount(); + }); + host.remove(); + }); + await flush(); + const chip = Array.from(document.querySelectorAll<HTMLButtonElement>(".limited-banner-chip")).find( + (button) => button.textContent === "Continue on another harness", + )!; + press(chip); + await flush(); + press( + Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find( + (button) => button.textContent === "Continue", + )!, + ); + await flush(); + // The selection moves mid-switch; the switch then lands. + render("chat-2"); + await flush(); + release!(); + await flush(); + expect(resumed).toEqual([]); + }); +}); + +// ── The strip mounts the banner (the chat page's wiring) ─────────────── describe("StatusStrip — the Limited arm", () => { it("renders the banner instead of the error word when the chat reads Limited", async () => { @@ -407,6 +648,8 @@ describe("StatusStrip — the Limited arm", () => { scheduled: null, now: NOW, onResume: () => {}, + currentHarness: "claude-code", + targetDeviceId: null, }, }, ), diff --git a/web/packages/app/tests/limited.test.ts b/web/packages/app/tests/limited.test.ts index 19bef8ad0f..558ce8302a 100644 --- a/web/packages/app/tests/limited.test.ts +++ b/web/packages/app/tests/limited.test.ts @@ -3,6 +3,7 @@ import type { EngineClient } from "@roboco/engine-client"; import type { AgentAccount, AgentUsageWindow, + HarnessDescriptor, HarnessId, ResumeAccountCandidates, } from "@roboco/proto"; @@ -10,6 +11,7 @@ import { RESUME_CONTINUE_PROMPT, cancelScheduledResume, candidateResetCaption, + continueOfferedHarnesses, failoverCardTitle, failoverListOffers, failoverOutcomeNotice, @@ -145,6 +147,38 @@ function candidates(fields: Partial<ResumeAccountCandidates> = {}): ResumeAccoun }; } +// ── "Continue on another harness" (tickets 25/26) ───────────────────── + +describe("continueOfferedHarnesses", () => { + const descriptor = (id: HarnessId, installed = true): HarnessDescriptor => ({ + id, + name: id, + supportsSteering: true, + steeringMode: "step-boundary", + reasoningLevels: [], + installed, + canInstall: false, + enabled: true, + }); + + it("offers the composer's offered set minus the chat's current harness", () => { + const catalog = [ + descriptor("claude-code"), + descriptor("codex"), + descriptor("pi"), + // Present but not installed: offered_harnesses drops it. + descriptor("grok", false), + ]; + const offered = continueOfferedHarnesses(catalog, "claude-code"); + expect(offered.map((row) => row.id)).toEqual(["codex", "pi"]); + // A config-less chat (no current harness): the whole offered set. + expect(continueOfferedHarnesses(catalog, null)).toHaveLength(3); + // The current harness is the ONLY other one: nothing to offer. + expect(continueOfferedHarnesses([descriptor("claude-code")], "claude-code")).toEqual([]); + expect(continueOfferedHarnesses([], "pi")).toEqual([]); + }); +}); + describe("harnessOffersFailover / failoverCardTitle", () => { it("only the switchable harnesses offer the account failover (ticket 09)", () => { expect(harnessOffersFailover("claude-code")).toBe(true); diff --git a/web/packages/app/tests/side-chat-actions.test.ts b/web/packages/app/tests/side-chat-actions.test.ts index 4fbc6bcb3a..11bbf26f55 100644 --- a/web/packages/app/tests/side-chat-actions.test.ts +++ b/web/packages/app/tests/side-chat-actions.test.ts @@ -100,6 +100,41 @@ describe("forkSideChat", () => { expect(params).not.toHaveProperty("parentChatId"); expect(params).not.toHaveProperty("targetDeviceId"); }); + + it("rides the ticket 21/22 additions only when chosen — boundary, harness, model", async () => { + const caller = new FakeCaller(parent()); + await forkSideChat(caller, { + chatId: "fork-9", + sourceChatId: "source-1", + boundary: "a1", + harness: "pi", + model: "glm-5.3", + targetDeviceId: "dev-1", + }); + expect(caller.calls[0]!.params).toEqual({ + chatId: "fork-9", + sourceChatId: "source-1", + boundary: "a1", + harness: "pi", + model: "glm-5.3", + targetDeviceId: "dev-1", + }); + }); + + it("blank strings are absent picks, never empty overrides (the dialog hands through whatever it holds)", async () => { + const caller = new FakeCaller(parent()); + await forkSideChat(caller, { + chatId: "fork-9", + sourceChatId: "source-1", + boundary: " ", + model: "", + }); + const params = caller.calls[0]!.params as Record<string, unknown>; + expect(params).toEqual({ chatId: "fork-9", sourceChatId: "source-1" }); + expect(params).not.toHaveProperty("boundary"); + expect(params).not.toHaveProperty("model"); + expect(params).not.toHaveProperty("harness"); + }); }); describe("mintUnsavedSideChat (the first send's createChat, upstream #568)", () => { diff --git a/web/packages/app/tests/transcript-fork-seam.test.ts b/web/packages/app/tests/transcript-fork-seam.test.ts index 9e6e836a3d..ee450a46e9 100644 --- a/web/packages/app/tests/transcript-fork-seam.test.ts +++ b/web/packages/app/tests/transcript-fork-seam.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MessagePart, SessionMessageEntry } from "@roboco/proto"; import { parseMarkdown } from "../src/lib/markdown"; -import { fnv1a, rowsForEntry, singleLine } from "../src/lib/transcript"; +import { fnv1a, rowsForEntry, singleLine, switchedToLabel } from "../src/lib/transcript"; /** * The fork seam (ticket 10) — `MessagePart::Fork` → the `ForkMarker` row @@ -10,6 +10,10 @@ import { fnv1a, rowsForEntry, singleLine } from "../src/lib/transcript"; * source title, one-lined so a long title can never widen the divider. * The seam is the FIRST entry of a forked chat's doc (system role, * complete) — copied history above it, the fork's own turns below. + * + * The same file carries the seams and hover actions of orchestration v2's + * tickets 21+24: the "Switched to <harness>" divider and the + * `forkEntry` stamp every settled assistant reply's hover strip offers. */ const parse = (_key: string, text: string, live: boolean) => parseMarkdown(text, live); @@ -103,3 +107,120 @@ describe("the fork seam row", () => { expect(rows[0]!.compactFold).toBeNull(); }); }); + +// ── The harness-switch seam (ticket 24) ───────────────────────────────── + +describe("the harness-switch seam row", () => { + const switchPart = (id: string, harness: "codex" | "pi"): MessagePart => ({ + kind: "harnessSwitch", + id, + harness, + }); + + it("emits one harnessSwitchMarker row with the pre-resolved label", () => { + const rows = rowsForEntry(entry("switch-1", [switchPart("switch:chat-9", "codex")]), { + parse, + }); + expect(rows).toHaveLength(1); + const row = rows[0]!; + expect(row.rowKind).toEqual({ kind: "harnessSwitchMarker", label: "Switched to Codex" }); + expect(row.id).toBe("switch-1#switch:chat-9"); + expect(row.turnStart).toBe(true); + // No message metadata lane: a quiet divider, never a hover target — + // exactly the fork seam's exclusion. + expect(row.timestamp).toBeNull(); + expect(row.copyText).toBeNull(); + expect(row.forkEntry).toBeUndefined(); + }); + + it("versions off the label and one-lines it — the seam is fixed once written", () => { + expect(switchedToLabel("pi")).toBe("Switched to Pi"); + expect(switchedToLabel("claude-code")).toBe("Switched to Claude Code"); + const first = rowsForEntry(entry("e", [switchPart("f", "codex")]), { parse })[0]!; + const replay = rowsForEntry(entry("e", [switchPart("f", "codex")]), { parse })[0]!; + expect(replay.version).toBe(first.version); + expect(replay.version).toBe(fnv1a("Switched to Codex")); + const other = rowsForEntry(entry("e", [switchPart("f", "pi")]), { parse })[0]!; + expect(other.version).not.toBe(first.version); + }); + + it("sits between the old harness's turns and the new one's, in document order", () => { + const before: MessagePart = { kind: "text", id: "t1", text: "An old turn" }; + const seam: MessagePart = switchPart("switch:c2", "pi"); + const after: MessagePart = { kind: "text", id: "t2", text: "A new turn" }; + const rows = [ + ...rowsForEntry(entry("e1", [before], { role: "assistant" }), { parse }), + ...rowsForEntry(entry("e2", [seam]), { parse }), + ...rowsForEntry(entry("e3", [after], { role: "user" }), { parse }), + ]; + const kinds = rows.map((row) => + row.rowKind.kind === "harnessSwitchMarker" + ? "switch" + : row.rowKind.kind === "markdown" + ? "text" + : row.rowKind.kind === "user" + ? "user" + : row.rowKind.kind, + ); + expect(kinds).toEqual(["text", "switch", "user"]); + }); + + it("renders in compact mode too — the seam is never a folded work part", () => { + const rows = rowsForEntry(entry("e", [switchPart("f", "codex")]), { parse, compact: true }); + expect(rows).toHaveLength(1); + expect(rows[0]!.rowKind.kind).toBe("harnessSwitchMarker"); + }); +}); + +// ── "Fork from here" (ticket 21): the settled assistant rows' stamp ───── + +describe("forkEntry — the hover action's stamp", () => { + const reply = (id: string, status: "complete" | "streaming" | "aborted"): SessionMessageEntry => + entry(id, [{ kind: "text", id: `${id}#t`, text: "A reply" }], { + role: "assistant", + status, + }); + + it("lands on the settled assistant reply's last row, beside the timestamp lane", () => { + const rows = rowsForEntry(reply("a1", "complete"), { parse }); + expect(rows).toHaveLength(1); + expect(rows[0]!.forkEntry).toBe("a1"); + expect(rows[0]!.timestamp).not.toBeNull(); + // Errored and interrupted replies (aborted status) qualify too. + expect(rowsForEntry(reply("a2", "aborted"), { parse })[0]!.forkEntry).toBe("a2"); + }); + + it("a streaming reply never carries it — the run is still writing it", () => { + const rows = rowsForEntry(reply("a3", "streaming"), { parse }); + expect(rows[0]!.forkEntry).toBeUndefined(); + expect(rows[0]!.timestamp).toBeNull(); + }); + + it("user and system entries never carry it", () => { + const user = entry("u1", [{ kind: "text", id: "u1#t", text: "A question" }], { + role: "user", + }); + expect(rowsForEntry(user, { parse })[0]!.forkEntry).toBeUndefined(); + const seam = entry("s1", [forkPart("f", "src", "Source")], { role: "system" }); + expect(rowsForEntry(seam, { parse })[0]!.forkEntry).toBeUndefined(); + }); + + it("compact mode's folded work rows never carry it — only the entry's own last settled row", () => { + const withWork: SessionMessageEntry = entry( + "a4", + [ + { kind: "tool", id: "a4#tool", call: { kind: "exec", command: "ls" }, isError: false, resolved: true }, + { kind: "text", id: "a4#t", text: "The reply" }, + ], + { role: "assistant", status: "complete" }, + ); + const rows = rowsForEntry(withWork, { parse, compact: true }); + // The folded work rows are tagged with the fold and stripped. + const folded = rows.filter((row) => row.compactFold !== null); + expect(folded.length).toBeGreaterThan(0); + expect(folded.every((row) => row.forkEntry === undefined)).toBe(true); + // The visible reply rows keep it only on the entry's LAST row. + const visible = rows.filter((row) => row.compactFold === null); + expect(visible[visible.length - 1]!.forkEntry).toBe("a4"); + }); +}); diff --git a/web/packages/engine-client/src/methods.ts b/web/packages/engine-client/src/methods.ts index e8e1d13cc0..3f1198df7f 100644 --- a/web/packages/engine-client/src/methods.ts +++ b/web/packages/engine-client/src/methods.ts @@ -96,13 +96,27 @@ export const QUEUE_COMMAND = "QueueCommand"; export const RETRY_DELIVERY = "RetryDelivery"; /** * Side-chat fork (crates/engine/src/rpc.rs `FORK_SIDE_CHAT`): mints a child - * chat by copying the source's transcript through its latest completed - * response and stamping the `Forked from` seam part. Params - * `{chatId, sourceChatId, parentChatId?, targetDeviceId?}` — the reply is - * the fresh `Chat`. `parentChatId` defaults to the source (a side chat's - * own fork passes its parent so the copy lists as a sibling). + * chat by copying the source's transcript through a chosen reply and + * stamping the `Forked from` seam part. Params `{chatId, sourceChatId, + * parentChatId?, boundary?, harness?, model?, targetDeviceId?}` — the + * reply is the fresh `Chat`. `parentChatId` defaults to the source (a side + * chat's own fork passes its parent so the copy lists as a sibling); + * `boundary` (ticket 21, "Fork from here") picks the settled assistant + * entry the copy stops at — any of them, errored and interrupted included + * — defaulting to the latest complete reply; `harness`/`model` (ticket + * 22) override what the copy runs on, a cross-harness fork taking the + * budgeted history instead of a native session fork. */ export const FORK_SIDE_CHAT = "ForkSideChat"; +/** The one-way harness switch (t3-orchestration-v2 ticket 24, crates/rpc + * `SWITCH_HARNESS`): `{chatId, harness, model?, reasoning?, modelOptions?}` + * → `{}`. The engine writes the "Switched to <harness>" seam into the + * transcript, drops the chat's harness session, and saves the arrived + * picks — model options reset to the new harness's defaults unless the + * user chose them. Refused while the chat works; must run on the chat's + * host device. The Limited banner's "Continue on another harness" + * (ticket 25) performs the same switch before its continue prompt. */ +export const SWITCH_HARNESS = "SwitchHarness"; /** Message-queue surface (crates/engine/rpc.rs §3.5). The queue lives on the chat doc; * `WatchQueue` streams `{items}` snapshots, the rest are mutations that require * an explicit ack so a racing device's row never silently moves. Edit leases From cc2a2c925157ecb027b914aecb05e873709095bc Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 06:55:00 +0700 Subject: [PATCH 63/96] fix(proto): camelCase the switched failover outcome's fields --- crates/proto/src/entities.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/proto/src/entities.rs b/crates/proto/src/entities.rs index a998ad7e7f..2792138387 100644 --- a/crates/proto/src/entities.rs +++ b/crates/proto/src/entities.rs @@ -1132,6 +1132,7 @@ pub struct ResumeAccountCandidates { pub enum ResumeOnAccountOutcome { /// The switch landed and the continuation was dispatched: the chat /// resumes its harness session under `account_id`. + #[serde(rename_all = "camelCase")] Switched { account_id: String, /// The row's email (or display name) — the "Resumed on …" label. From 8db774b0e07d6d6f63123191fd802f183d5cb5ab Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 07:18:56 +0700 Subject: [PATCH 64/96] fix(ui): keep the picker switch test's entities alive until the frame lands --- crates/ui/src/pickers.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 568a6c5b5b..745d103414 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -6261,7 +6261,10 @@ mod tests { let _guard = runtime.enter(); let (out, mut requests) = tokio::sync::mpsc::channel::<String>(256); let (_replies, inbound) = tokio::sync::mpsc::channel::<String>(256); - cx.update(|cx| { + // The handles outlive the update: dropping them would release the + // state (and its test engine's client) and the pickers (and the + // switch task) before the frame is ever written. + let (_state, _pickers) = cx.update(|cx| { cx.set_global(Theme::dark()); let (state, pickers) = working_chat(roboco_proto::SessionStatus::Idle, cx); state.update(cx, |state, _| { @@ -6291,6 +6294,7 @@ mod tests { .insert("context".into(), serde_json::Value::String("1m".into())); pickers.pick_harness(HarnessId::Codex, cx); }); + (state, pickers) }); cx.run_until_parked(); // `EngineTarget::call` spawns onto the registry's runtime handle — From 732abc146db4859e7d5397185c54540a5d9ebe1b Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 07:19:20 +0700 Subject: [PATCH 65/96] fix(ui): the notice short id keeps eight characters --- crates/ui/src/transcript.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index 18658a4d97..e341062350 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -10463,7 +10463,7 @@ mod tests { /// outcome line appends its reason, and every outcome has a verb. #[test] fn notice_side_row_helpers_shape_the_card_lines() { - assert_eq!(short_chat_id("chat-side-1234"), "chat-si"); + assert_eq!(short_chat_id("chat-side-1234"), "chat-sid"); assert_eq!(short_chat_id("short"), "short"); // A multibyte id boundary never panics and still yields whole // characters. From 26f1229ad1b2521cbe1639c7f8e270e1403694c3 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 07:29:12 +0700 Subject: [PATCH 66/96] fix(harness): settle the opencode fake-server compile slips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ticket-20 fake's three fixtures had compile errors CI caught (ui-tests run 38983887086, engine-local-regressions): - message_list declared Option<Value> but the messages map holds Vec<Value> — return the Vec and let the 1.x arm wrap it (Value::Array) and the 2.x arm serialize it into {data, cursor}; the declared type was also what made Value::Array(list) a double-wrap at the 1.x route. - fork_outcome and the paged 2.x message lookup returned references into temporary MutexGuards out of and_then closures (E0515) — clone inside the closure so the owned tuple/pages escape; the match arm then simplifies to (status, body). Swept the rest of the new test code for the same guard-escape and type-mismatch patterns: every other .lock() is a let-binding, an owned clone, or a pre-existing insert/deref shape. No builds run locally per the verification policy; CI is the signal. --- crates/harness/tests/opencode.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index 6093466446..dfa8e28cb0 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -296,7 +296,7 @@ impl FakeOpencode { /// The message list for a session's boundary resolution, when the fake /// has one (unlisted sessions 404 — the fork's resolution fallback). - fn message_list(&self, path: &str, prefix: &str) -> Option<Value> { + fn message_list(&self, path: &str, prefix: &str) -> Option<Vec<Value>> { let id = Self::session_id_of(path, prefix)?; self.messages.lock().unwrap().get(&id).cloned() } @@ -307,9 +307,9 @@ impl FakeOpencode { let id = Self::session_id_of(path, prefix); match id .as_deref() - .and_then(|id| self.forks.lock().unwrap().get(id)) + .and_then(|id| self.forks.lock().unwrap().get(id).cloned()) { - Some((status, body)) => (*status, body.clone()), + Some((status, body)) => (status, body), None => ("404 Not Found", json!({})), } } @@ -410,8 +410,7 @@ impl FakeOpencode { let session = Self::session_id_of(p, "/api/session/"); let pages = session .as_deref() - .and_then(|id| self.paged_messages.lock().unwrap().get(id)) - .cloned(); + .and_then(|id| self.paged_messages.lock().unwrap().get(id).cloned()); if let Some(pages) = pages { let index = query .split('&') From 1e299bf14d6e7d9c3a53ffa1fe75f3d84b97220a Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 07:40:54 +0700 Subject: [PATCH 67/96] fix(web): settle the typecheck after the ticket 15/26 merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web-codegen job (tsc --noEmit over src + tests) failed on 732abc146 with nine errors; two came from the ticket-26 code, the rest from the branch's accumulated web tests that the job had never seen green. Ticket 26's own: - limited-banner.tsx: the widened resume handle ((switched?: Partial< DraftConfig>) => void) is no longer directly assignable to the plain Resume chip's onClick — wrap it so the MouseEvent can't ride the override parameter. - transcript.tsx: estimateRowHeight's switch missed the new harnessSwitchMarker row kind (TS2366) — add its estimate (py(14) + the one 13px line, measurement corrects on mount), matching the completionNotice arm ticket 15 added. Pre-existing on the branch: - tests/limited-banner.test.ts: type the failover rows query (querySelectorAll<HTMLElement>) so press() takes Elements with the right type. - tests/accounts-view.test.ts: the legacy sign-in-state delete needs an optional property (Partial<AgentAccount>), not an intersection that stays required (TS2790). - tests/transcript-completion-notice-control.test.ts: toHaveLength takes one argument (TS2554) — the message moves to a comment. - tests/transcript-model.test.ts: the row() fixture's ternary chain needs arms for the harnessSwitchMarker and completionNotice kinds the union gained (TS2322). - tests/view.test.ts: the SessionLimit literal needs its type annotation so harness narrows to HarnessId (TS2322). Not run locally (the no-build override); CI on the branch is the signal. --- web/packages/app/src/components/limited-banner.tsx | 2 +- web/packages/app/src/components/transcript.tsx | 4 ++++ web/packages/app/tests/accounts-view.test.ts | 4 ++-- web/packages/app/tests/limited-banner.test.ts | 4 ++-- .../app/tests/transcript-completion-notice-control.test.ts | 2 +- web/packages/app/tests/transcript-model.test.ts | 6 +++++- web/packages/app/tests/view.test.ts | 4 ++-- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/web/packages/app/src/components/limited-banner.tsx b/web/packages/app/src/components/limited-banner.tsx index 642854bc39..34fc12299a 100644 --- a/web/packages/app/src/components/limited-banner.tsx +++ b/web/packages/app/src/components/limited-banner.tsx @@ -256,7 +256,7 @@ export function LimitedBanner({ className="limited-banner-chip" title="Send the continue prompt now" disabled={chipsDisabled} - onClick={onResume} + onClick={() => onResume()} > Resume </button> diff --git a/web/packages/app/src/components/transcript.tsx b/web/packages/app/src/components/transcript.tsx index 9430d0bde5..af80be3276 100644 --- a/web/packages/app/src/components/transcript.tsx +++ b/web/packages/app/src/components/transcript.tsx @@ -2026,6 +2026,10 @@ export function estimateRowHeight( // + gap(6) + the 13px title line — an estimate the measurement // corrects on mount. return 67; + case "harnessSwitchMarker": + // `harness_switch_marker` (transcript.rs:8580): py(14) + the one 13px + // medium line — an estimate the measurement corrects on mount. + return 47; case "completionNotice": // `render_completion_notice` (transcript.rs): the wrapper pads (4+4), // the card's own (8+8), the 20px header, the 4px gap, and one 42px diff --git a/web/packages/app/tests/accounts-view.test.ts b/web/packages/app/tests/accounts-view.test.ts index fa9fc36f50..8fe9313f90 100644 --- a/web/packages/app/tests/accounts-view.test.ts +++ b/web/packages/app/tests/accounts-view.test.ts @@ -421,9 +421,9 @@ describe("signedOut / accountNote / usageFallback (ticket 08)", () => { expect(signedOut(account({ signInState: "signed-in" }))).toBe(false); // An older engine's payload without the additive field defaults to // unknown, never signed out. - const legacy = account({}) as AgentAccount & { signInState?: unknown }; + const legacy: Partial<AgentAccount> = { ...account({}) }; delete legacy.signInState; - expect(signedOut(legacy)).toBe(false); + expect(signedOut(legacy as AgentAccount)).toBe(false); }); it("accountNote: the probe's reason, else the sign-out itself, else nothing", () => { diff --git a/web/packages/app/tests/limited-banner.test.ts b/web/packages/app/tests/limited-banner.test.ts index c92317485a..e6d32b1514 100644 --- a/web/packages/app/tests/limited-banner.test.ts +++ b/web/packages/app/tests/limited-banner.test.ts @@ -372,7 +372,7 @@ describe("LimitedBanner — Resume on another account", () => { const card = handle.card(); expect(card).not.toBeNull(); expect(card!.querySelector(".menu-heading")!.textContent).toBe("Resume on another Claude account"); - const rows = Array.from(card!.querySelectorAll(".menu-row")); + const rows = Array.from(card!.querySelectorAll<HTMLElement>(".menu-row")); expect(rows.length).toBe(2); // The first candidate is the recommendation; the caption says what // switching means (story 53). @@ -404,7 +404,7 @@ describe("LimitedBanner — Resume on another account", () => { const handle = mountBanner(claudeLimit({ resetsAt: NOW + 60_000 })); await flush(); press(handle.failoverChip()!); - press(Array.from(handle.card()!.querySelectorAll(".menu-row"))[0]!); + press(Array.from(handle.card()!.querySelectorAll<HTMLElement>(".menu-row"))[0]!); await flush(); expect(handle.banner()!.querySelector(".limited-banner-notice")!.textContent).toBe("engine offline"); }); diff --git a/web/packages/app/tests/transcript-completion-notice-control.test.ts b/web/packages/app/tests/transcript-completion-notice-control.test.ts index 824e96e265..b6eef7f0cb 100644 --- a/web/packages/app/tests/transcript-completion-notice-control.test.ts +++ b/web/packages/app/tests/transcript-completion-notice-control.test.ts @@ -62,7 +62,7 @@ describe("the completion notice's open control", () => { const rows = Array.from( container!.querySelectorAll("button.completion-notice-side"), ); - expect(rows).toHaveLength(2, "one control per side chat"); + expect(rows).toHaveLength(2); // one control per side chat act(() => { rows[1]!.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 }), diff --git a/web/packages/app/tests/transcript-model.test.ts b/web/packages/app/tests/transcript-model.test.ts index 400e423df8..6132a0d014 100644 --- a/web/packages/app/tests/transcript-model.test.ts +++ b/web/packages/app/tests/transcript-model.test.ts @@ -484,7 +484,11 @@ describe("topGapFor / diffRows", () => { ? { kind, header: "", resolved: false } : kind === "forkMarker" ? { kind, sourceChatId: "", sourceTitle: "" } - : { kind, message: "" }; + : kind === "harnessSwitchMarker" + ? { kind, label: "" } + : kind === "completionNotice" + ? { kind, sides: [] } + : { kind, message: "" }; return { id, version: 0, diff --git a/web/packages/app/tests/view.test.ts b/web/packages/app/tests/view.test.ts index 7f302fc881..d508b852c4 100644 --- a/web/packages/app/tests/view.test.ts +++ b/web/packages/app/tests/view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { Chat, Space } from "@roboco/proto"; +import type { Chat, SessionLimit, Space } from "@roboco/proto"; import type { ChatStatus } from "@roboco/engine-client"; import { UNDELIVERED_GRACE_MS, type PendingSend } from "../src/state/transcript-store"; import { @@ -89,7 +89,7 @@ describe("effectiveIndicator", () => { }); it("an_errored_row_with_a_limit_reads_as_limited (proto view.rs)", () => { - const limit = { harness: "claude-code", window: "5-hour", resetsAt: 0, accountId: null }; + const limit: SessionLimit = { harness: "claude-code", window: "5-hour", resetsAt: 0, accountId: null }; expect(effectiveIndicator(status({ status: "errored", limit }), NOW)).toBe("limited"); // The same record on any other status is inert — a row that is working // again (the record cleared on the new turn) never shows a stale From e3ff6d4c01eb29d917ddd255d9a58edd15b9b6b8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 08:14:34 +0700 Subject: [PATCH 68/96] fix(harness): the opencode suite reads past the TurnRef bookkeeping Ticket 20 made the driver emit an AgentEvent::TurnRef ahead of every turn's content. Nine pre-existing tests read the stream positionally (the first content event after AvailableCommands is now the ref) and the three quiet-gap checks read the stream directly, where the queued ref counted as traffic (UI tests run 38987553109). - next_event now passes over TurnRef events; the raw read lives on as next_event_with_refs. Every content-order assertion keeps its teeth: the skipping reader still surfaces any real event that arrives, and a settle in a quiet gap still fails the gap check. - The quiet-gap checks (thinking, foreign idle, duplicate error) read through the skipping helper: the gap stays pending while only refs land, and any content still trips the assertion. - drain_to_done keeps the raw read, so the drained windows still carry the refs: the fork tests' ref-count assertions stay honest, and the suite's drain assertions key on Done/any/last, not on the refs. - The turn-ref and fork tests read their refs through next_event_with_refs and keep asserting them exactly where they sit. - assert_torn_down's stream-end check stays raw on purpose: a ref arriving after the terminal Done would be a bug worth catching. The unit tests in src/opencode/tests.rs need nothing: every event loop there matches with a `_ => {}` arm, and none assert positions. No local builds/tests per the verification policy; CI is the signal. --- crates/harness/tests/opencode.rs | 47 ++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/crates/harness/tests/opencode.rs b/crates/harness/tests/opencode.rs index dfa8e28cb0..9d73250c60 100644 --- a/crates/harness/tests/opencode.rs +++ b/crates/harness/tests/opencode.rs @@ -578,8 +578,25 @@ fn v2_idle(fake: &FakeOpencode, session: &str) { })); } +/// The next event, passing over the driver's `TurnRef` bookkeeping +/// (t3-orchestration-v2 ticket 20): one rides every turn's stream ahead of +/// its content and says nothing about it, so tests that assert on content +/// order read through this. The turn-ref tests themselves use +/// [`next_event_with_refs`] and assert the refs where they sit. async fn next_event( stream: &mut (impl futures::Stream<Item = Result<AgentEvent, HarnessError>> + Unpin), +) -> AgentEvent { + loop { + let event = next_event_with_refs(stream).await; + if !matches!(event, AgentEvent::TurnRef { .. }) { + return event; + } + } +} + +/// The raw next event, `TurnRef` included. +async fn next_event_with_refs( + stream: &mut (impl futures::Stream<Item = Result<AgentEvent, HarnessError>> + Unpin), ) -> AgentEvent { tokio::time::timeout(Duration::from_secs(10), stream.next()) .await @@ -603,13 +620,16 @@ async fn wait_posts(fake: &FakeOpencode, path: &str, n: usize) -> Vec<Value> { .unwrap_or_else(|_| panic!("{path} never saw {n} posts")) } -/// Drain until a Done arrives; returns everything seen (Done last). +/// Drain until a Done arrives; returns everything seen (Done last). The +/// refs ride the drained window too — assertions that count them stay +/// honest, and the content-order assertions in this suite key on Done, +/// `any`, or the last element, not on the refs' position. async fn drain_to_done( stream: &mut (impl futures::Stream<Item = Result<AgentEvent, HarnessError>> + Unpin), ) -> Vec<AgentEvent> { let mut events = Vec::new(); loop { - let ev = next_event(stream).await; + let ev = next_event_with_refs(stream).await; let done = matches!(&ev, AgentEvent::Done { .. }); events.push(ev); if done { @@ -684,7 +704,9 @@ async fn thinking_streams_and_the_turn_settles_only_on_idle() { })); let text = next_event(&mut stream).await; assert!(matches!(&text, AgentEvent::TextDelta { text } if text == "Hello")); - let quiet = tokio::time::timeout(Duration::from_millis(600), stream.next()).await; + // Nothing but TurnRef bookkeeping may arrive in the quiet gap — the + // skipping reader keeps the future pending while only refs land. + let quiet = tokio::time::timeout(Duration::from_millis(600), next_event(&mut stream)).await; assert!(quiet.is_err(), "nothing may settle a quiet-but-live turn"); idle(&fake, "ses_test"); @@ -761,7 +783,10 @@ async fn foreign_session_idle_never_settles_our_turn() { assistant_message(&fake, "ses_test", "msg_1"); idle(&fake, "ses_OTHER"); - let quiet = tokio::time::timeout(Duration::from_millis(600), stream.next()).await; + // Only the turn's own TurnRef may be queued here (ticket 20) — the + // skipping reader holds the gap open for real traffic, which a + // foreign idle never produces. + let quiet = tokio::time::timeout(Duration::from_millis(600), next_event(&mut stream)).await; assert!(quiet.is_err(), "a foreign session's idle settled our turn"); idle(&fake, "ses_test"); @@ -1238,7 +1263,7 @@ async fn session_error_with_no_content_settles_errored() { "data": { "message": "ProviderAuthError: no credentials for anthropic\n at stack" }, }}, })); - let quiet = tokio::time::timeout(Duration::from_millis(400), stream.next()).await; + let quiet = tokio::time::timeout(Duration::from_millis(400), next_event(&mut stream)).await; assert!( quiet.is_err(), "duplicate error must not mint a second chip" @@ -1641,7 +1666,7 @@ async fn prompts_carry_a_client_message_id_and_each_turn_gets_its_ref() { // The ref rides the stream BEFORE the turn's content: the engine // stamps it on the entry the turn is about to stream. - let turn_ref = next_event(&mut stream).await; + let turn_ref = next_event_with_refs(&mut stream).await; assert_eq!( turn_ref, AgentEvent::TurnRef { @@ -1691,7 +1716,7 @@ async fn prompts_carry_a_client_message_id_and_each_turn_gets_its_ref() { let second_id = prompts[1]["messageID"].as_str().unwrap().to_owned(); assert!(second_id.starts_with("msg_"), "{second_id}"); assert_ne!(second_id, first_id); - let turn_ref = next_event(&mut stream).await; + let turn_ref = next_event_with_refs(&mut stream).await; assert_eq!( turn_ref, AgentEvent::TurnRef { @@ -1746,7 +1771,7 @@ async fn v2_prompts_carry_a_client_message_id_and_emit_the_turn_ref() { let message_id = prompts[0]["id"].as_str().unwrap().to_owned(); assert!(message_id.starts_with("msg_"), "{message_id}"); - let turn_ref = next_event(&mut stream).await; + let turn_ref = next_event_with_refs(&mut stream).await; assert_eq!( turn_ref, AgentEvent::TurnRef { @@ -1814,7 +1839,7 @@ async fn a_native_fork_cuts_the_source_session_at_the_next_turn() { assert!(fake.posts_to("/session").is_empty(), "no fresh create"); // The forked turn's ref names the forked session. let message_id = prompts[0]["messageID"].as_str().unwrap().to_owned(); - let turn_ref = next_event(&mut stream).await; + let turn_ref = next_event_with_refs(&mut stream).await; assert_eq!( turn_ref, AgentEvent::TurnRef { @@ -1916,7 +1941,7 @@ async fn a_v2_native_fork_cuts_before_the_next_user_message() { assert_eq!(prompts[0]["text"], "hi"); assert!(fake.posts_to("/api/session").is_empty(), "no fresh create"); let message_id = prompts[0]["id"].as_str().unwrap().to_owned(); - let turn_ref = next_event(&mut stream).await; + let turn_ref = next_event_with_refs(&mut stream).await; assert_eq!( turn_ref, AgentEvent::TurnRef { @@ -2011,7 +2036,7 @@ async fn a_refused_fork_falls_back_to_a_fresh_session_with_the_budgeted_history( let _commands = next_event(&mut stream).await; // The fresh session's turn still emits its ref (the entry the engine // stamps is addressable for a LATER fork of this chat). - let _turn_ref = next_event(&mut stream).await; + let _turn_ref = next_event_with_refs(&mut stream).await; assert!(matches!(&_turn_ref, AgentEvent::TurnRef { .. })); assert_eq!(fake.posts_to("/session").len(), 1, "one fresh create"); // …whose prompt carries the budgeted history in front of the user From 4e5879d83efd93113a5e194ff5fa29bb92f9f995 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 10:45:27 +0700 Subject: [PATCH 69/96] fix(engine): settle the limited-account test compile slips --- crates/engine/src/agent_accounts.rs | 4 ++-- crates/engine/src/engine_settings.rs | 2 +- crates/engine/src/scheduled_resumes.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index c727006118..e87299fb23 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -5442,11 +5442,11 @@ mod probe_tests { let mut signed_out = plain_row(); signed_out.sign_in_state = AgentSignInState::SignedOut; let mut still_limited = plain_row(); - still_limited.limited_until = Some(Utc::now() + TimeDelta::hours(3)); + still_limited.limited_until = Some(Utc::now() + chrono::TimeDelta::hours(3)); let expired_limit = { // A limited_until in the PAST: the window lifted. let mut row = plain_row(); - row.limited_until = Some(Utc::now() - TimeDelta::hours(1)); + row.limited_until = Some(Utc::now() - chrono::TimeDelta::hours(1)); row }; let rows = vec![ diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs index cd660ef190..17327a930b 100644 --- a/crates/engine/src/engine_settings.rs +++ b/crates/engine/src/engine_settings.rs @@ -113,7 +113,7 @@ mod tests { auto_resume_limited: true, }) .unwrap(); - let reloaded = EngineSettingsStore::open(Some(path)); + let reloaded = EngineSettingsStore::open(Some(path.clone())); assert!(reloaded.get().auto_resume_limited); // Back off again. diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 175f07ef74..289fe6d107 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -340,7 +340,7 @@ mod tests { fn requeue_restores_a_consumed_entry_for_the_next_boot() { let path = temp_path(); let store = ScheduledResumes::default(); - store.set_path(path); + store.set_path(path.clone()); store.arm("chat", 5_000); assert!(store.consume("chat", 5_000)); store.requeue("chat", 5_000); From 01849dc2d0b58e9135162d9822b7b68e6c633a1f Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 10:52:27 +0700 Subject: [PATCH 70/96] fix(harness): settle the claude suite CI red after ticket 18 wired it in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ticket 18 wired `cargo test -p roboco-harness --test claude` into ui-tests.yml for the first time ("it was previously not run by any workflow"), which surfaced eight latent failures the suite had been carrying. The TurnRef event is NOT the cause here: this suite asserts by contains/any/last and by the positions of matching events, not by raw stream index, so the refs never shifted an assertion. - Five fork/resume tests (resume-missing from ticket 09, the four fork launches from ticket 18) asserted Done{result: None}. The driver forwards the result frame's text (normalize.rs: result: f.result) and always has — the passing happy-path (Some("done!")) and resume-killed-tasks (Some("PONG")) tests assert exactly that, and the fake emits result text in every one of these scenarios on purpose. The None shape was copied from the codex/opencode suites, whose wire shapes genuinely carry no final text. Assertions now expect the text the turn actually produced; nothing in the spec (stories 51, 57-60) asks a fork or fallback to drop the turn's reply. - eager_done_forwards_wake_turn_as_second_done and ask_user_question_round_trips_through_the_control_channel routed to the fake's "unknown scenario" arm: the #748 port (33ded9af9) rewrote the fixture "ADAPTED, not copied" and dropped the scenario:wake and scenario:askuser arms while keeping their tests. Both arms are restored verbatim (the eager-done/wake shape and the AskUserQuestion round-trip with updatedInput.answers, keyed "Pick one":"B"). - an_unreplayed_steer_releases_the_turn_end timed out in run_to_end's 10 s drain: the absorbed-steer fake parks with `exec sleep 30`, and a parked CLI keeps the event stream open (the session parks with the steering mailbox open — same contract as the live smoke test). The fake now parks 7 s: past the 5 s UNREPORTED_SETTLE release (so the Done provably comes from the quiet fallback, not stdout EOF — the Steered-count assertion keeps that honest) and inside the drain window so the stream ends at EOF. No local builds/tests per the verification policy; CI is the signal. --- crates/harness/tests/claude.rs | 17 ++++-- crates/harness/tests/fixtures/fake-claude.sh | 58 +++++++++++++++++++- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index 29d63d7864..84d1286a12 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -117,11 +117,13 @@ async fn a_resume_without_a_session_file_starts_fresh_with_the_history() { )), "the budgeted history rode in front of the prompt: {events:?}" ); + // The fallback session's own final text rides the Done like any + // turn's (the driver forwards the result frame's text). assert_eq!( events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + result: Some("echoed".into()), error: None, session_id: Some("sess-resume-missing".into()), }) @@ -201,11 +203,13 @@ async fn a_fork_at_the_latest_turn_resumes_the_source_session_with_fork_session( turn_id: "msg-fork-last".into(), } })); + // The forked turn's final text rides the Done like any turn's — the + // driver forwards the result frame's text (happy path: Some("done!")). assert_eq!( events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + result: Some("forked latest".into()), error: None, session_id: Some("sess-fork-latest-new".into()), }) @@ -255,11 +259,12 @@ async fn a_fork_at_an_earlier_turn_passes_the_boundary_uuid_to_resume_session_at turn_id: "msg-fork-earlier-last".into(), } })); + // The forked turn's final text rides the Done like any turn's. assert_eq!( events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + result: Some("forked earlier".into()), error: None, session_id: Some("sess-fork-earlier-new".into()), }) @@ -305,11 +310,12 @@ async fn a_fork_the_cli_refuses_falls_back_to_a_fresh_session_with_the_budgeted_ e, AgentEvent::TextDelta { text } if text == "fresh fallback answer" ))); + // The fallback turn's final text rides the Done like any turn's. assert_eq!( events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + result: Some("fresh fallback".into()), error: None, session_id: Some("sess-fork-fresh".into()), }) @@ -347,11 +353,12 @@ async fn a_fork_of_a_missing_session_falls_back_to_the_budgeted_history() { e, AgentEvent::TextDelta { text } if text == "missing session fallback answer" ))); + // The fallback turn's final text rides the Done like any turn's. assert_eq!( events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + result: Some("missing session fallback".into()), error: None, session_id: Some("sess-fork-missing-fresh".into()), }) diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 38d2beca6c..fe147efae3 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -205,6 +205,57 @@ case "$first" in emit '{"type":"result","subtype":"success","result":"done!","errors":[],"usage":{"input_tokens":10,"output_tokens":20},"session_id":"sess-1","total_cost_usd":0.01}' ;; +*scenario:wake*) + # Eager-done + wake, the live-verified 2.1.228 background-subagent shape: + # the parent turn settles with result #1 while the subagent still runs; + # tagged subagent traffic continues; the CLI then wakes with a second init + # (SAME session id) and settles the wake turn with result #2. + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-wake"}' + emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[{"type":"tool_use","id":"toolu_agent","name":"Agent","input":{"description":"background task","run_in_background":true}}]}}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"LAUNCHED"}}}' + emit '{"type":"result","subtype":"success","result":"LAUNCHED","errors":[],"usage":{"input_tokens":5,"output_tokens":5},"session_id":"sess-wake"}' + # Background subagent interior, after the eager done. + emit '{"type":"stream_event","parent_tool_use_id":"toolu_agent","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"sub working"}}}' + emit '{"type":"assistant","parent_tool_use_id":"toolu_agent","message":{"content":[{"type":"tool_use","id":"sub-t1","name":"Bash","input":{"command":"sleep 1"}}]}}' + emit '{"type":"user","parent_tool_use_id":"toolu_agent","message":{"content":[{"type":"tool_result","tool_use_id":"sub-t1","is_error":false}]}}' + # The wake turn: re-init (deduped), untagged output, second result. + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-wake"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"subagent finished"}}}' + emit '{"type":"result","subtype":"success","result":"wrapped up","errors":[],"usage":{"input_tokens":3,"output_tokens":3},"session_id":"sess-wake"}' + ;; + +*scenario:askuser*) + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":["Bash"],"cwd":"/tmp","session_id":"sess-ask"}' + # A plain tool permission request: must be auto-allowed. + emit '{"type":"control_request","request_id":"cr-0","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"ls"}}}' + read -r resp0 || exit 1 + case "$resp0" in + *'"request_id":"cr-0"'*'"behavior":"allow"'*) ;; + *) + emit '{"type":"result","subtype":"error_during_execution","errors":["bash tool was not allowed"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-ask"}' + exit 0 + ;; + esac + # AskUserQuestion: must be intercepted and answered via updatedInput.answers. + emit '{"type":"control_request","request_id":"cr-1","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"header":"Choice","question":"Pick one","options":["A","B"],"multiSelect":false}]}}}' + read -r resp1 || exit 1 + case "$resp1" in + *'"behavior":"allow"'*) + case "$resp1" in + *'"Pick one":"B"'*) + emit '{"type":"result","subtype":"success","result":"answered","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-ask"}' + ;; + *) + emit '{"type":"result","subtype":"error_during_execution","errors":["answers missing from updatedInput"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-ask"}' + ;; + esac + ;; + *) + emit '{"type":"result","subtype":"error_during_execution","errors":["AskUserQuestion was denied"],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-ask"}' + ;; + esac + ;; + *scenario:steer*) fid=$(uuid_of "$first") emit "{\"type\":\"command_lifecycle\",\"command_uuid\":\"$fid\",\"state\":\"queued\"}" @@ -424,7 +475,12 @@ case "$first" in read -r steer || exit 1 emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"absorbed"}}}' emit '{"type":"result","subtype":"success","result":"absorbed","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-abs"}' - exec sleep 30 + # Park long enough to prove the quiet fallback released the held result + # (UNREPORTED_SETTLE, 5 s) while the CLI is still alive — exiting sooner + # would release it from EOF instead, and the test's Steered count would + # catch that — but still inside run_to_end's 10 s drain budget, so the + # stream ends without timing the test out. + exec sleep 7 ;; *scenario:stop-in-place*) From 1ce8f33506f6c6856f686677da283b3462ebaefb Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 11:15:20 +0700 Subject: [PATCH 71/96] =?UTF-8?q?fix(engine):=20code-review=20group=20A=20?= =?UTF-8?q?=E2=80=94=20completion=20notices,=20limits,=20forks,=20restart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A1 parked loop-cap notice releases as the System completion-notice entry (queue rows carry the notice's sides, additive; ADR 0005) A2 capped outcomes merge into the one parked row A3 only a user-origin Run (or Resume / explicit queue action) thaws the cap's queue pause; agent-origin sends must not release it A4 crash during a notice turn revives with the continue prompt under the notice entry's id (no stale user-prompt replay, no cap reset) A5 ack carries the observation's start moment (additive observedAt); a settle between the read and the report survives the ack A6 per-parent single-flight notice delivery (no second delivery steering into the first notice turn) A7 deleted parents: purge drops owed outcomes; a missing row reads as gone, not active A8 stop_turn drops pending notices like interrupt; archived parents lose parked rows; a pre-Stop queued message does not re-arm A9 short_id cuts on a char boundary A10 held sends are rewritten onto the switch's config; a session recorded under another harness never rides a resume A11 fork config drop when the copy's harness differs from the source config's (model/reasoning/options go, sandbox stays) A12 logical_message_root resolves the named root directly (3+ fragment chains keep the root's provider turn ref) A13 the provider turn ref is cleared at each turn end (no stale ref on the next turn's steer-split segments) A14 a mid-turn (steer-split) boundary falls back to the budgeted paste A15 a terse later limit classification merges into the detailed earlier one; fake-claude scenario:errors matches the live order A16 a failed failover is reported as a System entry on the chat A17 the failover watcher is driven by the status watch, bounded A18 the credential fingerprint keys on login identity, not token bytes A19 note_run_signed_out is spawned, not awaited inline A20 boot republishes still-Limited and armed-resume rows (banner keeps resumes-in/Cancel; ScheduleResumeAtReset works after restart) A21 queueing a message no longer cancels the armed continuation (story 41: queued rows wait behind it) A22 archive cancels the resume only on a successful write A23 engine settings + scheduled resumes persist atomically, snapshotted under the lock A24 the budgeted-history lead names no side chat A25 journal_paths doc no longer claims .steers is included A26 EngineSettingsStore::open and budgeted_transcript_prompt gated #[cfg(test)] (dead-code warnings) A27 'workspace unavailable' user-facing string -> registry wording Wire: QueuedMessage gains an optional completionNotice field (additive; generated TS updated). No new types/consts. Tests added (not run; CI): loop-cap release/merge/no-agent-release, late-observation ack, deleted parent, restart republish, queued-waits- behind-continuation, terse-limit merge, held-send-across-switch, fork config drop + mid-turn boundary + 3-fragment root, failover failure note, fingerprint identity stability. --- crates/doc/src/queue.rs | 45 +++ crates/engine/src/account_failover.rs | 202 ++++++++-- crates/engine/src/agent_accounts.rs | 90 ++++- .../src/agent_accounts/provider_tests.rs | 33 ++ crates/engine/src/completion_notices.rs | 351 +++++++++++++++--- crates/engine/src/doc_host.rs | 81 +++- crates/engine/src/engine_settings.rs | 37 +- crates/engine/src/rpc.rs | 97 ++++- crates/engine/src/run_journal.rs | 10 +- crates/engine/src/scheduled_resumes.rs | 51 ++- crates/engine/src/sessions.rs | 342 ++++++++++++++--- crates/engine/src/transcript_budget.rs | 7 +- crates/engine/tests/account_failover.rs | 31 ++ crates/engine/tests/account_sign_in_state.rs | 4 +- crates/engine/tests/codex_subagents.rs | 4 +- crates/engine/tests/completion_notices.rs | 344 ++++++++++++++++- crates/engine/tests/continue_on_harness.rs | 88 +++++ crates/engine/tests/e2e.rs | 18 +- crates/engine/tests/limited_chats.rs | 98 ++++- .../tests/m5c_accounts_uploads_titles.rs | 4 +- crates/engine/tests/pi_resume.rs | 2 +- crates/engine/tests/resume_at_reset.rs | 89 ++++- crates/engine/tests/rich_composer_delivery.rs | 12 +- crates/engine/tests/runtime_survival.rs | 46 +-- crates/engine/tests/runtime_survival_live.rs | 2 +- crates/engine/tests/self_continued_quiesce.rs | 4 +- crates/engine/tests/side_chats.rs | 232 +++++++++++- crates/engine/tests/subagent_idle_reap.rs | 2 +- crates/engine/tests/turn_quiesce.rs | 8 +- crates/harness/tests/fixtures/fake-claude.sh | 10 +- crates/roboco-mcp/src/roboco.rs | 22 +- crates/roboco-mcp/src/tools.rs | 36 +- .../proto/src/generated/QueuedMessage.ts | 12 +- 33 files changed, 2090 insertions(+), 324 deletions(-) diff --git a/crates/doc/src/queue.rs b/crates/doc/src/queue.rs index ec69d89f1f..0907309cfb 100644 --- a/crates/doc/src/queue.rs +++ b/crates/doc/src/queue.rs @@ -56,6 +56,14 @@ pub struct QueuedMessage { /// entry keeps it. #[serde(default, skip_serializing_if = "Option::is_none")] pub origin_chat_id: Option<String>, + /// The loop cap's parked completion notice (orchestration v2, ticket 14, + /// additive; ADR 0005): the sides the notice reports, so the row — the + /// notice's durable carrier while it waits for the user — releases as + /// the same System completion-notice entry a direct delivery writes, + /// never as a user message. `None` for every other row. A later capped + /// delivery merges its sides into the existing row's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion_notice: Option<Vec<roboco_proto::NoticeSide>>, } /// Why a queued row is not currently eligible for automatic or explicit @@ -104,6 +112,7 @@ impl QueuedMessage { edited_at: None, delivery_gate: None, origin_chat_id: None, + completion_notice: None, } } } @@ -370,6 +379,12 @@ fn write_queued_map(map: &loro::LoroMap, item: &QueuedMessage) -> Result<(), Doc if let Some(origin_chat_id) = &item.origin_chat_id { map.insert("originChatId", origin_chat_id.as_str())?; } + if let Some(sides) = &item.completion_notice { + map.insert( + "completionNotice", + crate::schema::loro_value_from_json(&serde_json::to_value(sides)?), + )?; + } Ok(()) } @@ -417,6 +432,9 @@ fn queued_from_json(v: serde_json::Value) -> Option<QueuedMessage> { .get("originChatId") .and_then(|o| o.as_str()) .map(str::to_string), + completion_notice: v + .get("completionNotice") + .and_then(|sides| serde_json::from_value(sides.clone()).ok()), }) } @@ -439,6 +457,7 @@ mod tests { edited_at: None, delivery_gate: None, origin_chat_id: None, + completion_notice: None, } } @@ -517,6 +536,32 @@ mod tests { assert_eq!(legacy[0].origin_chat_id.as_deref(), Some("chat-origin-1")); } + #[test] + fn completion_notice_sides_round_trip_and_old_rows_read_as_plain() { + let doc = doc(); + let mut parked = item("notice:1", "Side chat reached outcomes."); + parked.completion_notice = Some(vec![roboco_proto::NoticeSide { + chat_id: "chat-side-1234".into(), + title: "Implement login".into(), + outcome: roboco_proto::NoticeOutcome::Completed, + detail: None, + }]); + doc.push_queued(&parked).unwrap(); + doc.push_queued(&item("q1", "typed by the user")).unwrap(); + + let rows = doc.read_queue().unwrap(); + assert_eq!(rows[0].completion_notice, parked.completion_notice); + assert_eq!(rows[1].completion_notice, None); + // The wire name is camelCase and additive: an old writer's row + // (simulated by dropping the field) keeps reading as a plain row. + let json = serde_json::to_value(rows).unwrap(); + assert_eq!(json[0]["completionNotice"][0]["chatId"], "chat-side-1234"); + assert!(json[1].get("completionNotice").is_none()); + let legacy = serde_json::from_value::<Vec<QueuedMessage>>(json).unwrap(); + assert_eq!(legacy[0].completion_notice, parked.completion_notice); + assert_eq!(legacy[1].completion_notice, None); + } + #[test] fn delivery_gate_round_trips_and_finish_clears_it_atomically() { let doc = doc(); diff --git a/crates/engine/src/account_failover.rs b/crates/engine/src/account_failover.rs index b101b7a4e3..3318dea1bf 100644 --- a/crates/engine/src/account_failover.rs +++ b/crates/engine/src/account_failover.rs @@ -37,7 +37,7 @@ use std::collections::HashSet; use std::time::Duration; -use roboco_doc::SessionCommandPayload; +use roboco_doc::{MessagePart, MessageRole, MessageStatus, SessionCommandPayload, SessionMessageEntry}; use roboco_proto::{ AgentAccount, AgentSignInState, HarnessId, ResumeAccountCandidates, ResumeOnAccountOutcome, RESUME_CONTINUE_PROMPT, @@ -45,7 +45,7 @@ use roboco_proto::{ use crate::agent_accounts::AgentAccounts; use crate::sessions::SessionsEngine; -use crate::{new_id, EngineError}; +use crate::{new_id, now_ms, EngineError}; /// Only these harnesses fail over: each keeps one switchable account login /// whose sessions survive a switch (research 03). Pure. @@ -70,6 +70,19 @@ const TURN_START_WINDOW: Duration = Duration::from_secs(30); /// short, but a store that keeps returning the same row must never spin. const MAX_ATTEMPTS: usize = 8; +/// How long the watcher waits for the continuation turn to SETTLE after it +/// started: the wait is driven by the status watch (no polling), so a long +/// turn costs nothing — the bound only stops a watcher leaking forever on +/// a run that never settles (a chat parked on a question for days counts as +/// never here; the switch itself stands, only the chain ends). +const SETTLE_WINDOW: Duration = Duration::from_secs(24 * 60 * 60); + +/// How long the watcher re-checks the signed-out mark for after a settle +/// (bounded): the run's own marking is spawned, not awaited (it queues +/// behind the accounts ops lock a usage probe can hold), so the mark can +/// land a beat after the turn errored. +const MARK_GRACE: Duration = Duration::from_secs(2); + /// The ranked switch targets for `chat_id` (ticket 09): the cached rows of /// the chat's harness, best first, plus the account the chat's limit hit. /// Empty for a harness without failover, or when every other account is @@ -330,7 +343,7 @@ fn dispatch_continuation( let last = engine .last_request(chat_id) .ok_or_else(|| "the chat has no run configuration to continue".to_string())?; - let resume = engine.harness_session_to_resume(chat_id, &last.cwd); + let resume = engine.harness_session_to_resume(chat_id, &last.cwd, harness); let fallback = fallback_history(engine, chat_id); let request = roboco_proto::RunRequest { prompt: RESUME_CONTINUE_PROMPT.into(), @@ -378,12 +391,21 @@ fn fallback_history(engine: &SessionsEngine, chat_id: &str) -> Option<String> { crate::transcript_budget::budgeted_transcript_prefix(&entries, Some(chat_id)) } -/// Watch the continuation turn (fire-and-forget): once it settles, -/// a signed-out failure moves to the next candidate (the run's error has +/// Watch the continuation turn (fire-and-forget): once it settles, a +/// signed-out failure moves to the next candidate (the run's error has /// already marked the account — ticket 08); any other failure restores the -/// previously active account. A turn the user stopped, or a chat that +/// previously active account and REPORTS it (ticket 09) — the chat's +/// transcript gains a System entry saying the switch failed and the +/// previous account is active again, so the user (and the web client, which +/// renders System entries) sees it. A turn the user stopped, or a chat that /// vanished, ends the chain and leaves the switch standing — the user's /// stop is not a failed failover. +/// +/// Driven by the session status watch, not polling (the old 100ms/250ms +/// loops could miss a fast Working→Errored flip entirely and spun forever +/// while a question waited): the watcher sleeps until a row it cares about +/// changes, wakes on the engine's own transitions, and bounds itself by +/// [`TURN_START_WINDOW`] (the start) and [`SETTLE_WINDOW`] (the settle). async fn watch( engine: &SessionsEngine, accounts: &AgentAccounts, @@ -393,50 +415,112 @@ async fn watch( previous: Option<String>, tried: HashSet<String>, ) { + // The row the pre-switch chat settled on (Limited: Errored with a + // written-at). A status row is only "the continuation's outcome" when + // it is newer than this moment — the chat sat Errored before the turn + // ever started, so the old row must not read as the turn's failure. + let began = chrono::Utc::now(); // Phase 1: the continuation starts. The queued command dispatches - // within milliseconds; the bound only covers a held queue. + // within milliseconds; the bound only covers a held queue. The watch + // wakes on every transition — including a turn that errors before it + // ever reads Working, which the old poller could sleep straight through. + let mut rows = engine.watch_sessions(); let start_by = tokio::time::Instant::now() + TURN_START_WINDOW; - loop { + let started = loop { if engine.shutting_down() { return; } - match engine.session_status(chat_id).map(|row| row.status) { - Some(roboco_proto::SessionStatus::Working) => break, - _ if tokio::time::Instant::now() >= start_by => { + let row = engine.session_status(chat_id); + match row.map(|row| (row.status, row.updated_at)) { + Some((roboco_proto::SessionStatus::Working | roboco_proto::SessionStatus::AwaitingInput, _)) => { + break true; + } + // Errored again (or idle) on a row WRITTEN after we began: the + // turn ran and settled within the window — watch it as settled. + Some((roboco_proto::SessionStatus::Idle | roboco_proto::SessionStatus::Errored, at)) + if at > began => + { + break false; + } + // No row yet (or the chat's pre-switch row: Errored with a + // written-at before we began) — the command is still on its way. + _ => {} + } + tokio::select! { + changed = rows.changed() => { + if changed.is_err() { + return; + } + } + _ = tokio::time::sleep_until(start_by) => { tracing::info!( chat = %chat_id, "failover continuation never started; leaving the queued command to drain" ); return; } - _ => tokio::time::sleep(Duration::from_millis(100)).await, } - } - // Phase 2: settle. AwaitingInput is still the user's turn in progress. - let settled = loop { - if engine.shutting_down() { - return; - } - let status = engine.session_status(chat_id).map(|row| row.status); - match status { - Some(roboco_proto::SessionStatus::Idle) - | Some(roboco_proto::SessionStatus::Errored) - | None => break status, - Some( - roboco_proto::SessionStatus::Working - | roboco_proto::SessionStatus::AwaitingInput, - ) => tokio::time::sleep(Duration::from_millis(250)).await, + }; + // Phase 2: settle. AwaitingInput is still the user's turn in progress — + // the watcher stays (for free) until the turn ends one way or another. + let settled = if started { + let settle_by = tokio::time::Instant::now() + SETTLE_WINDOW; + let mut settled = None; + loop { + if engine.shutting_down() { + return; + } + let status = engine.session_status(chat_id).map(|row| row.status); + match status { + Some(roboco_proto::SessionStatus::Idle) + | Some(roboco_proto::SessionStatus::Errored) + | None => { + settled = status; + break; + } + // Working | AwaitingInput: keep waiting on the watch. + _ => {} + } + tokio::select! { + changed = rows.changed() => { + if changed.is_err() { + return; + } + } + _ = tokio::time::sleep_until(settle_by) => { + tracing::info!( + chat = %chat_id, + "failover continuation never settled; leaving the switch standing" + ); + return; + } + } } + settled + } else { + // The turn already settled during phase 1: read its final state. + engine.session_status(chat_id).map(|row| row.status) }; match settled { Some(roboco_proto::SessionStatus::Errored) => { // Why did it fail? A signed-out error has already marked the // switched-to account (the run's own wiring, ticket 08): the - // ACTIVE row reading SignedOut is that mark. - let active = accounts.active_account(harness).await; - let signed_out = active - .as_ref() - .is_some_and(|row| row.sign_in_state == AgentSignInState::SignedOut); + // ACTIVE row reading SignedOut is that mark. The mark is + // spawned (it queues behind the accounts ops lock), so it can + // land a beat after the settle — re-check it for a bounded + // grace before concluding the failure was something else. + let mut signed_out = false; + let deadline = tokio::time::Instant::now() + MARK_GRACE; + loop { + let active = accounts.active_account(harness).await; + signed_out = active + .as_ref() + .is_some_and(|row| row.sign_in_state == AgentSignInState::SignedOut); + if signed_out || tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } if signed_out { tracing::info!( chat = %chat_id, @@ -456,12 +540,26 @@ async fn watch( // The chain ran out of candidates (or the store broke): the // failover failed as a whole — put the previous account // back so the harness is not left on a login whose turn - // just failed and nothing uses. + // just failed and nothing uses, and say so on the chat. if matches!(next, Attempt::NoCandidates | Attempt::Failed(_)) { + let reason = match next { + Attempt::NoCandidates => { + "every other account is limited, signed out, or in use".to_string() + } + Attempt::Failed(reason) => reason, + Attempt::Switched(_) => unreachable!("matched above"), + }; restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + note_failed_failover(engine, chat_id, previous.as_deref(), &reason); } } else { restore(engine, accounts, chat_id, harness, previous.as_deref()).await; + note_failed_failover( + engine, + chat_id, + previous.as_deref(), + "the resumed turn failed", + ); } } // Completed, interrupted (the user's stop — not a failed @@ -470,6 +568,44 @@ async fn watch( } } +/// Write the failed-failover report into the chat's transcript (ticket 09: +/// "restore the previously active account and report the failure"): a +/// System entry, rendered by every client, saying what failed and which +/// account the harness is back on. Best-effort — a failed write costs the +/// note, never the switch. +fn note_failed_failover(engine: &SessionsEngine, chat_id: &str, previous: Option<&str>, reason: &str) { + let Some(host) = engine.host() else { + return; + }; + let Some(handle) = host.open(chat_id).ok() else { + return; + }; + let account = previous + .map(|id| format!("account {id}")) + .unwrap_or_else(|| "its previous login".into()); + let text = format!( + "Resume on another account failed: {reason}. The harness is back on {account}." + ); + let entry = SessionMessageEntry { + id: format!("failover:{}", new_id()), + role: MessageRole::System, + parts: vec![MessagePart::Text { + id: "t0".into(), + text, + }], + created_at: now_ms(), + device_id: host.device_id().to_owned(), + status: Some(MessageStatus::Complete), + continuation_of: None, + duration_ms: None, + origin_chat_id: None, + provider_turn: None, + }; + if let Err(err) = handle.doc().push_message(&entry) { + tracing::warn!(chat = %chat_id, error = %err, "failed-failover note write failed"); + } +} + /// Put the previously active account back (best-effort): a failover that /// failed left the harness on an account whose turn errored — the world /// returns to the login the chats were running under. diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index c727006118..be19be8bd2 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -1138,20 +1138,62 @@ impl AgentAccounts { } /// Fingerprint of the CLI store's live login for `harness` — a digest of - /// the credential bytes every new run on this harness would use. The - /// sessions engine stamps it on every warm runtime it starts, so a - /// runtime launched under another login is never reused after a switch - /// (ticket 09). `None` for a harness whose live store roboco does not - /// read — and for a Claude Keychain login, which has no file: no - /// fingerprint, no forced retirement. + /// the login's IDENTITY, never its token bytes (ticket 09): a CLI token + /// refresh rewrites the credential file on every expiry, and a + /// bytes-keyed fingerprint would retire every warm runtime on the SAME + /// login — a mid-turn steer became an interrupt-and-redispatch. Claude's + /// identity (account uuid, email, organization) lives in the config + /// file's `oauthAccount`; Codex's rides the auth file's `id_token` + /// claims (or its API key). The sessions engine stamps the digest on + /// every warm runtime it starts, so a runtime launched under another + /// login is never reused after a switch. `None` for a harness whose live + /// store roboco does not read — and for a Claude Keychain login, which + /// has no file: no fingerprint, no forced retirement. When the identity + /// cannot be read, the raw credential bytes stand in (the old shape): + /// identity unknown is the one case where the tokens themselves are the + /// best available proxy for "same login". pub fn live_credentials_fingerprint(&self, harness: HarnessId) -> Option<String> { - let file = match harness { - HarnessId::ClaudeCode => self.inner.config.claude_creds_file(), - HarnessId::Codex => self.inner.config.codex_auth_file(), - _ => return None, + let identity_fingerprint = |parts: &[Option<String>]| { + let joined: Vec<&str> = parts.iter().flatten().map(String::as_str).collect(); + (!joined.is_empty()).then(|| { + crate::repos::hex(&Sha256::digest(joined.join("\u{0}").as_bytes()))[..16] + .to_string() + }) }; - let bytes = std::fs::read(&file).ok()?; - Some(crate::repos::hex(&Sha256::digest(&bytes))[..16].to_string()) + match harness { + HarnessId::ClaudeCode => { + // The credentials file's tokens churn on every refresh; the + // config's `oauthAccount` is the durable identity of the + // login every run on this harness uses. + let cfg = read_json(&self.inner.config.claude_config_file); + let identity = cfg.as_ref().and_then(|c| c.get("oauthAccount")).map(|oauth| { + [ + str_field(oauth, "accountUuid"), + str_field(oauth, "emailAddress"), + str_field(oauth, "organizationUuid"), + ] + }); + if let Some(digest) = identity.and_then(|parts| identity_fingerprint(&parts)) { + return Some(digest); + } + let bytes = std::fs::read(self.inner.config.claude_creds_file()).ok()?; + Some(crate::repos::hex(&Sha256::digest(&bytes))[..16].to_string()) + } + HarnessId::Codex => { + let auth = read_json(&self.inner.config.codex_auth_file())?; + // The id_token rotates; its CLAIMS (the user and workspace + // ids) are the login's identity. An API-key login is its + // own stable identity. + if let Some(parts) = codex_login_identity(&auth) + && let Some(digest) = identity_fingerprint(&parts) + { + return Some(digest); + } + let bytes = serde_json::to_string(&auth).ok()?; + Some(crate::repos::hex(&Sha256::digest(bytes.as_bytes()))[..16].to_string()) + } + _ => None, + } } /// Caller holds [`Inner::ops`]. @@ -3650,6 +3692,30 @@ fn with_claude_ai_oauth( } } +/// The Codex auth file's login identity (for the credential fingerprint — +/// [`AgentAccounts::live_credentials_fingerprint`]): the id_token's user +/// and workspace claims, or the API key. `None` when the file names no +/// identity — the caller then falls back to the raw bytes. +fn codex_login_identity(auth: &serde_json::Value) -> Option<Vec<Option<String>>> { + if let Some(id_token) = auth + .get("tokens") + .and_then(|t| t.get("id_token")) + .and_then(|v| v.as_str()) + { + let claims = jwt_claims(id_token).unwrap_or_else(|| serde_json::json!({})); + let oa = claims + .get("https://api.openai.com/auth") + .cloned() + .unwrap_or_default(); + return Some(vec![ + str_field(&claims, "email"), + str_field(&oa, "chatgpt_account_id"), + str_field(&oa, "chatgpt_user_id"), + ]); + } + str_field(auth, "OPENAI_API_KEY").map(|key| vec![Some(key)]) +} + /// Parse a codex `auth.json` (the live one or a fresh login's). fn parse_codex_auth(auth: serde_json::Value) -> Option<Detected> { if let Some(id_token) = auth diff --git a/crates/engine/src/agent_accounts/provider_tests.rs b/crates/engine/src/agent_accounts/provider_tests.rs index 5c97c15b41..66c617c785 100644 --- a/crates/engine/src/agent_accounts/provider_tests.rs +++ b/crates/engine/src/agent_accounts/provider_tests.rs @@ -1943,6 +1943,39 @@ fn claude_usage_body(session: f64, week: f64) -> String { .to_string() } +/// The live credential fingerprint keys on the login's IDENTITY, not its +/// token bytes (ticket 09): a CLI token refresh rewrites the credential +/// file on every expiry, and a bytes-keyed fingerprint would retire every +/// warm runtime on the SAME login — a mid-turn steer became an +/// interrupt-and-redispatch. Only a real login switch may change it. +#[test] +fn the_live_credential_fingerprint_keys_on_identity_not_tokens() { + let tmp = tempfile::tempdir().unwrap(); + let config = AgentAccountsConfig::isolated(tmp.path()); + let accounts = AgentAccounts::new(config.clone()); + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token"); + let alice = accounts + .live_credentials_fingerprint(HarnessId::ClaudeCode) + .expect("a fingerprint for the live login"); + + // The CLI refreshes the access token: the credential file's bytes all + // change, the login does not. + write_claude_login(&config, "uuid-alice", "alice@example.com", "alice-token-2"); + assert_eq!( + accounts.live_credentials_fingerprint(HarnessId::ClaudeCode), + Some(alice.clone()), + "a token refresh must not churn the fingerprint" + ); + + // A real switch to another account: a different identity, a different + // fingerprint — the warm runtimes retire. + write_claude_login(&config, "uuid-bob", "bob@example.com", "bob-token"); + let bob = accounts + .live_credentials_fingerprint(HarnessId::ClaudeCode) + .expect("a fingerprint for the switched login"); + assert_ne!(bob, alice, "a login switch changes the fingerprint"); +} + /// A live login that a run refused: an expired access token the CLI refreshes /// itself is AMBIGUOUS — unknown, never signed out — and no refresh is ever /// attempted for the live login. diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 50e119ae2a..01b83ff037 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -22,19 +22,20 @@ //! auto-resume. An auto-resumed run owes nothing at boot — the //! re-dispatch keeps the starting message's origin, so the notice //! follows the resumed turn's own end. -//! - **Archived chats are quiet both ways:** an archived side chat records -//! nothing for its parent, and an archived parent is owed nothing. +//! - **Archived and deleted chats are quiet both ways:** an archived side +//! chat records nothing for its parent, an archived or deleted parent is +//! owed nothing (the delete paths drop the parent's notices; a deletion +//! that raced a record finds the row gone and stays quiet). //! //! Ticket 14's rules on top: //! //! - **Acknowledgement:** an outcome is dropped when the parent observed it //! after it happened — through `wait_for_turn`, `send_message` with wait, //! `read_chat` or `get_chat`. The MCP server reports such observations to -//! the engine ([`CompletionNotices::ack`]); each side chat carries a -//! monotonic settle serial advanced before its settled status publishes, -//! so an observation covers every outcome at or below the serial it -//! observed — including a record that lands right after the report raced -//! it — and never one for a later turn. +//! the engine ([`CompletionNotices::ack`]) with the moment the +//! observation started, so the watermark covers the settles THAT +//! observation saw — never one that landed between the read and the +//! report, which is the parent's unseen outcome. //! - **Stop and interrupt:** stopping or interrupting the parent drops the //! notices owed to it and suppresses outcomes from side chats whose //! notifiable turns were running at that moment ([`stop_parent`] — T3's @@ -43,9 +44,15 @@ //! (`mark_notifiable` in `sessions.rs`). //! - **Loop cap:** at most [`NOTICE_LOOP_CAP`] consecutive parent turns may //! be started by notices without a user message in between. Beyond it, -//! the notice parks as a visible, PAUSED queue row on the parent and -//! waits for the user: a user-authored message resets the count and -//! thaws the queue, and the row then runs as the parent's next turn. +//! the notice parks as a visible, PAUSED queue row on the parent — +//! carrying the notice's own sides, so its release writes the same +//! System completion-notice entry, never a user message — and merges with +//! any row already parked. It waits for the user: a user-authored message +//! resets the count and thaws the queue, and the row then runs as the +//! parent's next turn. +//! - **Delivery is single-flight per parent:** two settling children cannot +//! both deliver — the second would steer into the notice turn the first +//! is starting. The loser's outcomes stay pending for that turn's end. //! //! The notice message is a SYSTEM entry — never a user message — carrying a //! [`roboco_doc::MessagePart::CompletionNotice`] marker so clients can render @@ -54,6 +61,7 @@ //! id with its outcome, and points the parent agent at `read_chat`. use std::collections::HashMap; +use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, PoisonError}; @@ -68,6 +76,13 @@ use crate::doc_host::DocHost; use crate::sessions::SessionsEngine; use crate::{new_id, now_ms}; +/// How many recent settles per chat the observation watermark can look +/// back through (ticket 14's acknowledgement): the map keeps one timestamp +/// per settled turn, bounded so a long-lived chat cannot grow it without +/// end. An observation older than every kept settle reads as watermark 0 +/// (nothing dropped) — conservative, never a lost report. +const SETTLE_HISTORY: usize = 1024; + /// Cap for a notice's one-line detail — an error's reason or a question's /// summary. Matches the transcript's tool-output summary budget: the notice /// is a pointer to `read_chat`, never a report. @@ -220,19 +235,45 @@ struct NoticeState { /// parent → consecutive notice-started turns since the last /// user-authored message (ticket 14's loop cap). loops: HashMap<String, u32>, + /// parent → when its last Stop happened (epoch ms), for the re-arm + /// rule: a message the parent sent BEFORE that stop must not re-arm a + /// side chat's next turn (story 24 counts only later messages). Transient + /// by design — never persisted; a restart loses it and the gate opens, + /// which costs at most one notice the stop meant to suppress. + stopped_at: HashMap<String, i64>, + /// parent → a delivery of its notice turn is being built right now + /// (the single-flight guard): a second outcome racing it must not + /// steer into the turn the first is starting — it re-arms at that + /// turn's end instead. + delivering: std::collections::HashSet<String>, +} + +/// One settle's mark: the serial it stamped and the moment it happened. +#[derive(Debug, Clone)] +struct SettleMark { + /// The serial this settle stamped. + serial: u64, + /// When the settle happened (epoch ms): the clock an observation's + /// "what did I see" timestamp compares against. + at: i64, } /// Outcome records owed to parents, keyed by parent chat id. #[derive(Debug, Default)] pub(crate) struct CompletionNotices { state: Mutex<NoticeState>, - /// side chat → turns settled so far (ticket 14): the monotonic serial - /// outcomes and observations compare on. In-memory by design — a - /// restart's boot recovery re-stamps serials for the outcomes it - /// records, and a lost watermark costs at most one redundant notice. - /// Never taken together with [`Self::state`] except by `ack`, which - /// takes this one first (the one ordering rule to keep deadlock-free). - settles: Mutex<HashMap<String, u64>>, + /// side chat → the turns settled so far (ticket 14): the monotonic + /// serial outcomes and observations compare on, with the moment each + /// settle happened so an acknowledgement can say WHICH settles its + /// observation covered (the serial it read, not the serial the engine + /// happens to be at when the report arrives — a settle racing between + /// the read and the report is the parent's UNSEEN outcome and must + /// survive the ack). In-memory by design — a restart's boot recovery + /// re-stamps serials for the outcomes it records, and a lost watermark + /// costs at most one redundant notice. Never taken together with + /// [`Self::state`] except by `ack`, which takes this one first (the + /// one ordering rule to keep deadlock-free). + settles: Mutex<HashMap<String, VecDeque<SettleMark>>>, /// Where the ledger is mirrored (ticket 13): an outcome is a result the /// parent has not received yet, and losing one means a child's work /// silently never reaches its creator. `None` until a store path is @@ -313,20 +354,64 @@ impl CompletionNotices { /// sees a serial at least as high as everything it observed. pub(crate) fn settle(&self, chat_id: &str) -> u64 { let mut settles = lock(&self.settles); - let serial = settles.entry(chat_id.to_string()).or_insert(0); - *serial += 1; - *serial + let marks = settles.entry(chat_id.to_string()).or_default(); + let serial = marks.last().map(|mark| mark.serial).unwrap_or(0) + 1; + marks.push_back(SettleMark { + serial, + at: now_ms(), + }); + if marks.len() > SETTLE_HISTORY { + marks.pop_front(); + } + serial + } + + /// The chat's current settle serial — zero for a chat that never + /// settled in this engine run. + pub(crate) fn settle_serial(&self, chat_id: &str) -> u64 { + lock(&self.settles) + .get(chat_id) + .and_then(|marks| marks.last()) + .map(|mark| mark.serial) + .unwrap_or(0) + } + + /// The settle serial an observation made at `observed_at` covered: the + /// newest settle that happened at or before the observation. Settles + /// are monotonic, so an outcome for any settle past this watermark + /// happened after what the observer saw and owes its notice. Zero when + /// the observation predates every kept settle (a restarted engine, a + /// very old report): conservative — nothing is dropped. + fn serial_observed_at(&self, chat_id: &str, observed_at: i64) -> u64 { + lock(&self.settles) + .get(chat_id) + .and_then(|marks| { + marks + .iter() + .rev() + .find(|mark| mark.at <= observed_at) + .map(|mark| mark.serial) + }) + .unwrap_or(0) } /// The parent observed the side chat after its last outcome happened /// (ticket 14) — through `wait_for_turn`, `send_message` with wait, - /// `read_chat` or `get_chat`; the MCP server reports it. Outcomes for - /// settles at or below the observed serial are dropped (they were - /// seen), and the watermark stands so a record that lands right after - /// this — the settle raced the report — is dropped too. Outcomes for - /// LATER turns owe their notice. Returns the watermark for the log. - pub(crate) fn ack(&self, parent: &str, side_chat: &str) -> u64 { - let watermark = lock(&self.settles).get(side_chat).copied().unwrap_or(0); + /// `read_chat` or `get_chat`; the MCP server reports it, with the + /// moment the observation started (`observed_at`) when the caller + /// knows it. The watermark is the serial THAT observation covered, not + /// the serial the engine happens to be at when the report arrives — a + /// settle that landed between the read and this report is the parent's + /// UNSEEN outcome and survives the ack. Outcomes for settles at or + /// below the watermark are dropped (they were seen), and the watermark + /// stands so a record that lands right after this — the settle raced + /// the report — is dropped too. Outcomes for LATER turns owe their + /// notice. Returns the watermark for the log. + pub(crate) fn ack(&self, parent: &str, side_chat: &str, observed_at: Option<i64>) -> u64 { + let watermark = match observed_at { + Some(at) => self.serial_observed_at(side_chat, at), + None => self.settle_serial(side_chat), + }; let mut state = lock(&self.state); if let Some(owed) = state.pending.get_mut(parent) { owed.retain(|outcome| { @@ -343,6 +428,20 @@ impl CompletionNotices { watermark } + /// Try to begin a delivery for `parent` (the single-flight guard): a + /// delivery already being built owns the parent's notice turn, so a + /// second one must not race it — the outcomes stay pending and the + /// parent's turn-end delivery picks them up instead. `false` when a + /// delivery is already in flight. + pub(crate) fn begin_delivery(&self, parent: &str) -> bool { + lock(&self.state).delivering.insert(parent.to_string()) + } + + /// Release the single-flight guard `begin_delivery` armed. + pub(crate) fn end_delivery(&self, parent: &str) { + lock(&self.state).delivering.remove(parent); + } + /// A notice turn started for `parent` (ticket 14's loop cap): the /// consecutive count of notice-started turns grows. Called from the /// dispatch path whenever the starting message id carries the engine's @@ -370,6 +469,20 @@ impl CompletionNotices { lock(&self.state).loops.get(parent).copied().unwrap_or(0) } + /// When `parent` was last stopped, if it was in this engine run. The + /// stopped-parent re-arm rule: only a message sent AFTER this moment + /// makes a side chat's next turn notifiable again (ticket 14). + pub(crate) fn parent_stopped_at(&self, parent: &str) -> Option<i64> { + lock(&self.state).stopped_at.get(parent).copied() + } + + /// Record `parent`'s stop moment (the stopped-parent rule's re-arm + /// gate): `stop_parent` calls this on every Stop. + pub(crate) fn note_stop(&self, parent: &str, at: i64) { + let mut state = lock(&self.state); + state.stopped_at.insert(parent.to_string(), at); + } + /// A user-authored message reached `parent`: the streak of consecutive /// notice-started turns is broken, so the cap resets (ticket 14). A /// Stop counts as user intervention for the same reason — see @@ -538,9 +651,11 @@ pub(crate) fn question_summary(questions: &[roboco_proto::UserInputQuestion]) -> one_line(Some(text)).unwrap_or_else(|| "input".into()) } -/// Is this chat archived (ticket 13)? A missing or unreadable row reads as -/// active: only a row that says archived silences notices — a row the -/// engine cannot read must not silently eat a report. +/// Is this chat quiet (ticket 13)? A row that says archived, and a row +/// that is GONE (deleted: the delete paths drop the parent's notices, but +/// a deletion that raced a record must not deliver to a chat that no +/// longer exists) — both are quiet. An UNREADABLE row reads as active: a +/// row the engine cannot read must not silently eat a report. pub(crate) fn chat_archived(engine: &SessionsEngine, chat_id: &str) -> bool { // Bind the host first: `workspace()` borrows it, and a // `host().and_then(|host| host.workspace())` chain would borrow a @@ -549,9 +664,11 @@ pub(crate) fn chat_archived(engine: &SessionsEngine, chat_id: &str) -> bool { let Some(host) = engine.host() else { return false; }; - host.workspace() - .and_then(|ws| ws.chat(chat_id).ok().flatten()) - .is_some_and(|chat| chat.archived) + match host.workspace().and_then(|ws| ws.chat(chat_id)) { + Some(Ok(Some(chat))) => chat.archived, + Some(Ok(None)) => true, // the chat is gone + _ => false, // the row is unreadable: keep talking, never eat a report + } } /// Record an outcome for a side-chat turn and deliver what its parent is @@ -593,6 +710,7 @@ pub(crate) async fn record_and_deliver( /// the notice-turn streak resets and the cap's parked rows go with the /// rest of the pending notices. pub(crate) fn stop_parent(engine: &SessionsEngine, parent: &str) { + engine.notices().note_stop(parent, now_ms()); engine.notices().drop_for(parent); engine.notices().note_user_message(parent); engine.suppress_notifiable_runs(parent); @@ -638,14 +756,25 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { if engine.shutting_down() { return; } + // Single-flight (the delivery race): two settling children can both + // pass the idle check before either dispatches, and the second would + // steer into the notice turn the first is starting. One delivery owns + // the parent; the loser's outcomes stay pending — the turn-end + // delivery sites pick them up when the winner's turn ends. + if !engine.notices().begin_delivery(parent) { + return; + } + let _guard = DeliveryGuard(engine, parent); if engine.turn_in_flight(parent) { return; } - // An archived parent receives nothing (ticket 13): drop what piled up - // rather than holding it — an archived chat owes no wakeups, even after - // it is unarchived. + // An archived or deleted parent receives nothing (ticket 13): drop what + // piled up — and the loop cap's parked rows with it, which are just the + // notice's waiting form — rather than holding it: an archived chat owes + // no wakeups, even after it is unarchived. if chat_archived(engine, parent) { engine.notices().drop_for(parent); + drop_parked_notices(engine, parent); return; } let Some(outcomes) = engine.notices().take_for_delivery(parent) else { @@ -664,7 +793,7 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { // the count and thaws the queue, and the row then runs as the parent's // next turn (counting as notice-started itself, via its id prefix). if engine.notices().loop_cap_reached(parent) { - park_notice(engine, parent, &host, &text, outcomes); + park_notice(engine, parent, &host, &text, sides, outcomes); return; } // The notice turn runs on the parent's own configuration: its chat row @@ -709,7 +838,7 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { } let harness = host.harness_for_request(parent, &request); if let Err(err) = engine - .dispatch(parent, harness, request, Some(entry.id.clone()), None) + .dispatch(parent, harness, request, Some(entry.id.clone()), None, None) .await { // The entry is already in the transcript (readable on the parent's @@ -719,25 +848,43 @@ pub(crate) async fn deliver(engine: &SessionsEngine, parent: &str) { } } +/// Ends the single-flight guard [`deliver`] armed, even on an early return +/// (a guard holding a mutex across the awaits below would block every +/// mutation instead of serialising the deliveries). +struct DeliveryGuard<'a>(&'a SessionsEngine, &'a str); + +impl Drop for DeliveryGuard<'_> { + fn drop(&mut self) { + self.0.notices().end_delivery(self.1); + } +} + /// Park a capped notice as a visible, paused queue row on the parent (the /// loop cap's waiting state, ticket 14). The queue pauses BEFORE the row /// lands, so the doc-change drain finds it frozen and cannot send the row /// straight out; a recovered queue starts paused too, so the row keeps /// waiting across a restart. The row's id carries the notice prefix — the -/// turn it eventually starts still counts as notice-started — and its -/// text is the notice itself, so the queue shows exactly what waits. The +/// turn it eventually starts still counts as notice-started — its text is +/// the notice itself, so the queue shows exactly what waits, and it carries +/// the notice's sides so its release writes the SAME System completion- +/// notice entry a direct delivery writes, never a user message. The /// outcomes are consumed into the row (a durable carrier of its own: /// queue rows live on the chat doc); only a row that cannot be written -/// puts them back into the pending set. +/// puts them back into the pending set. Outcomes from a LATER capped +/// delivery MERGE into the parked row (ticket 13: outcomes arriving before +/// delivery merge into the same pending notice) instead of parking beside +/// it. fn park_notice( engine: &SessionsEngine, parent: &str, host: &DocHost, text: &str, + sides: Vec<NoticeSide>, outcomes: Vec<PendingOutcome>, ) { + let parked_id = format!("{NOTICE_ID_PREFIX}{}", new_id()); let row = QueuedMessage { - id: format!("{NOTICE_ID_PREFIX}{}", new_id()), + id: parked_id, text: text.to_owned(), attachments: Vec::new(), hold_for_turn_end: false, @@ -748,12 +895,43 @@ fn park_notice( // Engine-authored, not user-typed: the id prefix (not the origin) // carries that for the loop-cap bookkeeping. origin_chat_id: None, + // The sides make the row the notice's own carrier: on release it + // writes the System completion-notice entry, not a user message. + completion_notice: Some(sides), }; - let parked = host.open(parent).ok().and_then(|handle| { + let handle = host.open(parent).ok(); + let parked = handle.and_then(|handle| { // Pause first: the row's own write wakes the drain, which must // already see a frozen queue. handle.pause_queue(); - handle.doc().push_queued(&row).ok() + // A row the cap already parked waits here: merge into it — one + // pending notice, not a row per capped delivery. Its id stays (the + // streak bookkeeping keys on the prefix, and the user's queue view + // keeps one row), its text and sides grow to cover every outcome. + let existing = handle + .doc() + .read_queue() + .ok() + .and_then(|rows| { + rows.into_iter().enumerate().find_map(|(ix, row)| { + (row.id.starts_with(NOTICE_ID_PREFIX)).then_some((ix, row)) + }) + }); + match existing { + Some((ix, mut old)) => { + let mut merged = old.completion_notice.clone().unwrap_or_default(); + merged.extend(row.completion_notice.clone().unwrap_or_default()); + old.text = notice_text(&merged); + old.completion_notice = Some(merged); + handle + .doc() + .remove_queued(&old.id) + .ok() + .and_then(|_| handle.doc().insert_queued(ix, &old).ok()) + .map(|_| old.id) + } + None => handle.doc().push_queued(&row).ok().map(|_| row.id), + } }); if parked.is_none() { tracing::warn!( @@ -836,16 +1014,24 @@ fn with_detail(side: &NoticeSide, verb: &str) -> String { } } -/// Eight-char id prefix — the sidebar's convention. +/// Eight-char id prefix — the sidebar's convention. Ids are engine-minted +/// (ASCII) in practice, but a boundary-crossing multibyte id must not +/// panic: cut at the nearest char boundary at or before the eighth byte. fn short_id(id: &str) -> &str { - &id[..id.len().min(8)] + let mut cut = id.len().min(8); + while cut > 0 && !id.is_char_boundary(cut) { + cut -= 1; + } + &id[..cut] } /// The engine-authored notice message: a SYSTEM entry (never a user /// message) carrying the structured marker clients render distinctly /// (ticket 15), with the notice's text beside it as a plain part so older -/// layouts stay readable. -fn notice_entry(device_id: &str, sides: &[NoticeSide], text: &str) -> SessionMessageEntry { +/// layouts stay readable. Also the entry a released parked row writes +/// (its id prefixes `notice:`), so the two delivery paths land as the same +/// transcript shape. +pub(crate) fn notice_entry(device_id: &str, sides: &[NoticeSide], text: &str) -> SessionMessageEntry { SessionMessageEntry { id: format!("{NOTICE_ID_PREFIX}{}", new_id()), role: MessageRole::System, @@ -923,6 +1109,21 @@ mod tests { assert_eq!(capped.chars().count(), NOTICE_DETAIL_MAX + 1); } + #[test] + fn short_id_cuts_at_a_char_boundary() { + // ASCII ids keep the full eight chars. + assert_eq!(short_id("chat-side-1234"), "chat-si"); + assert_eq!(short_id("short"), "short"); + // A multibyte id never panics: the cut backs up to the nearest + // boundary (the review's byte-slice crash). + assert_eq!(short_id("é"), "é"); + let wide = "ééééééééé"; + let cut = short_id(wide); + assert!(cut.chars().count() <= 4, "{cut}"); + assert!(wide.starts_with(cut), "{cut}"); + assert_eq!(short_id(""), ""); + } + #[test] fn question_summary_prefers_the_question_then_the_header() { use roboco_proto::UserInputQuestion; @@ -1176,7 +1377,7 @@ mod tests { // The parent observed the chat after the turn ended (wait_for_turn, // read_chat, get_chat — all arrive as this call): the outcome goes. - let watermark = notices.ack("parent", "chat-side-1"); + let watermark = notices.ack("parent", "chat-side-1", None); assert_eq!(watermark, turn_one); assert!(notices.parents().is_empty()); @@ -1204,13 +1405,13 @@ mod tests { notices.record("parent", pending_at("chat-side-b", settle, Outcome::Completed)); // Observing one chat never silences another. - notices.ack("parent", "chat-side-a"); + notices.ack("parent", "chat-side-a", None); let owed = notices.take_for_delivery("parent").unwrap(); assert_eq!(owed.len(), 1); assert_eq!(owed[0].side_chat_id, "chat-side-b"); // And only for the parent that observed it. notices.record("other-parent", pending_at("chat-side-a", settle, Outcome::Completed)); - notices.ack("parent", "chat-side-a"); + notices.ack("parent", "chat-side-a", None); assert_eq!(notices.parents(), vec!["other-parent".to_string()]); } @@ -1220,7 +1421,7 @@ mod tests { // A side chat that never settled in this engine run: an outcome // reloaded from a ledger (settle 0) is still covered — its turn // settled before the restart, so a read after it has seen it. - assert_eq!(notices.ack("parent", "chat-side-1"), 0); + assert_eq!(notices.ack("parent", "chat-side-1", None), 0); notices.record("parent", pending("chat-side-1", Outcome::Completed)); assert!(notices.parents().is_empty()); // But a fresh settle outruns that watermark. @@ -1229,6 +1430,52 @@ mod tests { assert_eq!(notices.parents(), vec!["parent".to_string()]); } + #[test] + fn an_ack_from_an_observation_covers_only_the_settles_it_saw() { + // The MCP server reports the moment its read started. A settle that + // lands between the read and the report is the parent's UNSEEN + // outcome: the watermark must not cover it, or an outcome the parent + // never saw is dropped silently (ticket 14). + let notices = CompletionNotices::default(); + let before = now_ms(); + let seen = notices.settle("chat-side-1"); + while now_ms() <= before { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let read_at = now_ms(); + // The settle stamped after the read: the parent's observation never + // saw this turn. + while now_ms() <= read_at { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let unseen = notices.settle("chat-side-1"); + notices.record("parent", pending_at("chat-side-1", seen, Outcome::Completed)); + notices.record("parent", pending_at("chat-side-1", unseen, Outcome::Completed)); + + // The report arrives last (the read happened at `read_at`): only the + // settle at or before the read is covered. + let watermark = notices.ack("parent", "chat-side-1", Some(read_at)); + assert_eq!(watermark, seen); + let owed = notices.take_for_delivery("parent").unwrap(); + assert_eq!(owed.len(), 1); + assert_eq!(owed[0].settle, unseen); + + // A report with no observation time keeps the old reading: the + // engine's current serial (the caller could not say what it saw). + let current = notices.settle_serial("chat-side-1"); + assert_eq!(notices.ack("other-parent", "chat-side-1", None), current); + } + + #[test] + fn settle_serials_advance_and_survive_many_settles() { + let notices = CompletionNotices::default(); + assert_eq!(notices.settle_serial("chat"), 0); + assert_eq!(notices.settle("chat"), 1); + assert_eq!(notices.settle("chat"), 2); + assert_eq!(notices.settle_serial("chat"), 2); + assert_eq!(notices.settle_serial("other"), 0); + } + #[test] fn the_loop_cap_counts_notice_turns_until_a_user_message() { let notices = CompletionNotices::default(); diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 7bc9e81da4..6d04037071 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -410,6 +410,29 @@ impl ChatDocHandle { self.queue_paused.store(false, Ordering::Release); } + /// Rewrite the full run requests held for this chat's turn end onto the + /// harness switch's config (ticket 24: "queued messages after a switch + /// go to the new harness"). A held send stores the request it was sent + /// with — model, reasoning, options and all — and the drain prefers it + /// over the chat row, so without this a message held before the switch + /// would run on the OLD harness (its model ids in the new harness's + /// vocabulary) after the switch. The switch's own resolution rules + /// apply: the new harness and its picks, the sandbox untouched. + pub fn rewrite_held_sends_for_switch(&self, config: &roboco_proto::ChatConfig) { + let mut sends = lock(&self.held_sends); + for request in sends.values_mut() { + request.harness = Some(config.harness); + request.model = config.model.clone(); + request.reasoning = config.reasoning; + request.model_options = config.model_options.clone(); + // The old harness's provider session and fork intent never ride + // the new harness's first run (the seam already cleared the + // chat-level ones; these are the held copies). + request.resume = None; + request.provider_fork = None; + } + } + /// Where a newly steered row goes: after the rows already steered, ahead /// of every ordinary row. Records `id` as steered. fn steer_slot(&self, id: &str) -> Result<usize, DocError> { @@ -1060,6 +1083,11 @@ impl DocHost { pub fn purge_chat(&self, chat_id: &str) { if let Some(sessions) = self.sessions() { sessions.cancel_scheduled_resume_with_chat(chat_id); + // A deleted parent is owed nothing (ticket 13): its pending + // completion notices go with the chat — the durable ledger + // forgets them, and a record that raced the deletion finds the + // row gone and stays quiet on delivery. + sessions.notices().drop_for(chat_id); } let removed = lock(&self.inner.handles).remove(chat_id); drop(removed); @@ -1192,11 +1220,11 @@ impl DocHost { handle.publish_queue(); // Same reasoning as a command: the user is acting in this chat again. self.unarchive_on_send(chat_id); - // And their newer intent cancels any pending Resume-at-reset - // continuation (orchestration v2, ticket 07). - if let Some(sessions) = self.sessions() { - sessions.cancel_scheduled_resume(chat_id); - } + // Queuing is not sending (story 41): the row waits behind the armed + // continuation — the limit that parked the queue is still the wall + // the user chose to wait out, and the continuation stays armed to + // fire at its reset. Only a NEW direct message (the send paths + // below) cancels it. Ok(id) } @@ -1986,12 +2014,31 @@ impl DocHost { queued_message_prompt(&item.text, &attachments) }; self.resolve_attachment_refs(&mut prompt, &mut attachments); + // A parked notice row (the loop cap's waiting form, ticket 14) + // releases as the SAME System completion-notice entry a direct + // delivery writes — never a user message (notices are engine + // artifacts, not the user's words). The entry lands BEFORE the + // dispatch, so the dispatch's own user-message write dedupes onto + // it and the turn starts on the notice's text with the notice's id + // (the loop counter still reads it as notice-started). + if let Some(sides) = item.completion_notice.clone() { + let entry = crate::completion_notices::notice_entry( + self.inner.config.device_id.as_str(), + &sides, + &item.text, + ); + if let Err(err) = handle.doc().push_message(&entry) { + tracing::warn!(chat = %chat_id, error = %err, + "parked completion-notice entry write failed"); + } + } if send == QueueSend::Steer { match sessions - .steer( + .steer_at( chat_id, &prompt, Some(message_id.clone()), + item.issued_at.min(now_ms()), item.origin_chat_id.clone(), ) .await? @@ -2043,6 +2090,7 @@ impl DocHost { request, Some(message_id), item.origin_chat_id.clone(), + Some(item.issued_at), ) .await?; Ok(()) @@ -2757,12 +2805,21 @@ impl DocHost { request, Some(message_id.clone()), entry.origin_chat_id.clone(), + Some(entry.issued_at.min(now_ms())), ) .await?; // A fresh user-authored turn is the deliberate action that - // thaws a queue frozen by Cancel. Clear only after dispatch - // succeeds so a failed send cannot silently unfreeze it. - handle.queue_paused.store(false, Ordering::Release); + // thaws a queue frozen by Cancel — and the only kind that + // may release the loop cap's pause (ticket 14): an + // agent-origin Run (a side chat or an orchestrating parent + // sending through the Roboco MCP server) must not open the + // cap's parked notice without the user, or a busy child + // lifts the very brake that waits for one. Send now / Steer + // now (explicit user queue actions) and the engine's own + // resume continuations thaw through their own paths. + if entry.origin_chat_id.is_none() { + handle.queue_paused.store(false, Ordering::Release); + } Ok((SessionCommandStatus::Applied, None)) } SessionCommandPayload::Steer { prompt, message_id } => { @@ -2877,6 +2934,7 @@ impl DocHost { request, None, None, + None, ) .await?; Ok(( @@ -2980,6 +3038,7 @@ impl DocHost { request, message_id, origin_chat_id, + issued_at.min(now_ms()).into(), ) .await?; handle.queue_paused.store(false, Ordering::Release); @@ -3133,6 +3192,7 @@ impl DocHost { /// dispatch so the conversation records the checkout the harness will /// actually observe, rather than whichever checkout state was current on /// an earlier turn. + #[allow(clippy::too_many_arguments)] pub(crate) async fn dispatch_with_source_context( &self, sessions: &SessionsEngine, @@ -3141,6 +3201,7 @@ impl DocHost { request: roboco_proto::RunRequest, message_id: Option<String>, origin_chat_id: Option<String>, + sent_at: Option<i64>, ) -> Result<String, EngineError> { if let Some(workspace) = self.workspace() && let Some(context) = self.capture_source_context(&request.cwd).await @@ -3149,7 +3210,7 @@ impl DocHost { tracing::warn!(chat = %chat_id, error = %err, "conversation source stamp failed"); } sessions - .dispatch(chat_id, harness, request, message_id, origin_chat_id) + .dispatch(chat_id, harness, request, message_id, origin_chat_id, sent_at) .await } diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs index cd660ef190..94c017fe11 100644 --- a/crates/engine/src/engine_settings.rs +++ b/crates/engine/src/engine_settings.rs @@ -35,8 +35,11 @@ pub(crate) struct EngineSettingsStore { } impl EngineSettingsStore { - /// The persisted store at `path`, loaded. Engine assembly calls this; - /// the default (no path) serves bare engines. + /// The persisted store at `path`, loaded. Test-only now: engine + /// assembly wires the shared store in place with [`Self::attach`], so + /// production never opens a second copy. The default (no path) serves + /// bare engines and tests. + #[cfg(test)] pub(crate) fn open(path: Option<PathBuf>) -> Self { let settings = path.as_deref().and_then(load_file).unwrap_or_default(); Self { @@ -60,12 +63,19 @@ impl EngineSettingsStore { /// Update in memory and on disk. Returns the persistence error (the /// in-memory value still updated — a failed write never wedges the - /// toggle, and the next successful write heals the file). + /// toggle, and the next successful write heals the file). The written + /// snapshot is taken under the lock with the assignment: two racing + /// `set`s otherwise persist out of order and the file ends up behind + /// the in-memory value. pub(crate) fn set(&self, settings: EngineSettings) -> std::io::Result<()> { - *lock(&self.settings) = settings; + let snapshot = { + let mut current = lock(&self.settings); + *current = settings; + current.clone() + }; let path = lock(&self.path).clone(); match path { - Some(path) => write_file(&path, &settings), + Some(path) => write_file(&path, &snapshot), None => Ok(()), } } @@ -83,10 +93,25 @@ fn load_file(path: &Path) -> Option<EngineSettings> { } } +/// Write atomically (temp file + rename): a crash mid-write must never +/// leave a truncated `engine-settings.json` that boots as defaults — the +/// Auto-resume setting is durable intent. fn write_file(path: &Path, settings: &EngineSettings) -> std::io::Result<()> { let bytes = serde_json::to_vec_pretty(settings) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; - std::fs::write(path, bytes) + write_file_atomic(path, &bytes) +} + +/// Temp-file-then-rename, with the temp file in the target's directory so +/// the rename stays on one filesystem. +fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let dir = path.parent().unwrap_or_else(|| path.as_path()); + let mut staged = tempfile::NamedTempFile::new_in(dir)?; + staged.write_all(bytes)?; + staged.flush()?; + staged.as_file().sync_all()?; + staged.persist(path).map_err(|err| err.error) } fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { diff --git a/crates/engine/src/rpc.rs b/crates/engine/src/rpc.rs index 7c915cb1be..bc14d79e11 100644 --- a/crates/engine/src/rpc.rs +++ b/crates/engine/src/rpc.rs @@ -284,6 +284,13 @@ struct AckSideChatParams { /// (an agent parent). Reported after `wait_for_turn`, a send with /// wait, `read_chat` or `get_chat` (ticket 14). origin_chat_id: String, + /// When the observation started (epoch ms; additive — absent when the + /// caller could not say): the engine's acknowledgement watermark covers + /// only the side chat's settles that had happened by then, so an + /// outcome that landed between the read and this report — the parent's + /// UNSEEN work — still owes its notice. + #[serde(default)] + observed_at: Option<i64>, } #[derive(Debug, Deserialize)] @@ -1133,9 +1140,19 @@ impl EngineRpc { .map_err(failed); // Archiving a Limited chat cancels its pending Resume-at-reset // continuation (orchestration v2, ticket 07): the user put the - // work away — it must not resume on its own overnight. - if archived { + // work away — it must not resume on its own overnight. Only + // on a SUCCESSFUL archive: a refused write (the registry row + // unreadable, the doc busy) leaves the chat exactly as it + // was, continuation armed — cancelling beside a failed + // write would strand an unarchived chat with no continuation. + if archived && result.is_ok() { self.sessions.cancel_scheduled_resume(&chat_id); + // An archived parent is owed nothing (ticket 13): its + // pending completion notices go now, and the loop cap's + // parked rows — the notice's waiting form — go with them, + // or they would sit in the archived chat's queue forever + // (nothing drains an archived parent). + crate::completion_notices::stop_parent(&self.sessions, &chat_id); } result.map(drop) } @@ -1750,6 +1767,15 @@ impl RpcService for EngineRpc { // model change. A chat that never carried a config is // pinned to the harness it actually runs on (the engine // default) so a configless source stays honest. + // + // The same drop applies when the copy's harness differs + // from the SOURCE'S CURRENT CONFIG — not just from the + // boundary's turn-ref harness `cross_harness` judges + // against: forking an older Claude reply of a chat that has + // since switched to Codex, back onto Claude, inherits + // Codex's model ids on a Claude copy. The copy's harness + // versus the config it inherits is the mismatch that + // matters; the sandbox always survives. if override_harness.is_some() || requested_model.is_some() { let base = chat.config.clone().unwrap_or(roboco_proto::ChatConfig { harness: self.doc_host.harness_for(&p.source_chat_id), @@ -1758,15 +1784,21 @@ impl RpcService for EngineRpc { model_options: Default::default(), sandbox: roboco_proto::SandboxLevel::WorkspaceWrite, }); + let copy_harness = override_harness.unwrap_or(base.harness); + let config_cross = chat + .config + .as_ref() + .is_some_and(|config| config.harness != copy_harness); + let drop_vocab = cross_harness || config_cross; chat.config = Some(roboco_proto::ChatConfig { - harness: override_harness.unwrap_or(base.harness), - model: if cross_harness { + harness: copy_harness, + model: if drop_vocab { requested_model.map(str::to_owned) } else { requested_model.map(str::to_owned).or(base.model) }, - reasoning: if cross_harness { None } else { base.reasoning }, - model_options: if cross_harness { + reasoning: if drop_vocab { None } else { base.reasoning }, + model_options: if drop_vocab { Default::default() } else { base.model_options @@ -1845,8 +1877,16 @@ impl RpcService for EngineRpc { // keeps today's behavior: a fresh session plus the budgeted // paste, cut at the boundary because the fork's own doc only // holds the copied prefix. + // + // A boundary that is NOT the last segment of its turn gets + // the paste too (story 60): a steer-split reply's segments + // share the turn's ref, and the native fork cuts at the + // WHOLE turn — content the boundary excluded would ride the + // copy through the provider. Only the turn's last segment + // names a boundary the harness can actually cut at. if !cross_harness && let Some(reference) = entries[ref_entry].provider_turn.clone() + && !turn_continues_past(&entries, boundary, &reference) { target .doc() @@ -2003,7 +2043,8 @@ impl RpcService for EngineRpc { // the Roboco MCP server dials this engine's loopback, and // both chats live here. let p: AckSideChatParams = parse_params(params)?; - self.sessions.ack_notice(&p.origin_chat_id, &p.chat_id); + self.sessions + .ack_notice(&p.origin_chat_id, &p.chat_id, p.observed_at); RpcReply::value(&serde_json::json!({})) } methods::UPDATE_QUEUED_MESSAGE => { @@ -3355,22 +3396,26 @@ impl RpcService for EngineRpc { /// The index of the ROOT entry of the logical message `at` belongs to: an /// oversized relayed reply can split into continuation fragments /// (`root#c1`, …) after its root, and the whole chain is one message -/// (`join_continuation_entries`). An orphan fragment (root missing) is its -/// own root. +/// (`join_continuation_entries`). Fragments name their ROOT (`root#c2` +/// points at `root`, not at `root#c1`), so the walk resolves the named id +/// directly rather than stepping back one entry at a time — a three-plus +/// fragment chain would otherwise stop at the first fragment and lose the +/// root (and the root's provider turn ref, where a fork boundary is +/// stamped). An orphan fragment (root missing, or named forward) is its +/// own root; the walk can only move backwards, so a corrupt cycle cannot +/// spin it. fn logical_message_root(entries: &[roboco_doc::SessionMessageEntry], at: usize) -> usize { let mut root = at; - loop { - let Some(parent) = entries[root].continuation_of.as_deref() else { - return root; - }; - match root - .checked_sub(1) - .filter(|&prev| entries[prev].id.as_str() == parent) + while let Some(parent) = entries[root].continuation_of.as_deref() { + match entries + .iter() + .position(|entry| entry.id.as_str() == parent) { - Some(prev) => root = prev, - None => return root, + Some(prev) if prev < root => root = prev, + _ => return root, } } + root } /// The inclusive index of the LAST entry of the logical message `at` @@ -3389,6 +3434,22 @@ fn logical_message_end(entries: &[roboco_doc::SessionMessageEntry], at: usize) - end.max(at) } +/// Whether the provider turn the boundary entry belongs to CONTINUES past +/// the copy's end (story 60): a steer-split reply's later segments carry +/// the same turn ref, and a native fork cuts at the whole turn — so a +/// boundary that is not its turn's last segment must fall back to the +/// budgeted paste (later entries the boundary excluded would otherwise +/// reach the copy through the provider session). +fn turn_continues_past( + entries: &[roboco_doc::SessionMessageEntry], + boundary: usize, + reference: &roboco_proto::ProviderTurnRef, +) -> bool { + entries[boundary.saturating_add(1)..] + .iter() + .any(|entry| entry.provider_turn.as_ref() == Some(reference)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/run_journal.rs b/crates/engine/src/run_journal.rs index faf9f8cc34..4b18ac4196 100644 --- a/crates/engine/src/run_journal.rs +++ b/crates/engine/src/run_journal.rs @@ -432,11 +432,11 @@ impl Iterator for RevEvents { } } -/// Journal (`.jsonl`) and resume-budget (`.resume`) paths for `chat_id` under an -/// arbitrary journals directory — profile import copies these files between -/// profiles without opening a `RunJournal`. The consumed-steer record -/// (`.steers`, restart correctness) belongs in that copy too: a journal -/// imported without it replays a steer its harness had already consumed. +/// Journal (`.jsonl`) and resume-budget (`.resume`) paths for `chat_id` under +/// an arbitrary journals directory. Only those two: the consumed-steer record +/// (`.steers`, restart correctness) is a separate file that this helper does +/// NOT name — copy it alongside these when moving a profile, or an imported +/// journal replays a steer its harness had already consumed. pub fn journal_paths(dir: &Path, chat_id: &str) -> (PathBuf, PathBuf) { let stem = sanitize_id(chat_id); ( diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 175f07ef74..de7c67c4ec 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -210,18 +210,29 @@ impl ScheduledResumes { } fn persist(&self) { - let Some(path) = lock(&self.path).clone() else { - return; + // Snapshot under the locks (both maps in one acquisition, so an + // arming racing this persist cannot write a file that mixes + // pre- and post-arm state), then write outside them. + let (path, file) = { + let path = lock(&self.path).clone(); + let file = { + let entries = lock(&self.entries); + let fired = lock(&self.fired); + ScheduledResumeFile { + resumes: entries + .iter() + .map(|(chat, entry)| ScheduledResumeRecord { + chat_id: chat.clone(), + fire_at: entry.fire_at, + }) + .collect(), + fired: fired.clone(), + } + }; + (path, file) }; - let file = ScheduledResumeFile { - resumes: lock(&self.entries) - .iter() - .map(|(chat, entry)| ScheduledResumeRecord { - chat_id: chat.clone(), - fire_at: entry.fire_at, - }) - .collect(), - fired: lock(&self.fired).clone(), + let Some(path) = path else { + return; }; if let Err(err) = write_file(&path, &file) { tracing::warn!(path = %path.display(), error = %err, "scheduled-resume persist failed"); @@ -245,10 +256,26 @@ fn load_file(path: &Path) -> ScheduledResumeFile { }) } +/// Write atomically (temp file + rename): a crash or power loss mid-write +/// must never leave a truncated `scheduled-resumes.json` that boots as +/// "unreadable — nothing armed", silently dropping every pending +/// continuation. The rename lands whole or not at all. fn write_file(path: &Path, file: &ScheduledResumeFile) -> std::io::Result<()> { let bytes = serde_json::to_vec_pretty(file) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; - std::fs::write(path, bytes) + write_file_atomic(path, &bytes) +} + +/// Temp-file-then-rename, with the temp file in the target's directory so +/// the rename stays on one filesystem. +fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let dir = path.parent().unwrap_or_else(|| path.as_path()); + let mut staged = tempfile::NamedTempFile::new_in(dir)?; + staged.write_all(bytes)?; + staged.flush()?; + staged.as_file().sync_all()?; + staged.persist(path).map_err(|err| err.error) } fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index baa0f9ce6b..a8c432326b 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -70,6 +70,13 @@ type PendingInputs = Arc<Mutex<HashMap<String, oneshot::Sender<Vec<UserInputAnsw struct HarnessSessionRef { session_id: String, cwd: String, + /// The harness the session belongs to, when it is known (the run that + /// reported it always knows). `None` for a journal-derived session (the + /// journal records no harness): trusted as-is. A session recorded under + /// ANOTHER harness never rides a resume — a harness switch clears the + /// ref, and this guard covers the races around that clear (ticket 24's + /// "the next send starts a fresh provider session"). + harness: Option<HarnessId>, } /// The configuration a live harness runtime launched with. The steering @@ -353,11 +360,16 @@ impl SessionsEngine { lock(&self.inner.agent_accounts).clone() } - /// The harness-native session a run of `chat_id` from `cwd` would - /// resume (crate-internal; the failover's continuation names it - /// explicitly so its fallback history can pair with it). - pub(crate) fn harness_session_to_resume(&self, chat_id: &str, cwd: &str) -> Option<String> { - self.inner.resume_for(chat_id, cwd) + /// The harness-native session a run of `for_harness` on `chat_id` from + /// `cwd` would resume (crate-internal; the failover's continuation names + /// it explicitly so its fallback history can pair with it). + pub(crate) fn harness_session_to_resume( + &self, + chat_id: &str, + cwd: &str, + for_harness: HarnessId, + ) -> Option<String> { + self.inner.resume_for(chat_id, cwd, for_harness) } /// The ranked switch targets for one Limited chat's "Resume on another @@ -487,11 +499,15 @@ impl SessionsEngine { /// An observation report from the Roboco MCP server (ticket 14): the /// chat `parent` — the server's calling chat — just observed `side_chat` /// after its last outcome happened, through `wait_for_turn`, a send - /// with wait, `read_chat` or `get_chat`. Outcomes the parent already - /// observed owe no notice: they are dropped, and a record that lands - /// right after the report raced it is dropped too. - pub(crate) fn ack_notice(&self, parent: &str, side_chat: &str) { - let watermark = self.inner.notices.ack(parent, side_chat); + /// with wait, `read_chat` or `get_chat`. `observed_at` is the moment + /// the observation started (epoch ms), when the caller knows it: the + /// watermark covers only the settles that had happened by then, so an + /// outcome that landed between the read and this report — the parent's + /// unseen work — survives. Outcomes the parent already observed owe no + /// notice: they are dropped, and a record that lands right after the + /// report raced it is dropped too. + pub(crate) fn ack_notice(&self, parent: &str, side_chat: &str, observed_at: Option<i64>) { + let watermark = self.inner.notices.ack(parent, side_chat, observed_at); tracing::debug!( parent, side_chat, @@ -535,8 +551,17 @@ impl SessionsEngine { /// (`roboco_proto::AgentOrigin` on the chat) names, and only while it /// did not opt out of notices at creation. `None` for user-typed sends /// (no origin), third-party sends, user-created side chats (no agent - /// origin) and opted-out creators — those never notify. - fn notifiable_parent_for(&self, chat_id: &str, origin: Option<&str>) -> Option<String> { + /// origin) and opted-out creators — those never notify. `sent_at` (the + /// moment the message was sent, when the caller knows it) gates the + /// stopped-parent rule: a message queued or sent BEFORE the parent's + /// last Stop does not re-arm the chat (story 24 counts only later + /// messages); `None` (time unknown) never gates. + fn notifiable_parent_for( + &self, + chat_id: &str, + origin: Option<&str>, + sent_at: Option<i64>, + ) -> Option<String> { let origin = origin?; let agent_origin = self .inner @@ -547,6 +572,14 @@ impl SessionsEngine { .notify .then(|| agent_origin.chat_id) .filter(|parent| parent == origin) + .filter(|parent| { + sent_at.is_none_or(|at| { + self.inner + .notices + .parent_stopped_at(parent) + .is_none_or(|stop| at > stop) + }) + }) } /// Stamp a live run's notifiable parent for a message that just joined @@ -554,13 +587,21 @@ impl SessionsEngine { /// turn's notifiability is that message's own. One into a working turn /// only ever ARMS notices — a parent message makes the turn notifiable; /// anything else (a user's steer) leaves the starting message's claim - /// intact. - fn mark_notifiable(&self, chat_id: &str, run_id: &str, origin: Option<&str>) { + /// intact. `sent_at` (the message's send time, when known) carries the + /// stopped-parent rule through: a message sent before the parent's last + /// Stop must not re-arm. + fn mark_notifiable( + &self, + chat_id: &str, + run_id: &str, + origin: Option<&str>, + sent_at: Option<i64>, + ) { let mid_turn = matches!( self.session_status(chat_id).map(|session| session.status), Some(SessionStatus::Working) | Some(SessionStatus::AwaitingInput) ); - let from_parent = self.notifiable_parent_for(chat_id, origin); + let from_parent = self.notifiable_parent_for(chat_id, origin, sent_at); let mut handle = lock(&self.inner.runs); let Some(run) = handle.get_mut(chat_id).filter(|h| h.run_id == *run_id) else { return; @@ -701,6 +742,13 @@ impl SessionsEngine { /// through the Roboco MCP server; the doc-host command path has usually /// written the entry already (idempotent by id), and the queue-drain /// path relies on this write to stamp it. + /// + /// `sent_at` is the message's send time (epoch ms) when the caller knows + /// it — a queued row's or a command's issue time. It carries the + /// stopped-parent re-arm rule (ticket 14): a message sent before the + /// origin chat's last Stop does not re-arm that side chat's notices. + /// `None` (unknown, or an engine-authored send) never gates. + #[allow(clippy::too_many_arguments)] pub async fn dispatch( &self, chat_id: &str, @@ -708,6 +756,7 @@ impl SessionsEngine { request: RunRequest, message_id: Option<String>, origin_chat_id: Option<String>, + sent_at: Option<i64>, ) -> Result<String, EngineError> { self.dispatch_with( chat_id, @@ -716,6 +765,7 @@ impl SessionsEngine { message_id, origin_chat_id, false, + sent_at, ) .await } @@ -725,6 +775,7 @@ impl SessionsEngine { /// final (its own startup death surfaces instead of retrying again). /// Boxed future: `drive_run` re-enters this for that retry, and the /// erasure breaks the opaque-type cycle the recursion would otherwise form. + #[allow(clippy::too_many_arguments)] fn dispatch_with<'a>( &'a self, chat_id: &'a str, @@ -741,9 +792,11 @@ impl SessionsEngine { message_id, origin_chat_id, startup_retry, + sent_at, )) } + #[allow(clippy::too_many_arguments)] async fn dispatch_inner( &self, chat_id: &str, @@ -752,6 +805,7 @@ impl SessionsEngine { mut message_id: Option<String>, origin_chat_id: Option<String>, startup_retry: bool, + sent_at: Option<i64>, ) -> Result<String, EngineError> { // Project-less chats store cwd `~` (the creating device can't know the // host's home); expand it here, on the host, where the run spawns. @@ -847,7 +901,7 @@ impl SessionsEngine { // flight (arming its completion notice) or starts the parked // session's next turn (whose notifiability is the message's // own) — orchestration v2. - self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref()); + self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref(), sent_at); if reconfigure { // The runtime runs this configuration from now on: later // sends compare against it, and a re-dispatch of an @@ -950,7 +1004,9 @@ impl SessionsEngine { // and starting the retry fresh silently dropped a good conversation. let mut resume_injected = false; if request.resume.is_none() { - request.resume = self.inner.resume_for(chat_id, &request.cwd); + request.resume = self + .inner + .resume_for(chat_id, &request.cwd, harness_id); resume_injected = request.resume.is_some(); } // A fork's first run continues the SOURCE's provider session @@ -1044,7 +1100,7 @@ impl SessionsEngine { // and replace this runtime when the login has changed. credential_fingerprint: live_login.clone(), notifiable: Mutex::new( - self.notifiable_parent_for(chat_id, origin_chat_id.as_deref()), + self.notifiable_parent_for(chat_id, origin_chat_id.as_deref(), sent_at), ), retiring: Arc::new(std::sync::atomic::AtomicBool::new(false)), }, @@ -1207,7 +1263,7 @@ impl SessionsEngine { // A parent's steer within the turn keeps the turn notifiable; one // into a parked session defines the next turn's notifiability — // orchestration v2 completion notices. - self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref()); + self.mark_notifiable(chat_id, &run_id, origin_chat_id.as_deref(), Some(issued_at)); let handle = self.doc_handle(chat_id)?; handle.write_user_message( &user_id, @@ -1290,8 +1346,14 @@ impl SessionsEngine { /// — still running, and the next prompt routes into it. Drivers without /// that, runs whose harness has not started yet, and stops that do not /// settle fall back to [`Self::terminate`]. + /// + /// Like [`Self::interrupt`], this is the user stopping the work in front + /// of them: the notices the chat is owed as a parent go with the turn it + /// kills (ticket 14) — a stopped parent must not be woken by a notice for + /// work the stop just ended. pub async fn stop_turn(&self, chat_id: &str) -> Result<bool, EngineError> { const STOP_SETTLE: std::time::Duration = std::time::Duration::from_secs(15); + crate::completion_notices::stop_parent(self, chat_id); let target = lock(&self.inner.runs) .get(chat_id) .filter(|h| { @@ -1387,7 +1449,7 @@ impl SessionsEngine { config: roboco_proto::ChatConfig, ) -> Result<(), EngineError> { let Some(ws) = self.inner.workspace() else { - return Err(EngineError::Other("workspace unavailable".into())); + return Err(EngineError::Other("The registry is unavailable".into())); }; let chat = ws .chat(chat_id)? @@ -1442,6 +1504,12 @@ impl SessionsEngine { // the new harness), and the native-fork intent above. lock(&self.inner.harness_sessions).remove(chat_id); ws.set_chat_harness_session(chat_id, "", ""); + // Sends held for the turn end carry the FULL request they were sent + // with — the old harness, its model, its options — and the drain + // prefers them over the chat row: rewrite them onto the switch's + // config, or the first message after the switch would run on the + // old harness (ticket 24). + handle.rewrite_held_sends_for_switch(&config); ws.set_chat_config(chat_id, &config)?; Ok(()) } @@ -1502,16 +1570,26 @@ impl SessionsEngine { // same harness conversation (roboco recoverDraft, sessions.ts:538). if let Some((session_id, cwd)) = self.inner.journal_harness_session(&chat_id) { self.inner - .remember_harness_session(&chat_id, &session_id, &cwd); + .remember_harness_session(&chat_id, &session_id, &cwd, None); } - // The revival prompt: the last user message. Its structured origin - // rides along, so a parent-started turn that is revived stays - // notifiable for its completion notice (orchestration v2). + // The revival prompt: the turn's starting message. Its structured + // origin rides along, so a parent-started turn that is revived stays + // notifiable for its completion notice (orchestration v2). A + // notice-started turn's starting entry is the engine's SYSTEM + // notice — the loop cap's parked row or a delivered notice — so it + // is considered here too: without it, the revival would pick the + // USER message under the notice and re-send the parent's stale + // instruction for work the notice already answered. let prompt = handle.doc().read_entries().ok().and_then(|entries| { entries .iter() .rev() - .find(|e| e.role == MessageRole::User) + .find(|e| { + e.role == MessageRole::User + || (e.role == MessageRole::System + && e.id + .starts_with(crate::completion_notices::NOTICE_ID_PREFIX)) + }) .and_then(|e| { e.parts.iter().find_map(|p| match p { MessagePart::Text { text, .. } => Some(( @@ -1523,6 +1601,15 @@ impl SessionsEngine { }) }) }); + // A notice-started turn revives with the engine's continue prompt + // (the same rule a consumed steer gets, ticket 04) under the + // notice entry's own id: `write_user_message` dedupes onto the + // System entry that is already there, and the loop counter still + // reads the revival as notice-started — never as the user's fresh + // message — so the cap's streak is not reset by a crash. + let notice_revival = prompt.as_ref().is_some_and(|(id, _, _)| { + id.starts_with(crate::completion_notices::NOTICE_ID_PREFIX) + }); // Restart correctness (orchestration v2, ticket 04): a steer the // harness had already confirmed consuming before the crash is // NOT re-delivered — the model answered it once, and a replay @@ -1541,6 +1628,11 @@ impl SessionsEngine { self.inner.journal.steer_consumed(&chat_id, user_id) }); let prompt = match prompt { + Some((id, _text, origin)) if notice_revival => Some(( + id, + roboco_proto::RESUME_CONTINUE_PROMPT.to_string(), + origin, + )), Some((user_id, _text, origin)) if steer_consumed => Some(( format!("auto-resume-continue:{chat_id}:{user_id}"), roboco_proto::RESUME_CONTINUE_PROMPT.to_string(), @@ -1604,7 +1696,9 @@ impl SessionsEngine { let notifiable = prompt .as_ref() .and_then(|(_, _, origin)| origin.as_deref()) - .and_then(|origin| self.notifiable_parent_for(&chat_id, Some(origin))) + .and_then(|origin| { + self.notifiable_parent_for(&chat_id, Some(origin), None) + }) .filter(|parent| { !crate::completion_notices::chat_archived(self, &chat_id) && !crate::completion_notices::chat_archived(self, parent) @@ -1757,7 +1851,19 @@ impl SessionsEngine { /// Boot recovery: arm timers for every continuation that survived the /// restart — an overdue one fires now, after the engine is up. Called /// from assembly once the doc host is wired (firing needs it). + /// + /// It also REPUBLISHES the client-visible rows a restart cleared: the + /// in-memory status map (the merged WatchSessions source for this + /// device's chats) starts empty at boot, so a still-Limited chat and + /// its armed continuation would vanish from the banner — "resumes in…", + /// Cancel, and `ScheduleResumeAtReset` (which reads the in-memory limit + /// record) all refused. The durable workspace rows still carry both, so + /// the boot re-creates the in-memory rows from them: still-Limited + /// chats keep their limit record, chats with an armed continuation keep + /// its row, and a fresh transition overwrites the republished row as + /// usual. pub fn recover_scheduled_resumes(&self) { + self.republish_durable_rows(); let pending = self.inner.scheduled_resumes.pending(); for (ix, (chat_id, fire_at, disarmed)) in pending.into_iter().enumerate() { // Same stagger discipline as the crashed-run auto-resume: a @@ -1765,6 +1871,11 @@ impl SessionsEngine { // as one boot burst of CLI spawns. let delay = std::time::Duration::from_millis(ix as u64 * RESUME_STAGGER_MS); self.spawn_resume_timer(chat_id.clone(), fire_at, disarmed, delay); + // The armed continuation's row rides the republished status row + // (the durable row may predate the restart's last write): the + // banner keeps its countdown and its Cancel. + self.inner + .set_scheduled_resume_row(&chat_id, Some(ScheduledResume { fire_at })); tracing::info!( chat = %chat_id, fire_at, @@ -1774,6 +1885,48 @@ impl SessionsEngine { } } + /// Re-create the in-memory status rows a restart wiped, from the durable + /// workspace rows: only chats that are still Limited (errored with a + /// limit record) — a row that says Working or AwaitingInput belongs to + /// `recover_stale`'s revival, and an idle row owes clients nothing. The + /// settle serial of a republished row is unknown (the serials restart + /// with the engine); the row's limit record is what the banner and + /// `schedule_resume_at_reset` read. + fn republish_durable_rows(&self) { + let Some(ws) = self.inner.workspace() else { + return; + }; + let rows = match ws.read_sessions() { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(error = %err, "boot republish of session rows failed"); + return; + } + }; + let mut statuses = lock(&self.inner.statuses); + let mut republished = 0usize; + for row in rows { + if row.status != SessionStatus::Errored || row.limit.is_none() { + continue; + } + // A run the boot revival is about to own: its transition lands + // through the normal path; do not pre-seed a row the revival's + // own set_status overwrites anyway. + if statuses.contains_key(&row.chat_id) { + continue; + } + let chat_id = row.chat_id.clone(); + statuses.insert(chat_id, row); + republished += 1; + } + if republished > 0 { + let mut list: Vec<Session> = statuses.values().cloned().collect(); + list.sort_by(|a, b| a.chat_id.cmp(&b.chat_id)); + self.inner.sessions_tx.send_replace(list); + tracing::info!(republished, "limited session rows republished at boot"); + } + } + /// Arm (or re-arm) the chat's Resume-at-reset continuation at `fire_at`: /// store it durably, publish it on the status row, spawn its timer. fn arm_scheduled_resume(&self, chat_id: &str, fire_at: i64) { @@ -1940,6 +2093,7 @@ impl SessionsEngine { request, Some(message_id), None, + None, ) .await { @@ -2380,8 +2534,16 @@ impl Inner { /// Record the chat's harness-native session id (and its cwd): live-process /// cache plus the durable workspace chat row — the row is what survives an - /// engine restart (roboco sessions.ts:1039). - fn remember_harness_session(&self, chat_id: &str, session_id: &str, cwd: &str) { + /// engine restart (roboco sessions.ts:1039). `harness` is the harness the + /// session belongs to when the caller knows it (a run's own + /// SessionStarted always does); `None` (journal-derived) trusts the id. + fn remember_harness_session( + &self, + chat_id: &str, + session_id: &str, + cwd: &str, + harness: Option<HarnessId>, + ) { if session_id.is_empty() { return; } @@ -2390,6 +2552,7 @@ impl Inner { HarnessSessionRef { session_id: session_id.to_string(), cwd: cwd.to_string(), + harness, }, ); if let Some(ws) = self.workspace() { @@ -2404,15 +2567,18 @@ impl Inner { // conversations (user incident 2026-08-13). A truly stale id simply // yields a fresh session whose SessionStarted overwrites the row. - /// The session id to resume for a run in `chat_id` launching from `cwd` - /// (roboco sessions.ts:736, looked up on every dispatch): - /// live-process cache → workspace chat row → journal scan (the crash path - /// where the debounced row write never landed — SessionStarted/Done events - /// are journaled per event, flushed immediately). Cwd-gated throughout: - /// harness session stores are keyed by cwd, so a session created elsewhere - /// never rides `--resume`. An empty stored id is the explicit tombstone — - /// no resume, no falling through to staler sources. - fn resume_for(&self, chat_id: &str, cwd: &str) -> Option<String> { + /// The session id to resume for a run of `for_harness` in `chat_id` + /// launching from `cwd` (roboco sessions.ts:736, looked up on every + /// dispatch): live-process cache → workspace chat row → journal scan (the + /// crash path where the debounced row write never landed — + /// SessionStarted/Done events are journaled per event, flushed + /// immediately). Cwd-gated throughout: harness session stores are keyed + /// by cwd, so a session created elsewhere never rides `--resume`. And + /// HARNESS-gated: a session recorded under another harness (a switch's + /// clear raced, or the row config changed) never rides this resume — the + /// new harness gets a fresh session. An empty stored id is the explicit + /// tombstone — no resume, no falling through to staler sources. + fn resume_for(&self, chat_id: &str, cwd: &str, for_harness: HarnessId) -> Option<String> { // The same directory, however it is spelled: an agent reports where // it runs with symlinks resolved (`/private/var/…` for `/var/…` on // macOS), and until a turn's Done restores the request's spelling a @@ -2425,18 +2591,36 @@ impl Inner { .is_ok_and(|a| std::fs::canonicalize(cwd).is_ok_and(|b| a == b)) }; if let Some(known) = lock(&self.harness_sessions).get(chat_id).cloned() { - return (!known.session_id.is_empty() && cwd_ok(&known.cwd)) - .then_some(known.session_id); + if known.harness.is_some_and(|recorded| recorded != for_harness) { + // Another harness's conversation: fall through to the row, + // never resume it here. + } else { + return (!known.session_id.is_empty() && cwd_ok(&known.cwd)) + .then_some(known.session_id); + } } - if let Some(ws) = self.workspace() - && let Some((session_id, session_cwd)) = ws.chat_harness_session(chat_id) - { - return (!session_id.is_empty() && cwd_ok(session_cwd.as_deref().unwrap_or(""))) - .then_some(session_id); + if let Some(ws) = self.workspace() { + let row_harness = ws + .chat(chat_id) + .ok() + .flatten() + .and_then(|chat| chat.config) + .map(|config| config.harness); + if let Some((session_id, session_cwd)) = ws.chat_harness_session(chat_id) { + if row_harness.is_some_and(|recorded| recorded != for_harness) { + // The row's own config names another harness: its stored + // session id is that harness's, not this run's. + } else { + return (!session_id.is_empty() && cwd_ok(session_cwd.as_deref().unwrap_or(""))) + .then_some(session_id); + } + } } let (session_id, session_cwd) = self.journal_harness_session(chat_id)?; - // Cache the journal hit (memory + row) so later dispatches skip the scan. - self.remember_harness_session(chat_id, &session_id, &session_cwd); + // Cache the journal hit (memory + row) so later dispatches skip the + // scan. The journal records no harness: trusted as-is (the scan is + // the last resort, and a switched chat's tombstone never reaches it). + self.remember_harness_session(chat_id, &session_id, &session_cwd, None); cwd_ok(&session_cwd).then_some(session_id) } @@ -3758,6 +3942,10 @@ async fn drive_run( idle_since = Some(tokio::time::Instant::now()); reap_deferred_until = None; self_continued_turn = false; + // The watchdog finalized a completed turn (no native + // Done): same rule as a Done — the turn's ref must not + // leak into whatever turn follows in this runtime. + turn_ref = None; let settle = inner.notices.settle(&chat_id); inner.set_status_with_completion( &chat_id, SessionStatus::Idle, false, completed_turn.clone(), @@ -3795,11 +3983,19 @@ async fn drive_run( // marking): the harness rejected its login — the login is dead, not // stale. Checked BEFORE the subagent routing and the parked gate so // every arrival shape counts (a subagent running on the same login - // fails signed-out just as hard), and awaited inline so the mark has - // landed by the time the run's own terminal state settles. + // fails signed-out just as hard). Spawned, not awaited: the mark + // queues behind the accounts ops lock, which a usage probe can hold + // across a network round trip — awaiting it inline stalls this loop + // (streaming, Done) for seconds. The run's own terminal state + // settles independently; the failover watcher re-checks the mark + // after the settle, bounded, so a mark that lands late is still seen. if !marked_signed_out && event_signs_out(&event) { marked_signed_out = true; - inner.note_run_signed_out(&chat_id, harness_id).await; + let inner = inner.clone(); + let chat = chat_id.clone(); + tokio::spawn(async move { + inner.note_run_signed_out(&chat, harness_id).await; + }); } // ── subagent routing ─────────────────────────────────────────── @@ -4166,7 +4362,7 @@ async fn drive_run( // The user entry write inside dispatch is idempotent by // message id; `startup_retry` makes this attempt final. if let Err(err) = engine - .dispatch_with(&chat, harness_id, retry, Some(message_id), None, true) + .dispatch_with(&chat, harness_id, retry, Some(message_id), None, true, None) .await { tracing::error!(chat = %chat, error = %err, "startup-crash retry dispatch failed"); @@ -4249,7 +4445,7 @@ async fn drive_run( saw_session_started = true; // The event's own cwd (where the harness actually created the // session) scopes the stored id, not the request's. - inner.remember_harness_session(&chat_id, session_id, cwd); + inner.remember_harness_session(&chat_id, session_id, cwd, Some(harness_id)); note_fork_history_session(&doc, carried_history, session_id); } AgentEvent::TurnRef { reference } => { @@ -4263,7 +4459,7 @@ async fn drive_run( session_id: Some(session_id), .. } => { - inner.remember_harness_session(&chat_id, session_id, &run_cwd); + inner.remember_harness_session(&chat_id, session_id, &run_cwd, Some(harness_id)); note_fork_history_session(&doc, carried_history, session_id); } AgentEvent::InputRequested { questions, .. } => { @@ -4301,8 +4497,35 @@ async fn drive_run( AgentEvent::Error { kind, .. } => { // Track the latest classification; a later unclassified // error supersedes an earlier limit one (the turn then ended - // on that failure, not the limit). - last_error_kind = kind.clone(); + // on that failure, not the limit). A later limit that is + // LESS specific than the earlier one never wins: Claude's + // terse `rate_limit` code (window "usage", no reset) lands + // after the detailed `rate_limit_event` that named the + // window and the reset — keep the earlier detail when the + // later lacks it. + last_error_kind = match (last_error_kind.take(), kind.clone()) { + ( + Some(AgentErrorKind::UsageLimit { + window: earlier_window, + resets_at: earlier_reset, + }), + Some(AgentErrorKind::UsageLimit { + window: later_window, + resets_at: later_reset, + }), + ) => { + let window = if later_window != "usage" || earlier_window == "usage" { + later_window + } else { + earlier_window + }; + Some(AgentErrorKind::UsageLimit { + window, + resets_at: later_reset.or(earlier_reset), + }) + } + (_, later) => later, + }; } _ => {} } @@ -4430,6 +4653,14 @@ async fn drive_run( idle_since = Some(tokio::time::Instant::now()); reap_deferred_until = None; self_continued_turn = false; + // The turn is over: its provider turn ref must not leak into + // the next turn's segments. Harnesses that emit the ref + // before their segments (Claude, Codex, OpenCode) re-stamp + // it at the next turn's start; Pi emits its ref at settle, so + // without this clear a steer-split reply in the NEXT turn + // would carry THIS turn's ref (ticket 17's fork boundary + // would cut the wrong place). + turn_ref = None; // The settle serial stamps before the idle status publishes // (ticket 14): an observer of the parked turn's completion // has observed this settle. @@ -4602,6 +4833,7 @@ async fn drive_run( request, Some(steer.message_id.clone()), steer.origin_chat_id.clone(), + None, ) .await { @@ -5167,7 +5399,7 @@ mod tests { .unwrap(); let chat = "chat-sink-spin"; core.sessions - .dispatch(chat, HarnessId::Mock, request(), None, None) + .dispatch(chat, HarnessId::Mock, request(), None, None, None) .await .unwrap(); feed.send(AgentEvent::SessionStarted { diff --git a/crates/engine/src/transcript_budget.rs b/crates/engine/src/transcript_budget.rs index e06c4cc141..314f5e0298 100644 --- a/crates/engine/src/transcript_budget.rs +++ b/crates/engine/src/transcript_budget.rs @@ -53,7 +53,11 @@ const ITEM_CAP_BYTES: usize = TRANSCRIPT_BUDGET_BYTES / 4; /// omitted rather than shipped as a meaningless stub. const MIN_ITEM_BYTES: usize = 64; -const LEAD: &str = "Continue this side chat using the following prior transcript as context."; +/// The lead is shared by every budgeted-history use (ticket 01): a side +/// chat's fork bootstrap, a main chat's harness switch, and an account +/// failover's fresh-session fallback — so it names neither of them, only +/// the chat and the transcript. +const LEAD: &str = "Continue this chat using the following prior transcript as context."; const TRANSCRIPT_OPEN: &str = "<transcript>\n"; const TRANSCRIPT_CLOSE: &str = "\n</transcript>\n\n"; const TRUNCATION_MARKER: &str = " …[truncated]"; @@ -72,6 +76,7 @@ struct TranscriptItem { /// `source_chat_id` is the chat whose transcript the omitted messages are /// readable in — the fork's source for the fork bootstrap, the chat itself /// for harness switches and account failovers. +#[cfg(test)] pub fn budgeted_transcript_prompt( entries: &[SessionMessageEntry], source_chat_id: Option<&str>, diff --git a/crates/engine/tests/account_failover.rs b/crates/engine/tests/account_failover.rs index d92b7dbb73..7cbdec01a3 100644 --- a/crates/engine/tests/account_failover.rs +++ b/crates/engine/tests/account_failover.rs @@ -913,6 +913,37 @@ async fn a_plain_failure_restores_the_previous_account() { .await; let active = active_row(&client).await.expect("an active account"); assert_eq!(active.email.as_deref(), Some("alice@example.com")); + // The failure is REPORTED (ticket 09): a System entry on the chat says + // the failover failed and the previous account is back — the banner + // (and the web client, which renders System entries) shows it. + wait_for( + || { + core.doc_host + .open(LIMITED) + .is_ok_and(|handle| { + handle + .doc() + .read_entries() + .map(|entries| { + entries.iter().any(|entry| { + entry.role == roboco_doc::MessageRole::System + && entry + .parts + .iter() + .any(|part| matches!(part, + roboco_doc::MessagePart::Text { text, .. } + if text.starts_with( + "Resume on another account failed: the resumed turn failed." + ) && text.contains("The harness is back on") + )) + }) + }) + .unwrap_or(false) + }) + }, + "the failed failover to be reported on the chat", + ) + .await; // No second candidate: the chain ends with the restore. The settle // beat lets a stray third run fail the count assertion. tokio::time::sleep(Duration::from_millis(750)).await; diff --git a/crates/engine/tests/account_sign_in_state.rs b/crates/engine/tests/account_sign_in_state.rs index 7bfec2035a..dd3a6a7b47 100644 --- a/crates/engine/tests/account_sign_in_state.rs +++ b/crates/engine/tests/account_sign_in_state.rs @@ -247,7 +247,7 @@ async fn a_signed_out_run_error_marks_the_active_account_until_a_probe_clears_it assert_eq!(claude_sign_in_state(&client, false).await, "unknown"); core.sessions - .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None) + .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None, None) .await .expect("dispatch"); wait_for_status(&core, CHAT, SessionStatus::Errored).await; @@ -270,7 +270,7 @@ async fn a_subagents_signed_out_error_marks_the_shared_login_too() { assert_eq!(claude_sign_in_state(&client, false).await, "unknown"); core.sessions - .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None) + .dispatch(CHAT, HarnessId::ClaudeCode, run_request("hello"), None, None, None) .await .expect("dispatch"); wait_for_status(&core, CHAT, SessionStatus::Errored).await; diff --git a/crates/engine/tests/codex_subagents.rs b/crates/engine/tests/codex_subagents.rs index 8d679cac83..8ad48626b3 100644 --- a/crates/engine/tests/codex_subagents.rs +++ b/crates/engine/tests/codex_subagents.rs @@ -75,7 +75,7 @@ async fn check_persistence( request.clone(), Some("user-prompt".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(Duration::from_secs(10), async { @@ -181,7 +181,7 @@ async fn check_persistence( request, Some("user-followup".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(Duration::from_secs(10), async { diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 3a481945c2..80ea0ed920 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -51,6 +51,7 @@ use roboco_sync::DocsStore; const PARENT: &str = "chat-parent"; const SIDE: &str = "chat-side"; +const SIDE2: &str = "chat-side-2"; /// What one `run()` call does — the tests script turns FIFO, one entry per /// run the engine starts (a side chat's turn, then the parent's notice @@ -1411,6 +1412,56 @@ async fn an_archived_parent_receives_no_notices() { core.shutdown().await; } +/// A deleted parent is owed nothing (ticket 13): the delete path drops the +/// parent's pending outcomes — the durable ledger forgets them, and a +/// delivery attempt for a chat whose row is gone stays quiet instead of +/// waking a chat that no longer exists. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_deleted_parent_is_owed_nothing() { + let dir = tempfile::tempdir().unwrap(); + // The child's turn, the parent's working turn (Hang), then nothing for + // the parent — the parked notice's turn never happens. + let scripted = Scripted::new(vec![Turn::Hang, Turn::End { + status: DoneStatus::Completed, + error: None, + }]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + // The parent works; the child's outcome is recorded and stays pending. + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 1, + "the child's outcome to be recorded", + ) + .await; + + // The parent is deleted (the delete path drops what it is owed). + core.workspace.delete_chat(PARENT).unwrap(); + assert_eq!( + ledger_rows(dir.path()), + 0, + "the deleted parent's pending outcomes go with the chat" + ); + + // Nothing wakes the gone chat: no notice turn was started for it. + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + scripted.run_count(), + 2, + "a deleted parent receives nothing: no notice turn started" + ); + core.shutdown().await; +} + /// An archived side chat sends nothing: archiving the child mid-run /// suppresses the outcome its stopped turn would otherwise owe (an /// interrupt is not a message, so it never revives the archived chat). @@ -1523,11 +1574,26 @@ async fn notices_owed_to_a_parent_archived_before_delivery_are_dropped_at_boot() /// outcome happened, through `wait_for_turn`, a send with wait, `read_chat` /// or `get_chat`. All four arrive as this one RPC. async fn acknowledge(client: &roboco_rpc::RpcClient, side: &str, parent: &str) { + acknowledge_at(client, side, parent, None).await; +} + +/// The observation report with the moment the observation STARTED (the +/// shape the Roboco MCP server sends for `read_chat`/`get_chat`/waits): +/// the engine's watermark covers only the settles that had happened by +/// then, so an outcome landing between the read and this report — the +/// parent's unseen work — survives it. +async fn acknowledge_at( + client: &roboco_rpc::RpcClient, + side: &str, + parent: &str, + observed_at: Option<i64>, +) { + let mut params = serde_json::json!({ "chatId": side, "originChatId": parent }); + if let Some(at) = observed_at { + params["observedAt"] = serde_json::json!(at); + } client - .call( - roboco_rpc::methods::ACK_SIDE_CHAT, - serde_json::json!({ "chatId": side, "originChatId": parent }), - ) + .call(roboco_rpc::methods::ACK_SIDE_CHAT, params) .await .expect("report observation"); } @@ -1542,6 +1608,19 @@ fn stop(core: &EngineCore, chat: &str) { /// The paused queue rows a chat currently shows (WatchQueue's surface). fn queue_rows(core: &EngineCore, chat: &str) -> Vec<(String, String)> { + queue_rows_with_sides(core, chat) + .into_iter() + .map(|(id, text, _)| (id, text)) + .collect() +} + +/// The queue rows with the completion-notice sides a parked notice row +/// carries (ticket 14): the row is the notice's durable carrier while it +/// waits, and its release writes the entry those sides name. +fn queue_rows_with_sides( + core: &EngineCore, + chat: &str, +) -> Vec<(String, String, Option<Vec<NoticeSide>>)> { core.doc_host .open(chat) .expect("chat doc") @@ -1549,7 +1628,7 @@ fn queue_rows(core: &EngineCore, chat: &str) -> Vec<(String, String)> { .read_queue() .expect("queue") .into_iter() - .map(|row| (row.id, row.text)) + .map(|row| (row.id, row.text, row.completion_notice)) .collect() } @@ -1696,6 +1775,98 @@ async fn an_outcome_after_the_observation_still_notifies() { core.shutdown().await; } +/// The report carries WHEN the observation happened (ticket 14): a settle +/// that lands between the read and the report is the parent's UNSEEN +/// outcome — the ack must not drop it, or a busy child's report races away +/// its own completion notice. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_late_observation_report_never_drops_an_unseen_outcome() { + let dir = tempfile::tempdir().unwrap(); + let scripted = Scripted::new(vec![ + // The parent's working turn. + Turn::Hang, + // The child's first turn (seen by the observation). + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The child's second turn (settles between the read and the report). + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + // The parent's notice turn, for the second outcome only. + Turn::End { + status: DoneStatus::Completed, + error: None, + }, + ]); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + + queue_run(&client, PARENT, "orchestrate the release", "m-parent-1", None).await; + wait_for( + || scripted.prompts_containing("orchestrate the release"), + "the parent's turn to start", + ) + .await; + queue_run(&client, SIDE, "first task", "m-run-1", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 1, + "the first outcome to be recorded", + ) + .await; + + // The parent's read of the chat happens HERE. Millisecond separation is + // forced on both sides so the settle marks order unambiguously. + tokio::time::sleep(Duration::from_millis(5)).await; + let observed_at = chrono::Utc::now().timestamp_millis(); + tokio::time::sleep(Duration::from_millis(5)).await; + + // The child's second turn settles after the read: unseen by it. + queue_run(&client, SIDE, "second task", "m-run-2", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || ledger_rows(dir.path()) == 2, + "the second outcome to be recorded", + ) + .await; + + // The report arrives now — long after the read it speaks for. Only the + // settle the read saw is covered; the second outcome is owed. + acknowledge_at(&client, SIDE, PARENT, Some(observed_at)).await; + assert_eq!( + ledger_rows(dir.path()), + 1, + "the unseen outcome survives the late report; the seen one drops" + ); + + // The parent's turn ends: the notice covers the second outcome. + queue_user_steer(&client, PARENT, "please wrap up", "m-steer-1").await; + wait_for( + || scripted.prompts_containing("finished"), + "the unseen outcome's notice to start a parent turn", + ) + .await; + let notices = notice_entries(&core, PARENT); + assert_eq!(notices.len(), 1); + assert_eq!( + notice_sides(¬ices[0]), + vec![NoticeSide { + chat_id: SIDE.into(), + title: "Implement login".into(), + outcome: NoticeOutcome::Completed, + detail: None, + }], + "the notice reports the unseen outcome only" + ); + assert_eq!(scripted.run_count(), 4); + + core.shutdown().await; +} + /// Stopping the parent drops the notices owed to it and suppresses /// outcomes from side chats whose notifiable turns were running at that /// moment: the children keep running (Stop does not cascade), and their @@ -1875,9 +2046,10 @@ async fn a_later_parent_message_re_arms_the_child() { async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { let dir = tempfile::tempdir().unwrap(); // 27 child turns (25 rounds + the capped 26th + the post-reset 27th) - // + 26 notice turns + the user's message + the parked row's turn — - // every run pops one scripted turn. - let turns: Vec<Turn> = (0..27 + 26 + 1 + 1) + // + 26 notice turns + an agent's message to the parent + the user's + // message + the parked row's own turn — every run pops one scripted + // turn. + let turns: Vec<Turn> = (0..27 + 26 + 1 + 1 + 1) .map(|_| Turn::End { status: DoneStatus::Completed, error: None, @@ -1930,7 +2102,7 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { 51, "the 26th notice turn never ran (25 child + 25 notice + the 26th child)" ); - let rows = queue_rows(&core, PARENT); + let rows = queue_rows_with_sides(&core, PARENT); assert_eq!(rows.len(), 1, "exactly one parked notice row"); assert!( rows[0].0.starts_with("notice:"), @@ -1942,6 +2114,35 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { "the parked row shows the notice itself: {}", rows[0].1 ); + assert!( + rows[0].2.is_some(), + "the parked row carries the notice's sides, so its release writes the + same System completion-notice entry a direct delivery writes" + ); + + // An agent's message to the parent (a chat-origin Run, the MCP + // `send_message` surface) is NOT the user acting: it must not thaw the + // cap's pause, or a busy child lifts the brake that waits for the user + // (ticket 14's stopped-parent cap rule). The message still runs as its + // own turn — the queue, and the parked notice with it, stays held. + queue_run(&client, PARENT, "an agent nudges the parent", "m-agent", Some(SIDE)).await; + wait_for( + || scripted.prompts_containing("an agent nudges the parent"), + "the agent's turn to start", + ) + .await; + wait_idle(&core, PARENT).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!( + queue_rows(&core, PARENT).len(), + 1, + "an agent-origin message does not release the cap's parked notice" + ); + assert_eq!( + notice_entries(&core, PARENT).len(), + 25, + "the parked notice did not run on an agent's message" + ); // The user acts: their message resets the count, thaws the queue, and // runs as the parent's turn; the parked notice row follows it out. @@ -1953,18 +2154,32 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { .await; wait_idle(&core, PARENT).await; wait_for( - || scripted.run_count() == 53, + || scripted.run_count() == 54, "the parked notice row to run as the parent's next turn", ) .await; - // The row's turn is the 53rd run, carrying the notice text as its - // prompt — and it is a USER entry (the row), not a 26th system notice. - assert_eq!(notice_entries(&core, PARENT).len(), 25); + // The row's turn is the 54th run, carrying the notice text as its + // prompt — and it lands as the SAME System completion-notice entry a + // direct delivery writes (its own sides ride the row), never a user + // message: notices are engine artifacts, not the user's words. + wait_for( + || notice_entries(&core, PARENT).len() == 26, + "the released row's turn to write the 26th system notice entry", + ) + .await; + let released = notice_entries(&core, PARENT) + .pop() + .expect("the released notice entry"); + assert!( + released.id.starts_with("notice:"), + "the released entry keeps the parked row's id: {}", + released.id + ); let prompts = scripted.prompts.lock().unwrap(); assert!( - prompts[52].contains("Implement login") && prompts[52].contains("read_chat"), + prompts[53].contains("Implement login") && prompts[53].contains("read_chat"), "the released row ran the notice's own text: {}", - prompts[52] + prompts[53] ); drop(prompts); assert!( @@ -1984,6 +2199,105 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { core.shutdown().await; } +/// Outcomes that arrive while the cap holds MERGE into the parked row +/// (ticket 13's merging rule, extended to the cap's waiting form): one +/// parked notice, not a row per capped delivery, and its release is ONE +/// system notice covering every side chat that reached an outcome. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn capped_outcomes_merge_into_the_one_parked_notice_row() { + let dir = tempfile::tempdir().unwrap(); + // 26 side turns (25 rounds + the capped 26th) + 25 notice turns + the + // second side chat's turn + the user's message + the released row's + // turn — every run pops one scripted turn. + let turns: Vec<Turn> = (0..26 + 25 + 1 + 1 + 1) + .map(|_| Turn::End { + status: DoneStatus::Completed, + error: None, + }) + .collect(); + let scripted = Scripted::new(turns); + let core = assemble(dir.path(), scripted.clone()); + let client = roboco_rpc::memory_client(core.rpc_service()); + create_parent_and_side(&core, &client, None).await; + create_side(&core, &client, SIDE2, "Flaky test", None).await; + + // Fill the cap, then park the 26th notice. + for round in 0..25 { + queue_run( + &client, + SIDE, + &format!("round {round}"), + &format!("m-run-{round}"), + Some(PARENT), + ) + .await; + wait_idle(&core, SIDE).await; + wait_for( + || notice_entries(&core, PARENT).len() == round + 1, + &format!("round {round}'s notice to be written"), + ) + .await; + wait_idle(&core, PARENT).await; + } + queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; + wait_idle(&core, SIDE).await; + wait_for( + || queue_rows(&core, PARENT).len() == 1, + "the capped notice to park in the parent's queue", + ) + .await; + + // A second capped outcome parks INTO the same row: one row, its text + // and sides now covering both side chats. + queue_run(&client, SIDE2, "the second child fails", "m-run-2", Some(PARENT)).await; + wait_idle(&core, SIDE2).await; + wait_for( + || queue_rows_with_sides(&core, PARENT) + .into_iter() + .any(|(_, text, sides)| text.contains("Flaky test") && sides.is_some_and(|s| s.len() == 2)), + "the second capped outcome to merge into the parked row", + ) + .await; + let rows = queue_rows_with_sides(&core, PARENT); + assert_eq!(rows.len(), 1, "still exactly one parked notice row"); + assert!( + rows[0].1.contains("2 side chats reached outcomes"), + "the merged row's text is one notice: {}", + rows[0].1 + ); + assert_eq!( + scripted.run_count(), + 52, + "no notice turn started while the cap held (25 child + 25 notice + 2 capped children)" + ); + + // The user acts: the released row is ONE system notice naming both. + queue_run(&client, PARENT, "user speaks now", "m-user", None).await; + wait_for( + || scripted.prompts_containing("user speaks now"), + "the user's turn to start", + ) + .await; + wait_idle(&core, PARENT).await; + wait_for( + || notice_entries(&core, PARENT).len() == 26, + "the released row to write its merged system notice entry", + ) + .await; + let released = notice_entries(&core, PARENT).pop().expect("the released entry"); + let text = notice_text(&released); + assert!(text.contains("Implement login"), "{text}"); + assert!(text.contains("Flaky test"), "{text}"); + assert!( + text.starts_with("2 side chats reached outcomes:"), + "the released notice is one merged notice: {text}" + ); + assert_eq!(scripted.run_count(), 54); + assert!(queue_rows(&core, PARENT).is_empty()); + + core.shutdown().await; +} + /// A Stop also drops the loop cap's parked notice row (ticket 14: it is a /// pending notice, the visible form the cap left it waiting in): after the /// Stop the user's next message runs alone, and nothing restarts the diff --git a/crates/engine/tests/continue_on_harness.rs b/crates/engine/tests/continue_on_harness.rs index e15eb9304e..eff81aaed7 100644 --- a/crates/engine/tests/continue_on_harness.rs +++ b/crates/engine/tests/continue_on_harness.rs @@ -512,6 +512,94 @@ async fn continuing_on_another_harness_switches_then_sends_the_continue_prompt() core.shutdown().await; } +/// A message held for the turn end carries its FULL RunRequest — the +/// harness, model, options it was sent with — and the drain prefers that +/// stored request over the chat row. The switch rewrites the stored request +/// onto its config (ticket 24: "queued messages after a switch go to the new +/// harness"), or the held message would run on the OLD harness (its model +/// ids in the new harness's vocabulary) after the switch. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_full_request_held_across_the_switch_runs_on_the_new_harness() { + let (core, limited, limited_prompts, pi, pi_requests, _tmp) = setup(); + queue_run(&core, "Remember PINEAPPLE", HarnessId::ClaudeCode, None, "m-open"); + start_limited_turn(&limited_prompts).await; + // A full Run command sent while the turn-boundary run is working: it is + // HELD for the turn's end with the exact request it was sent with + // (claude, a claude model) — not a text-only queue row. + queue_run( + &core, + "queued as a full request", + HarnessId::ClaudeCode, + Some("claude-model"), + "m-held", + ); + let _ = limited.finish.send(()); + settle_limited(&core).await; + assert_eq!(queue_texts(&core), vec!["queued as a full request"]); + + // The switch, while the held row waits behind the Limited pause. + switch(&core, HarnessId::Pi, Some("pi-default")) + .await + .expect("switch while limited"); + expect_seams(&core, HarnessId::Pi, 1); + + // The continuation runs on the new harness; when it settles, the held + // row follows — rewritten onto the switch's config. + queue_run(&core, CONTINUE_PROMPT, HarnessId::Pi, Some("pi-default"), "m-resume"); + wait_for( + || pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.ends_with(CONTINUE_PROMPT)), + "the continue prompt to run on the new harness", + ) + .await; + let _ = pi.finish.send(()); + wait_for( + || pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.contains("queued as a full request")), + "the held row to follow the continuation", + ) + .await; + let held = pi_requests + .lock() + .unwrap() + .iter() + .find(|r| r.prompt.contains("queued as a full request")) + .cloned() + .expect("the held request"); + assert_eq!( + held.harness, Some(HarnessId::Pi), + "the stored request runs on the NEW harness after the switch" + ); + assert_eq!( + held.model.as_deref(), + Some("pi-default"), + "the stored request carries the switch's model, not the claude one" + ); + assert!(held.prompt.contains("queued as a full request"), "{}", held.prompt); + let _ = pi.finish.send(()); + wait_for( + || queue_texts(&core).is_empty() + && core + .sessions + .session_status(CHAT) + .is_none_or(|s| s.status != SessionStatus::Working), + "the last turn to settle and the queue to drain", + ) + .await; + assert_eq!( + limited_prompts.lock().unwrap().clone(), + vec!["Remember PINEAPPLE"], + "the limited harness never ran the held message" + ); + core.shutdown().await; +} + /// The switch alone does NOT clear the Limited state: the record lives as /// long as the errored status it rode, so the banner stays up (and the /// Resume / continue actions stay offered) until a turn actually starts. A diff --git a/crates/engine/tests/e2e.rs b/crates/engine/tests/e2e.rs index ad5ded8383..ad3d7cb8fa 100644 --- a/crates/engine/tests/e2e.rs +++ b/crates/engine/tests/e2e.rs @@ -287,7 +287,7 @@ async fn update_deferred_steering_preserves_the_active_turn_and_queued_prompt() run_request("opening"), Some("opening".into()), None, - ) + None) .await .unwrap(); tx.send(mock_script()[0].clone()).unwrap(); @@ -424,7 +424,7 @@ async fn pending_update_does_not_block_dispatch_or_other_harnesses() { run_request("waiting"), None, None, - ), + None), ) .await .expect("dispatch must not wait for the update") @@ -439,13 +439,13 @@ async fn pending_update_does_not_block_dispatch_or_other_harnesses() { run_request("cancelled"), None, None, - ), + None), ) .await .expect("dispatch must not wait for installation") .unwrap(); core.sessions - .dispatch("other", HarnessId::Codex, run_request("other"), None, None) + .dispatch("other", HarnessId::Codex, run_request("other"), None, None, None) .await .unwrap(); wait_for( @@ -1423,7 +1423,7 @@ async fn subagent_done_without_a_live_sink_updates_a_persisted_chip() { }) .unwrap(); core.sessions - .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None, None) .await .unwrap(); wait_for( @@ -2978,7 +2978,7 @@ async fn pending_steer_handoff_does_not_publish_a_completion() { run_request("redirect"), Some("user-steer".into()), None, - ) + None) .await .unwrap(); } else { @@ -3095,7 +3095,7 @@ async fn generated_image_is_materialized_before_publication_and_survives_reopen( ); sessions.set_doc_host(host.clone()); sessions - .dispatch(CHAT, HarnessId::Mock, run_request("image"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("image"), None, None, None) .await .unwrap(); wait_for( @@ -3143,7 +3143,7 @@ async fn generated_image_is_materialized_before_publication_and_survives_reopen( ); // Resume echoes the successful completed item after Codex removed its source. sessions - .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("resume"), None, None, None) .await .unwrap(); wait_for( @@ -3196,7 +3196,7 @@ async fn real_image_generation_profile_smoke() { run_request("Generate an image of a small green goblin using image generation."), None, None, - ) + None) .await .unwrap(); tokio::time::timeout(Duration::from_secs(300), async { diff --git a/crates/engine/tests/limited_chats.rs b/crates/engine/tests/limited_chats.rs index d391182c5e..9892b2362e 100644 --- a/crates/engine/tests/limited_chats.rs +++ b/crates/engine/tests/limited_chats.rs @@ -34,6 +34,11 @@ const CHAT: &str = "chat-limited"; struct LimitHarness { id: HarnessId, kind: Option<AgentErrorKind>, + /// A second classified error the doomed run emits AFTER the first — + /// Claude's live shape (a detailed `rate_limit_event`, then the CLI's + /// terse `rate_limit` assistant code), which the engine's limit merge + /// must survive. + second_kind: Option<AgentErrorKind>, finish: tokio::sync::broadcast::Sender<()>, prompts: Arc<Mutex<Vec<String>>>, runs: std::sync::atomic::AtomicUsize, @@ -41,12 +46,21 @@ struct LimitHarness { impl LimitHarness { fn new(id: HarnessId, kind: Option<AgentErrorKind>) -> (Arc<Self>, Arc<Mutex<Vec<String>>>) { + Self::with_second_kind(id, kind, None) + } + + fn with_second_kind( + id: HarnessId, + kind: Option<AgentErrorKind>, + second_kind: Option<AgentErrorKind>, + ) -> (Arc<Self>, Arc<Mutex<Vec<String>>>) { let (finish, _) = tokio::sync::broadcast::channel(16); let prompts = Arc::new(Mutex::new(Vec::new())); ( Arc::new(Self { id, kind, + second_kind, finish, prompts: prompts.clone(), runs: std::sync::atomic::AtomicUsize::new(0), @@ -84,6 +98,7 @@ impl Harness for LimitHarness { self.prompts.lock().unwrap().push(request.prompt.clone()); let run = self.runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let kind = self.kind.clone(); + let second_kind = self.second_kind.clone(); let session = format!("sess-{run}"); let mut finish = self.finish.subscribe(); let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { @@ -98,29 +113,39 @@ impl Harness for LimitHarness { // The turn runs until the test says it ends. let _ = finish.recv().await; if run == 0 { - // The classified limit error, then the errored Done — the - // driver shape ticket 05 landed. `None` kind = a plain, - // unclassified failure (the not-Limited control case). + // The classified limit error (then an optional second one — + // `second_kind`, Claude's live shape), then the errored Done + // — the driver shape ticket 05 landed. `None` kind = a + // plain, unclassified failure (the not-Limited control + // case). let (message, kind) = match kind { Some(kind @ AgentErrorKind::UsageLimit { .. }) => ( - "Claude usage limit reached — try again after the limit resets.", + "Claude 5-hour limit reached — the turn was blocked.", Some(kind), ), Some(kind) => ("The agent failed its login.", Some(kind)), None => ("The agent child crashed.", None), }; - vec![ + let mut events = vec![ Ok(AgentEvent::Error { message: message.into(), kind, }), - Ok(AgentEvent::Done { - status: DoneStatus::Errored, - result: None, - error: Some("the turn failed".into()), - session_id: Some(session), - }), - ] + ]; + if let Some(kind) = second_kind { + events.push(Ok(AgentEvent::Error { + message: "Claude usage limit reached — try again after the limit resets." + .into(), + kind: Some(kind), + })); + } + events.push(Ok(AgentEvent::Done { + status: DoneStatus::Errored, + result: None, + error: Some("the turn failed".into()), + session_id: Some(session), + })); + events } else { vec![ Ok(AgentEvent::TextDelta { @@ -274,6 +299,55 @@ fn limit_row(core: &EngineCore) -> Option<SessionLimit> { .and_then(|s| s.limit.clone()) } +/// **The terse code never overwrites the detailed event** (Claude's live +/// order): a turn that first reports a rejected window WITH its reset, then +/// the CLI's terse `rate_limit` assistant code (window unknown, no reset) +/// ends Limited on the DETAILED classification — the later, windowless +/// limit merges into the earlier one instead of replacing it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_terse_limit_code_does_not_overwrite_the_detailed_event() { + let (core, harness, prompts) = setup(LimitHarness::with_second_kind( + HarnessId::ClaudeCode, + Some(AgentErrorKind::UsageLimit { + window: "5-hour".into(), + resets_at: Some(1_762_675_400_000), + }), + // The terse code: window unknown ("usage"), no reset. + Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + )) + .await; + + queue_run(&core, "do the work", HarnessId::ClaudeCode, "m-open"); + wait_for( + || prompts.lock().unwrap().iter().any(|p| p == "do the work"), + "the limited run to start", + ) + .await; + let _ = harness.finish.send(()); + + wait_for( + || { + core.sessions + .session_status(CHAT) + .is_some_and(|s| s.status == SessionStatus::Errored && s.limit.is_some()) + }, + "the chat to go Limited", + ) + .await; + let limit = limit_row(&core).expect("the limit record"); + assert_eq!(limit.harness, HarnessId::ClaudeCode); + assert_eq!(limit.window, "5-hour", "the detailed window survives the terse code"); + assert_eq!( + limit.resets_at, + Some(1_762_675_400_000), + "the reported reset survives the terse code" + ); + core.shutdown().await; +} + /// A run ending on a classified usage-limit error leaves the additive limit /// record on the errored chat — harness, window and reset time exactly as /// the harness's own error reported them — and mirrors it onto the workspace diff --git a/crates/engine/tests/m5c_accounts_uploads_titles.rs b/crates/engine/tests/m5c_accounts_uploads_titles.rs index 581c09a6b6..6fa738c273 100644 --- a/crates/engine/tests/m5c_accounts_uploads_titles.rs +++ b/crates/engine/tests/m5c_accounts_uploads_titles.rs @@ -971,7 +971,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { resume_fallback_history: None, }; core.sessions - .dispatch(chat_id, HarnessId::Mock, request, None, None) + .dispatch(chat_id, HarnessId::Mock, request, None, None, None) .await .expect("dispatch"); @@ -1019,7 +1019,7 @@ async fn titling_e2e_names_chat_and_renames_worktree_branch() { resume_fallback_history: None, }; core.sessions - .dispatch(chat_id, HarnessId::Mock, request, None, None) + .dispatch(chat_id, HarnessId::Mock, request, None, None, None) .await .expect("second dispatch"); tokio::time::sleep(Duration::from_millis(400)).await; diff --git a/crates/engine/tests/pi_resume.rs b/crates/engine/tests/pi_resume.rs index 5d906c253f..3d49568a18 100644 --- a/crates/engine/tests/pi_resume.rs +++ b/crates/engine/tests/pi_resume.rs @@ -40,7 +40,7 @@ async fn pi_idle_crash_next_dispatch_resumes_the_stored_session() { resume_fallback_history: None, }; core.sessions - .dispatch(chat, HarnessId::Pi, req, None, None) + .dispatch(chat, HarnessId::Pi, req, None, None, None) .await .unwrap(); tokio::time::timeout(Duration::from_secs(10), async { diff --git a/crates/engine/tests/resume_at_reset.rs b/crates/engine/tests/resume_at_reset.rs index 2cee27cd55..bd6d6d244c 100644 --- a/crates/engine/tests/resume_at_reset.rs +++ b/crates/engine/tests/resume_at_reset.rs @@ -498,6 +498,53 @@ async fn a_pending_continuation_survives_a_restart_and_an_overdue_one_fires_afte core.shutdown().await; } +/// **Restart keeps the client-visible rows:** the in-memory status map is +/// the merged WatchSessions source for this device's chats, and it starts +/// empty at boot — without the republish, a still-Limited chat and its +/// armed continuation vanish from the banner (no "resumes in…", no +/// Cancel) and `ScheduleResumeAtReset` refuses ("not Limited"). The boot +/// re-creates both rows from the durable workspace row. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_restart_republishes_the_limit_and_scheduled_resume_rows() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("data"); + // A reset far enough out that nothing fires while we look. + let (core, harness, prompts, client) = setup_at(&data_dir, vec![Some(in_ms(60_000))]).await; + + run_into_limit(&core, &harness, &prompts).await; + schedule_resume(&client).await; + let fire_at = scheduled_fire_at(&core).expect("the continuation to arm"); + + core.shutdown().await; + drop(core); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Boot a fresh engine over the same data dir: the client-visible rows + // are republished at boot, before anything fires. + let (core, _harness, prompts, _client) = setup_at(&data_dir, vec![None]).await; + let row = core.sessions.session_status(CHAT).expect("the republished row"); + assert_eq!(row.status, SessionStatus::Errored, "still Limited"); + assert!(row.limit.is_some(), "the limit record survived the restart"); + assert_eq!( + row.scheduled_resume.map(|resume| resume.fire_at), + Some(fire_at), + "the armed continuation's row survived the restart" + ); + // The workspace mirror carries it too (remote devices' banners). + assert!(core + .workspace + .read_sessions() + .map(|rows| rows.iter().any(|row| { + row.chat_id == CHAT + && row.limit.is_some() + && row.scheduled_resume.is_some_and(|r| r.fire_at == fire_at) + })) + .unwrap_or(false)); + // Nothing fired while we looked. + assert!(!prompts_contain(&prompts, CONTINUE)); + core.shutdown().await; +} + /// **Cancellation by a new message:** a normal send (the composer's Run /// command) cancels the pending continuation — the user's newer intent wins. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -540,12 +587,16 @@ async fn a_new_message_cancels_the_pending_continuation() { core.shutdown().await; } -/// **Cancellation by a queued message:** a message the user queued on the -/// Limited chat (the queue is holding) is still their newer intent — the -/// pending continuation dies, and nothing fires at the reset time. +/// **Queueing is not sending (story 41):** a message the user queued on the +/// Limited chat does NOT cancel the armed continuation — queued messages +/// wait BEHIND it, order preserved. The continuation fires at the reset +/// time and the queued row follows it out (a queued row is not the newer +/// intent a direct send is; the user can still cancel with a message or an +/// archive). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_queued_message_cancels_the_pending_continuation() { - let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500))]).await; +async fn a_queued_message_waits_behind_the_pending_continuation() { + // Three runs: the doomed turn, the continuation, the queued row's own. + let (core, harness, prompts, client) = setup(vec![Some(in_ms(2_500)), None, None]).await; run_into_limit(&core, &harness, &prompts).await; schedule_resume(&client).await; @@ -558,17 +609,29 @@ async fn a_queued_message_cancels_the_pending_continuation() { core.doc_host .queue_message(CHAT, "queued while limited", Vec::new()) .expect("queue message"); + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + scheduled_fire_at(&core).is_some(), + "queueing a row does not cancel the armed continuation" + ); + + // The reset time arrives: the continuation fires, and the queued row + // follows it — the queue thaws with the continuation's dispatch. wait_for( - || scheduled_fire_at(&core).is_none(), - "the queued message to cancel the continuation", + || prompts_contain(&prompts, CONTINUE), + "the continuation to fire at the reset time", ) .await; - - // The reset time comes and goes: the continuation never fires; the row - // stays queued behind the Limited chat's hold. - tokio::time::sleep(Duration::from_millis(3_000)).await; - assert!(!prompts_contain(&prompts, CONTINUE)); - assert_eq!(sent_prompts(&prompts), vec!["do the work"]); + wait_for( + || prompts_contain(&prompts, "queued while limited"), + "the queued row to follow the continuation", + ) + .await; + assert_eq!( + sent_prompts(&prompts), + vec!["do the work", CONTINUE, "queued while limited"], + "order preserved: the continuation first, the queued row behind it" + ); core.shutdown().await; } diff --git a/crates/engine/tests/rich_composer_delivery.rs b/crates/engine/tests/rich_composer_delivery.rs index 2298718b74..736c83cf2a 100644 --- a/crates/engine/tests/rich_composer_delivery.rs +++ b/crates/engine/tests/rich_composer_delivery.rs @@ -280,7 +280,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let (_tmp, core, _harness, mut rx) = setup(id).await; let (raw, readable) = rich_text("first"); core.sessions - .dispatch(CHAT, id, request(&raw), None, None) + .dispatch(CHAT, id, request(&raw), None, None, None) .await .unwrap(); let Delivery::Run(run) = receive(&mut rx).await else { @@ -292,7 +292,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let (warm, readable) = rich_text("warm"); core.sessions - .dispatch(CHAT, id, request(&warm), None, None) + .dispatch(CHAT, id, request(&warm), None, None, None) .await .unwrap(); let Delivery::Steer(text, _) = receive(&mut rx).await else { @@ -318,7 +318,7 @@ async fn rich_selections_survive_fresh_warm_steer_and_attachment_delivery_for_ev let mut req = request(&attached); req.attachments.push(path.to_string_lossy().into_owned()); core.sessions - .dispatch(CHAT, id, req.clone(), None, None) + .dispatch(CHAT, id, req.clone(), None, None, None) .await .unwrap(); let Delivery::Steer(text, attachments) = receive(&mut rx).await else { @@ -340,7 +340,7 @@ async fn rich_selections_are_converted_once_on_startup_retry_for_every_harness() // Startup retries are intentionally limited to an engine-injected // resume: establish a real prior session before simulating its crash. core.sessions - .dispatch(CHAT, id, request("seed session"), None, None) + .dispatch(CHAT, id, request("seed session"), None, None, None) .await .unwrap(); let Delivery::Run(_) = receive(&mut rx).await else { @@ -364,7 +364,7 @@ async fn rich_selections_are_converted_once_on_startup_retry_for_every_harness() harness.fail_start.store(true, Ordering::SeqCst); let (raw, readable) = rich_text("retry"); core.sessions - .dispatch(CHAT, id, request(&raw), Some("same-user".into()), None) + .dispatch(CHAT, id, request(&raw), Some("same-user".into()), None, None) .await .unwrap(); for _ in 0..2 { @@ -384,7 +384,7 @@ async fn queue_edits_preserve_reselected_skills_until_delivery_for_every_harness for id in HARNESSES { let (_tmp, core, _harness, mut rx) = setup(id).await; core.sessions - .dispatch(CHAT, id, request("opening"), None, None) + .dispatch(CHAT, id, request("opening"), None, None, None) .await .unwrap(); let Delivery::Run(_) = receive(&mut rx).await else { diff --git a/crates/engine/tests/runtime_survival.rs b/crates/engine/tests/runtime_survival.rs index c1d823b57f..a05287fe90 100644 --- a/crates/engine/tests/runtime_survival.rs +++ b/crates/engine/tests/runtime_survival.rs @@ -280,7 +280,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None, None) .await .unwrap(); settle(&rig, 1).await; @@ -294,7 +294,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { follow_up.attachments = vec!["/tmp/shot.png".into()]; rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, follow_up, None, None) + .dispatch(CHAT, HarnessId::Mock, follow_up, None, None, None) .await .unwrap(); settle(&rig, 2).await; @@ -325,7 +325,7 @@ async fn sends_with_images_or_respelled_options_join_the_live_runtime() { other_model.model = Some("process-2".into()); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, other_model, None, None) + .dispatch(CHAT, HarnessId::Mock, other_model, None, None, None) .await .unwrap(); settle(&rig, 4).await; @@ -341,7 +341,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { let rig = rig_with(true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("launch the scouts"), None, None, None) .await .unwrap(); settle(&rig, 1).await; @@ -351,7 +351,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { other_model.reasoning = Some(ReasoningLevel::Medium); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, other_model, None, None) + .dispatch(CHAT, HarnessId::Mock, other_model, None, None, None) .await .unwrap(); settle(&rig, 2).await; @@ -363,7 +363,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { again.reasoning = Some(ReasoningLevel::Medium); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, again, None, None) + .dispatch(CHAT, HarnessId::Mock, again, None, None, None) .await .unwrap(); settle(&rig, 3).await; @@ -380,7 +380,7 @@ async fn a_model_switch_is_adopted_by_the_live_runtime() { elsewhere.cwd = "/var".into(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, elsewhere, None, None) + .dispatch(CHAT, HarnessId::Mock, elsewhere, None, None, None) .await .unwrap(); settle(&rig, 4).await; @@ -396,7 +396,7 @@ async fn a_message_orphaned_by_a_dying_runtime_replays_as_sent() { let rig = rig_with(true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None, None) .await .unwrap(); wait_for( @@ -410,7 +410,7 @@ async fn a_message_orphaned_by_a_dying_runtime_replays_as_sent() { sent.attachments = vec!["/tmp/shot.png".into()]; rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, sent, Some("m-orphan".into()), None) + .dispatch(CHAT, HarnessId::Mock, sent, Some("m-orphan".into()), None, None) .await .unwrap(); settle(&rig, 2).await; @@ -491,7 +491,7 @@ async fn a_send_held_for_the_turn_end_runs_as_sent_without_a_ping() { let (pings, recorder) = record_pings(&rig); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None, None) .await .unwrap(); wait_for( @@ -567,7 +567,7 @@ async fn stop_cancels_sends_held_for_the_stopped_turn() { let rig = rig_full(false, true); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("wait: first"), None, None, None) .await .unwrap(); wait_for( @@ -647,7 +647,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None, None) .await .unwrap(); wait_for( @@ -669,7 +669,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { request("hold: sent before the stop"), Some("m-late".into()), None, - ) + None) .await .unwrap(); tokio::time::sleep(Duration::from_millis(300)).await; @@ -688,7 +688,7 @@ async fn a_message_the_stop_cancelled_never_reaches_a_fresh_runtime() { request("after the stop"), Some("m-next".into()), None, - ) + None) .await .unwrap(); settle(&rig, 2).await; @@ -704,7 +704,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: build it"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: build it"), None, None, None) .await .unwrap(); wait_for( @@ -739,7 +739,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { ); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: again"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: again"), None, None, None) .await .unwrap(); wait_for( @@ -773,7 +773,7 @@ async fn send_now_keeps_the_runtime_and_stop_tears_it_down() { // The next message starts a fresh process. rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("status?"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("status?"), None, None, None) .await .unwrap(); settle(&rig, 3).await; @@ -823,7 +823,7 @@ async fn a_send_right_after_stop_runs_in_a_fresh_runtime() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None, None) .await .unwrap(); wait_for( @@ -874,7 +874,7 @@ async fn a_send_accepted_by_a_stopping_runtime_is_redispatched() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None, None) .await .unwrap(); wait_for( @@ -886,7 +886,7 @@ async fn a_send_accepted_by_a_stopping_runtime_is_redispatched() { rig.core.sessions.interrupt(CHAT), rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("racing the stop"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("racing the stop"), None, None, None) ); stop.unwrap(); send.unwrap(); @@ -920,7 +920,7 @@ async fn a_steer_sent_before_stop_is_cancelled_with_it() { let rig = rig(); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None) + .dispatch(CHAT, HarnessId::Mock, request("hold: working"), None, None, None) .await .unwrap(); wait_for( @@ -973,7 +973,7 @@ async fn stop_ends_a_parked_runtime_holding_background_work() { request("launch a background scout"), None, None, - ) + None) .await .unwrap(); settle(&rig, 1).await; @@ -1011,7 +1011,7 @@ async fn the_idle_reaper_spares_a_runtime_holding_background_work() { request("launch a background scout"), None, None, - ) + None) .await .unwrap(); settle(&rig, 1).await; diff --git a/crates/engine/tests/runtime_survival_live.rs b/crates/engine/tests/runtime_survival_live.rs index dcc69630b1..c0e3fdfe05 100644 --- a/crates/engine/tests/runtime_survival_live.rs +++ b/crates/engine/tests/runtime_survival_live.rs @@ -314,7 +314,7 @@ async fn agent_process_survives_sends_and_cancel() { ), Some("m-2b-steer".into()), None, - ) + None) .await .unwrap(); let steered_at = std::time::Instant::now(); diff --git a/crates/engine/tests/self_continued_quiesce.rs b/crates/engine/tests/self_continued_quiesce.rs index d57c9e444e..19e0e30651 100644 --- a/crates/engine/tests/self_continued_quiesce.rs +++ b/crates/engine/tests/self_continued_quiesce.rs @@ -214,7 +214,7 @@ async fn self_continued_turn_parks_on_the_short_window() { let rig = assemble("watch the build"); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, run_request("watch the build"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("watch the build"), None, None, None) .await .expect("dispatch"); @@ -261,7 +261,7 @@ async fn steered_turn_keeps_the_normal_window() { run_request("watch the build again"), None, None, - ) + None) .await .expect("dispatch"); diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 304ffbb553..56dc907fb5 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -229,7 +229,7 @@ async fn fork_is_frozen_durable_idempotent_and_has_an_independent_provider_sessi }, Some("side-user".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -400,7 +400,7 @@ async fn assistant_entries_record_the_provider_turn_ref() { }, Some("u1".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -649,7 +649,7 @@ async fn claude_assistant_entries_record_the_provider_turn_ref() { }, Some("u1".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -1331,6 +1331,201 @@ async fn a_boundary_without_a_ref_pastes_the_budget_up_to_the_boundary() { /// (oversized relayed replies). A boundary on the root copies the WHOLE /// message — never a truncated prefix — and the native boundary ref comes /// from the root, where a run stamps it. +/// "Fork from here" on a segment that is NOT its turn's last (story 60: a +/// steer-split reply): the turn's later segments share the boundary's +/// provider turn ref, and a native fork cuts at the WHOLE turn — content +/// the boundary excluded would ride the copy through the provider. The +/// mid-turn boundary falls back to the budgeted paste; the turn's LAST +/// segment forks natively. +#[tokio::test] +async fn a_fork_from_a_mid_turn_segment_falls_back_to_the_budgeted_paste() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + core.workspace + .set_chat_harness_session("main", "parent-provider-session", "/tmp"); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + // A steer-split reply: two entries, ONE provider turn (the same ref on + // both — what the engine stamps when a steer splits the reply). + source + .doc() + .push_message(&turn_entry( + "a1", + "first half of the reply", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + source + .doc() + .push_message(&turn_entry( + "a2", + "second half after the steer", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + // Fork from the EARLIER segment: the turn continues past it, so no + // native intent — the budgeted paste carries the prefix instead. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side-mid", + "sourceChatId": "main", + "boundary": "a1", + }), + ) + .await + .unwrap(); + let request = side_turn(&core, &requests, "side-mid", "Continue", "s1").await; + assert_eq!( + request.provider_fork, None, + "a mid-turn boundary must not take the native fork: the later \n content would ride the provider session" + ); + assert!( + request.prompt.contains("PINEAPPLE"), + "the budgeted paste covers the copy: {}", + request.prompt + ); + assert!( + !request.prompt.contains("second half after the steer"), + "the copy stops at the boundary: {}", + request.prompt + ); + // Fork from the turn's LAST segment: native, at that turn. + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side-last", + "sourceChatId": "main", + "boundary": "a2", + }), + ) + .await + .unwrap(); + let request = side_turn(&core, &requests, "side-last", "Continue", "s2").await; + let fork = request.provider_fork.expect("the last segment forks natively"); + assert_eq!(fork.session_id, "parent-provider-session"); + assert_eq!(fork.last_turn, "turn-1"); + core.shutdown().await; +} + +/// A fork whose copy lands on the BOUNDARY's harness while the source's +/// CURRENT config runs another (a chat that switched since the boundary +/// turn): the config the copy inherits belongs to the current harness, so +/// its model/reasoning/options ids are the wrong vocabulary — they drop, +/// exactly like a cross-harness fork (the sandbox survives). +#[tokio::test] +async fn a_fork_onto_the_boundarys_harness_drops_the_current_configs_picks() { + let dir = tempfile::tempdir().unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let registry = HarnessRegistry::new(); + registry.register(Arc::new(Capture(requests.clone()))); + let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock).unwrap(); + core.workspace + .create_chat( + "main", + None, + Some(&core.device_id), + None, + Some("/tmp".into()), + ) + .unwrap(); + // The source's CURRENT config: another harness, with a model only that + // harness knows. + core.workspace + .set_chat_config( + "main", + &roboco_proto::ChatConfig { + harness: HarnessId::ClaudeCode, + model: Some("claude-sonnet".into()), + reasoning: Some(roboco_proto::ReasoningLevel::High), + model_options: serde_json::Map::from([( + "claudeOption".into(), + serde_json::json!("on"), + )]), + sandbox: roboco_proto::SandboxLevel::WorkspaceWrite, + }, + ) + .unwrap(); + let source = core.doc_host.open("main").unwrap(); + source + .doc() + .push_message(&message( + "u1", + MessageRole::User, + "Remember PINEAPPLE", + MessageStatus::Complete, + )) + .unwrap(); + // The boundary's ref names the chat's OWN (registered) harness — the + // override onto it is NOT a cross-harness fork by the ref's judgement, + // but the config it inherits is the claude one. + source + .doc() + .push_message(&turn_entry( + "a1", + "I remember PINEAPPLE", + MessageStatus::Complete, + "turn-1", + )) + .unwrap(); + let client = roboco_rpc::memory_client(core.rpc_service()); + client + .call( + methods::FORK_SIDE_CHAT, + serde_json::json!({ + "chatId": "side", + "sourceChatId": "main", + "harness": "mock", + }), + ) + .await + .unwrap(); + // The copy's config: the boundary's harness, none of the current + // config's vocabulary, sandbox kept. + let row = core.workspace.chat("side").unwrap().unwrap(); + let config = row.config.expect("the copy carries a config"); + assert_eq!(config.harness, HarnessId::Mock); + assert_eq!( + config.model, None, + "the claude model must not ride the mock copy" + ); + assert_eq!(config.reasoning, None, "the claude reasoning drops with it"); + assert!( + config.model_options.is_empty(), + "the claude-only option ids drop with it" + ); + assert_eq!(config.sandbox, roboco_proto::SandboxLevel::WorkspaceWrite); + // The run asks for no model the mock harness does not know. + let request = side_turn(&core, &requests, "side", "Continue", "s1").await; + assert_eq!(request.model, None, "the run inherits the dropped pick"); + core.shutdown().await; +} + #[tokio::test] async fn a_split_boundary_entry_copies_whole() { let dir = tempfile::tempdir().unwrap(); @@ -1360,6 +1555,18 @@ async fn a_split_boundary_entry_copies_whole() { ); fragment.continuation_of = Some("a1".into()); source.doc().push_message(&fragment).unwrap(); + // A second fragment — fragments name the ROOT, not the fragment before + // them, so a walk that steps back one entry at a time stops at the + // first fragment and loses the root (and its turn ref). Three entries + // make the chain long enough to tell. + let mut second_fragment = message( + "a1#c2", + MessageRole::Assistant, + "third half", + MessageStatus::Complete, + ); + second_fragment.continuation_of = Some("a1".into()); + source.doc().push_message(&second_fragment).unwrap(); source .doc() .push_message(&turn_entry( @@ -1370,8 +1577,12 @@ async fn a_split_boundary_entry_copies_whole() { )) .unwrap(); let client = roboco_rpc::memory_client(core.rpc_service()); - // Both the root id and a fragment id resolve to the whole message. - for (chat, boundary) in [("side-root", "a1"), ("side-fragment", "a1#c1")] { + // The root id and EITHER fragment id resolve to the whole message. + for (chat, boundary) in [ + ("side-root", "a1"), + ("side-fragment", "a1#c1"), + ("side-second-fragment", "a1#c2"), + ] { client .call( methods::FORK_SIDE_CHAT, @@ -1398,6 +1609,7 @@ async fn a_split_boundary_entry_copies_whole() { vec![ "a1".to_string(), "a1#c1".to_string(), + "a1#c2".to_string(), format!("fork:{chat}"), ], "boundary {boundary} copies the whole logical message" @@ -1600,7 +1812,7 @@ async fn side_turn_on_harness( }, Some(message_id.into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { @@ -1831,7 +2043,7 @@ async fn warm_side_chat_sends_owed_fork_history_once() { }; let dispatch = |prompt: &'static str, id: &'static str| { core.sessions - .dispatch("fork", HarnessId::Mock, request(prompt), Some(id.into()), None) + .dispatch("fork", HarnessId::Mock, request(prompt), Some(id.into()), None, None) }; dispatch("/review", "f1").await.unwrap(); assert_eq!(settle(1).await, "/review"); @@ -2004,7 +2216,7 @@ async fn orphaned_history_steer_still_owes_the_history() { request("/review"), Some("f1".into()), None, - ) + None) .await .unwrap(); let idle = || { @@ -2027,7 +2239,7 @@ async fn orphaned_history_steer_still_owes_the_history() { request("What should you remember?"), Some("f2".into()), None, - ) + None) .await .unwrap(); let doc = core.doc_host.open("fork").unwrap(); @@ -2768,7 +2980,7 @@ async fn a_limited_source_chat_can_be_forked_into_another_harness() { }, Some("u2".into()), None, - ) + None) .await .unwrap(); tokio::time::timeout(std::time::Duration::from_secs(5), async { diff --git a/crates/engine/tests/subagent_idle_reap.rs b/crates/engine/tests/subagent_idle_reap.rs index 5b4e06b408..b82f7304f3 100644 --- a/crates/engine/tests/subagent_idle_reap.rs +++ b/crates/engine/tests/subagent_idle_reap.rs @@ -197,7 +197,7 @@ async fn park_after_spawn() -> Parked { let core = EngineCore::assemble(dir.path(), Arc::new(registry), HarnessId::Mock) .expect("engine core assembles"); core.sessions - .dispatch(CHAT, HarnessId::Mock, run_request("fan out"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("fan out"), None, None, None) .await .expect("dispatch"); diff --git a/crates/engine/tests/turn_quiesce.rs b/crates/engine/tests/turn_quiesce.rs index 881a2b3621..fecc18a28d 100644 --- a/crates/engine/tests/turn_quiesce.rs +++ b/crates/engine/tests/turn_quiesce.rs @@ -262,7 +262,7 @@ async fn parked_self_continuation_folds_and_requiesces() { run_request("pull the app and benchmark it"), None, None, - ) + None) .await .expect("dispatch"); @@ -343,7 +343,7 @@ async fn missing_turn_end_settles_instead_of_working_forever() { run_request("pull the app and benchmark it"), None, None, - ) + None) .await .expect("dispatch"); @@ -406,7 +406,7 @@ async fn open_tool_call_never_quiesces() { run_request("run the slow build"), None, None, - ) + None) .await .expect("dispatch"); @@ -462,7 +462,7 @@ async fn stale_tool_echo_stays_parked() { let rig = assemble("do a thing"); rig.core .sessions - .dispatch(CHAT, HarnessId::Mock, run_request("do a thing"), None, None) + .dispatch(CHAT, HarnessId::Mock, run_request("do a thing"), None, None, None) .await .expect("dispatch"); diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 38d2beca6c..262340ead1 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -482,11 +482,15 @@ case "$first" in *scenario:error*) emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-err"}' - # Terse assistant-level error code with no content. - emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}' # Hard-rejected claude.ai usage window, with the CLI's own reset time - # (unix seconds, as the live 2.1.228 capture carries it). + # (unix seconds, as the live 2.1.228 capture carries it) — FIRST, matching + # the live capture's order: the event lands before the terse + # assistant-level error code below, so the engine's limit merge (the + # later, windowless `rate_limit` code must not overwrite this window and + # reset) is what the fixture exercises. emit '{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","rateLimitType":"five_hour","resetsAt":1762675400}}' + # Terse assistant-level error code with no content. + emit '{"type":"assistant","parent_tool_use_id":null,"message":{"content":[]},"error":"rate_limit"}' # Result error with an EMPTY errors array: needs fallback wording. emit '{"type":"result","subtype":"error_max_turns","errors":[],"usage":{"input_tokens":1,"output_tokens":2},"session_id":"sess-err"}' ;; diff --git a/crates/roboco-mcp/src/roboco.rs b/crates/roboco-mcp/src/roboco.rs index e672b58596..ec37111dc8 100644 --- a/crates/roboco-mcp/src/roboco.rs +++ b/crates/roboco-mcp/src/roboco.rs @@ -358,13 +358,21 @@ impl Roboco { /// Report that the calling chat (`origin_chat_id`) observed `chat_id` /// after its last outcome happened — through `wait_for_turn`, a send /// with wait, `read_chat` or `get_chat` (ticket 14). Completion-notice - /// outcomes the parent already observed owe no notice. - pub async fn ack_side_chat(&self, chat_id: &str, origin_chat_id: &str) -> anyhow::Result<()> { - self.call( - methods::ACK_SIDE_CHAT, - json!({ "chatId": chat_id, "originChatId": origin_chat_id }), - ) - .await?; + /// outcomes the parent already observed owe no notice. `observed_at` + /// (the observation's start, epoch ms) makes the ack cover only the + /// settles the caller had already seen — never one that landed between + /// the read and this report. + pub async fn ack_side_chat( + &self, + chat_id: &str, + origin_chat_id: &str, + observed_at: Option<i64>, + ) -> anyhow::Result<()> { + let mut params = json!({ "chatId": chat_id, "originChatId": origin_chat_id }); + if let Some(at) = observed_at { + params["observedAt"] = json!(at); + } + self.call(methods::ACK_SIDE_CHAT, params).await?; Ok(()) } diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 5f737e1d4b..acabd63f07 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -703,6 +703,11 @@ impl Tools { async fn get_chat(&self, args: ChatArgs) -> anyhow::Result<Value> { let chat = self.roboco.resolve_chat(&args.chat).await?; + // The observation this call makes begins here (the rows and the + // transcript it reads are fetched next): the engine's ack covers + // what had settled by this moment — conservative, never an outcome + // the caller has not seen. + let observed_at = chrono::Utc::now().timestamp_millis(); let (spaces, sessions, entries) = tokio::try_join!( self.roboco.spaces(), self.roboco.sessions(), @@ -710,7 +715,7 @@ impl Tools { )?; // The caller just read the chat's status, question and transcript — // an observation (ticket 14): outcomes it already saw owe no notice. - self.acknowledge_observed(&chat.id).await; + self.acknowledge_observed(&chat.id, Some(observed_at)).await; let rendered = render_entries(&entries, RenderOptions::default()); let mut summary = summarize_chat(&chat, &spaces, &sessions); summary["messageCount"] = json!(rendered.len()); @@ -1150,10 +1155,14 @@ impl Tools { async fn read_chat(&self, args: ReadChatArgs) -> anyhow::Result<Value> { let chat = self.roboco.resolve_chat(&args.chat).await?; + // The observation begins before the transcript read: the engine's + // ack covers the settles that had happened by this moment, so a + // turn settling during the read still owes its notice. + let observed_at = chrono::Utc::now().timestamp_millis(); let entries = self.roboco.transcript(&chat.id).await?; // The caller just read the transcript — an observation (ticket 14): // outcomes for turns that already settled owe no notice. - self.acknowledge_observed(&chat.id).await; + self.acknowledge_observed(&chat.id, Some(observed_at)).await; let rendered = render_entries( &entries, RenderOptions { @@ -1329,7 +1338,13 @@ impl Tools { /// it already saw — a wait that timed out saw nothing. No calling chat /// (a human at a CLI) reports nothing: the user reading a chat is not /// the parent agent that would process the notice. - async fn acknowledge_observed(&self, chat_id: &str) { + /// + /// `observed_at` is the moment the observation STARTED (epoch ms): the + /// engine's watermark covers only the settles that had happened by then, + /// so an outcome that landed between the read and this report — the + /// parent's unseen work — still reaches it. `None` when the caller + /// cannot say; the engine then uses its own current serial. + async fn acknowledge_observed(&self, chat_id: &str, observed_at: Option<i64>) { let Some(origin) = self.roboco.origin().chat_id.clone() else { return; }; @@ -1337,7 +1352,11 @@ impl Tools { // A chat cannot observe itself into silence — `whoami`'s rule. return; } - if let Err(err) = self.roboco.ack_side_chat(chat_id, &origin).await { + if let Err(err) = self + .roboco + .ack_side_chat(chat_id, &origin, observed_at) + .await + { tracing::warn!(chat = chat_id, error = %err, "observation report failed"); } } @@ -1578,9 +1597,14 @@ impl Tools { // The wait resolved on a settle the caller now knows (ticket 14): // report the observation — a timed-out wait observed nothing. This // covers `wait_for_turn`, `send_message` with wait, and `create_chat` - // with wait, which all resolve through here. + // with wait, which all resolve through here. The row the wait + // resolved on carries its own moment: the ack covers exactly the + // settles that row already names, never a later one. if outcome != TurnOutcome::TimedOut { - self.acknowledge_observed(&chat.id).await; + let observed_at = session + .as_ref() + .map(|row: &Session| row.updated_at.timestamp_millis()); + self.acknowledge_observed(&chat.id, observed_at).await; } Ok(json!({ "outcome": outcome, diff --git a/web/packages/proto/src/generated/QueuedMessage.ts b/web/packages/proto/src/generated/QueuedMessage.ts index 0cdc225381..a50358d119 100644 --- a/web/packages/proto/src/generated/QueuedMessage.ts +++ b/web/packages/proto/src/generated/QueuedMessage.ts @@ -1,5 +1,6 @@ // DO NOT EDIT — generated by wiregen (`cargo run -p wiregen`; gate: `-- --check`). +import type { NoticeSide } from "./NoticeSide"; import type { QueueDeliveryGate } from "./QueueDeliveryGate"; /** @@ -49,4 +50,13 @@ deliveryGate?: QueueDeliveryGate | null, * and rows written before the field existed. The promoted user message * entry keeps it. */ -originChatId?: string | null, }; +originChatId?: string | null, +/** + * The loop cap's parked completion notice (orchestration v2, ticket 14, + * additive; ADR 0005): the sides the notice reports, so the row — the + * notice's durable carrier while it waits for the user — releases as + * the same System completion-notice entry a direct delivery writes, + * never as a user message. `None` for every other row. A later capped + * delivery merges its sides into the existing row's. + */ +completionNotice?: Array<NoticeSide> | null, }; From de7086f661b9524e883b6079805781f03e0e1664 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 11:19:15 +0700 Subject: [PATCH 72/96] fix(engine): the signed-out reprobe test waits out the backoff --- crates/engine/src/agent_accounts.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index e87299fb23..72d25aa3ed 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -5293,7 +5293,10 @@ mod probe_tests { assert!(entry.probe_due("new", soon)); // … while the same credentials keep it (the backoff arm also holds). assert!(!entry.probe_due("old", soon)); - assert!(entry.probe_due("old", 1_000 + FORCED_MIN_INTERVAL.as_millis() as i64 + 1)); + let past_freshness = 1_000 + FORCED_MIN_INTERVAL.as_millis() as i64 + 1; + assert!(!entry.probe_due("old", past_freshness)); + let backoff = ProbeError::SignedOut { status: 400 }.backoff().as_millis() as i64; + assert!(entry.probe_due("old", 1_000 + backoff)); // A mark with no recorded credentials (never failed a probe) reads // as recorded for any credentials. let mut bare = UsageEntry::default(); From 1a0854c68f484309ad5af9702f6506da6e73af55 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 12:12:48 +0700 Subject: [PATCH 73/96] fix(engine): a rejected Devin key still reads signed out --- crates/engine/src/agent_accounts.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index 72d25aa3ed..d721af89f4 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -4027,6 +4027,11 @@ fn usage_error_message( // login it is (this is what flipped the sign-in state). ProbeError::SignedOut { .. } => "Signed out — sign in again".to_string(), ProbeError::RateLimited { .. } => format!("Rate limited by {provider}{retry}"), + // Devin's key never expires on its own: a rejection means it was + // revoked, whichever login it is. + ProbeError::Unauthorized { .. } if harness == HarnessId::Devin => { + "Signed out — sign in again".to_string() + } ProbeError::Unauthorized { .. } if active => { // The CLI owns (and refreshes) the live token; it just hasn't yet. format!( From 0dca92fb00c7c7c4ae5790ff1cbb952af5b218b4 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 12:15:50 +0700 Subject: [PATCH 74/96] fix(harness): settle the pi suite CI red the claude fix unmasked The claude-suite fix (01849dc2d) let the "Pi native driver integration tests" step reach the pi harness suite for the first time; cargo stops at the first failing binary, so six latent fixture bugs surfaced at once. The driver (crates/harness/src/pi/mod.rs) is right in all six - every fix is in the fake-pi-rpc fixture; no test assertion changed. - Four fork-fallback tests (stale / cancelled / refused / missing source, ticket 19): the driver correctly pastes the budgeted history IN FRONT of the prompt when the native fork fails (codex parity: the codex driver delivers format!("{history}{}", prompt)), but the fake fork-fallback scenario matched the prompt with ==, so the pasted message never routed there and the default happy path echoed the whole paste back. The branch also asserted PINEAPPLE inside a text it had just required to equal "fork-fallback" - dead code either way. The scenario now matches the prompt suffix, exactly like the fake-codex fork-refused arm (marker match + PINEAPPLE assert), so the fake itself proves the paste arrived and answers with the distinct reply:fork-fallback marker the tests (and the still-unexercised engine pi_forks fallback test) expect. - turn_refs_name_the_session_file_and_each_turns_user_entry and a_steered_turn_names_its_last_user_entry: the fixture minted session entry ids from ONE global counter (u1, a2, u3, a4), contradicting its own header comment ("the leaf chain u1 -> a1 -> u2"), the FORK_TREE spelling, and the tests, which read the second user entry / the mid-turn steer as u2. append_entry now numbers per role, so the second turn prompt and a steer are u2 and the refs land where the tests - and the driver design (the LAST user entry of the turn, the exclusive fork boundary) - say they must. No local builds/tests per the verification policy; CI is the signal. --- crates/harness/tests/fixtures/fake-pi-rpc.sh | 27 +++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 99e68c1648..892ce4a0e0 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -145,14 +145,23 @@ streaming = False # entry so get_entries can serve the tree the driver walks after each # settle for provider turn refs: the leaf chain u1 -> a1 -> u2 ... with the # walk bounded by the `since` cursor the driver passes. -entry_seq = 0 +user_seq = 0 +assistant_seq = 0 leaf_id = None entries = [] def append_entry(role): - global entry_seq, leaf_id - entry_seq += 1 - entry_id = ("u%d" if role == "user" else "a%d") % entry_seq + global user_seq, assistant_seq, leaf_id + # Ids number per ROLE, the FORK_TREE spelling (u1, a1, u2, a2): the + # turn-ref tests read them as the Nth user entry of the session, so a + # shared counter minting a2/u3 for the second exchange would misname + # every ref after the first turn. + if role == "user": + user_seq += 1 + entry_id = "u%d" % user_seq + else: + assistant_seq += 1 + entry_id = "a%d" % assistant_seq entries.append({ "type": "message", "id": entry_id, @@ -680,13 +689,17 @@ def run_prompt(req): user_message(text) turn_and_settle("reply:" + text) return - if text == "fork-fallback": + if text.endswith("fork-fallback"): # The refused/stale fork fallback (ticket 19): a FRESH session (no # --session) with the budgeted history pasted in front of the - # prompt. + # prompt, so the scenario matches the prompt suffix and the + # PINEAPPLE assert is the oracle that the history actually rode + # along (a bare prompt fails it; codex parity: the fake-codex + # fork-refused arm asserts the same markers). The canned marker + # reply proves the scenario routed here — the default happy path + # would echo the whole pasted message instead. assert resumed_session is None, "the fallback must start a fresh session" assert "PINEAPPLE" in text, text - assert text.endswith("fork-fallback"), text user_message(text) turn_and_settle("reply:fork-fallback") return From 46f6575798c5f0bcb2cb1962d9b1c96b30cc017e Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 12:42:13 +0700 Subject: [PATCH 75/96] fix(mcp): settle the tool-test stub compile slips --- crates/roboco-mcp/src/tools.rs | 96 +++++++++++++++++----------------- 1 file changed, 49 insertions(+), 47 deletions(-) diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index 5f737e1d4b..5acce3cb25 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -1642,28 +1642,30 @@ mod tests { RpcReply::Stream(futures::stream::iter(vec![item]).boxed()) } - /// The workspace's chat rows, as `WatchChats` reports them. The fork - /// handler builds its copy from the same shape, so the stub's chats - /// stay one source of truth. - fn rows(&self) -> Value { - json!([ - { - "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", - "archived": false, "spaceId": "space-1", - "config": { - "harness": self.alpha_harness.unwrap_or("claude-code"), - "model": "opus", "reasoning": null, "sandbox": "workspace-write" + impl World { + /// The workspace's chat rows, as `WatchChats` reports them. The fork + /// handler builds its copy from the same shape, so the stub's chats + /// stay one source of truth. + fn rows(&self) -> Value { + json!([ + { + "id": "chat-alpha-1", "deviceId": "dev-local", "title": "Alpha", + "archived": false, "spaceId": "space-1", + "config": { + "harness": self.alpha_harness.unwrap_or("claude-code"), + "model": "opus", "reasoning": null, "sandbox": "workspace-write" + }, + "createdAt": "2026-09-01T00:00:00Z" }, - "createdAt": "2026-09-01T00:00:00Z" - }, - { - "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", - "parentChatId": self.beta_parent, - "agentOrigin": self.beta_agent_origin, - "archived": false, "spaceId": "space-1", - "createdAt": "2026-09-02T00:00:00Z" - } - ]) + { + "id": "chat-beta-2", "deviceId": "dev-local", "title": "Beta", + "parentChatId": self.beta_parent, + "agentOrigin": self.beta_agent_origin, + "archived": false, "spaceId": "space-1", + "createdAt": "2026-09-02T00:00:00Z" + } + ]) + } } #[async_trait] @@ -1843,7 +1845,7 @@ mod tests { #[tokio::test] async fn list_and_read_chats() { let world = Arc::new(World::default()); - let tools = tools(world, Origin::default()); + let tools = self::tools(world, Origin::default()); let listed = tools.call("list_chats", json!({})).await.unwrap(); assert_eq!(listed["total"], 2); // Newest activity first: Beta was created later. @@ -1870,7 +1872,7 @@ mod tests { #[tokio::test] async fn send_attributes_and_refuses_self() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-beta-2".into()), @@ -1936,7 +1938,7 @@ mod tests { // The parent's own notifying side chat: the line teaches the notice // flow and no longer invites a reply. let notifying = world("chat-alpha-1", true); - let tools = tools(notifying.clone(), origin.clone()); + let tools = self::tools(notifying.clone(), origin.clone()); tools .call("send_message", json!({ "chat": "Beta", "text": "the task" })) .await @@ -1956,7 +1958,7 @@ mod tests { // A side chat that opted out of notices keeps today's reply line. let opted_out = world("chat-alpha-1", false); - let tools = tools(opted_out.clone(), origin.clone()); + let tools = self::tools(opted_out.clone(), origin.clone()); tools .call("send_message", json!({ "chat": "Beta", "text": "the task" })) .await @@ -1973,7 +1975,7 @@ mod tests { // A side chat created by a third chat keeps the reply line too: only // the creator's messages teach the notice flow. let foreign = world("chat-gamma-9", true); - let tools = tools(foreign.clone(), origin); + let tools = self::tools(foreign.clone(), origin); tools .call("send_message", json!({ "chat": "Beta", "text": "the task" })) .await @@ -1988,7 +1990,7 @@ mod tests { // An ordinary chat (no agent origin) keeps today's line. let plain = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( plain.clone(), Origin { chat_id: Some("chat-beta-2".into()), @@ -2011,7 +2013,7 @@ mod tests { #[tokio::test] async fn auto_steers_busy_chats_even_at_turn_boundaries_or_after_long_quiet_tools() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); let chats = tools.roboco.chats().await.unwrap(); let session = Session { chat_id: chats[0].id.clone(), @@ -2045,7 +2047,7 @@ mod tests { #[tokio::test] async fn create_chat_writes_the_row_and_validates_model() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); let err = tools .call( "create_chat", @@ -2179,7 +2181,7 @@ mod tests { #[tokio::test] async fn fork_chat_with_no_options_forks_the_caller_from_its_latest_reply() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-alpha-1".into()), @@ -2219,7 +2221,7 @@ mod tests { #[tokio::test] async fn fork_chat_options_ride_the_rpc_and_title_the_copy() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-alpha-1".into()), @@ -2276,7 +2278,7 @@ mod tests { #[tokio::test] async fn fork_chat_validates_the_catalogs_before_forking() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); for args in [ // Not installed on this device. json!({ "chat": "alpha", "harness": "codex" }), @@ -2310,7 +2312,7 @@ mod tests { alpha_harness: Some("codex"), ..Default::default() }); - let tools = tools(codex_source.clone(), Origin::default()); + let tools = self::tools(codex_source.clone(), Origin::default()); let forked = tools .call("fork_chat", json!({ "chat": "alpha", "harness": "codex" })) .await @@ -2408,7 +2410,7 @@ mod tests { // The copied history is baselined out of the wait: a fork that never // runs must not report the source's replies as its own turn's. let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-alpha-1".into()), @@ -2564,7 +2566,7 @@ mod tests { #[tokio::test] async fn reads_waits_and_gets_report_observations_for_the_calling_chat() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-beta-2".into()), @@ -2641,7 +2643,7 @@ mod tests { #[tokio::test] async fn a_human_cli_reports_no_observations() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); tools .call("read_chat", json!({ "chat": "alpha" })) .await @@ -2671,7 +2673,7 @@ mod tests { working_session: Some("chat-alpha-1".into()), ..Default::default() }); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-beta-2".into()), @@ -2693,7 +2695,7 @@ mod tests { #[tokio::test] async fn create_chat_records_the_origin_as_parent() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-beta-2".into()), @@ -2732,7 +2734,7 @@ mod tests { #[tokio::test] async fn wait_on_an_idle_chat_returns_immediately() { let world = Arc::new(World::default()); - let tools = tools(world, Origin::default()); + let tools = self::tools(world, Origin::default()); let waited = tools .call( "wait_for_turn", @@ -2749,7 +2751,7 @@ mod tests { // The stub never grows a session row, so a send-then-wait must run // to its deadline rather than declaring the (unstarted) run done. let world = Arc::new(World::default()); - let tools = tools(world, Origin::default()); + let tools = self::tools(world, Origin::default()); let started = std::time::Instant::now(); let sent = tools .call( @@ -2786,7 +2788,7 @@ mod tests { dispatch_barrier: Some(tokio::sync::Barrier::new(2)), ..Default::default() }); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); let reply = tokio::time::timeout( Duration::from_secs(3), crate::jsonrpc::handle_request( @@ -2820,7 +2822,7 @@ mod tests { #[tokio::test] async fn invalid_batch_sizes_have_no_side_effects() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); for name in ["create_chats", "send_messages"] { for requests in [vec![], vec![json!({"prompt": "hello"}); MAX_BATCH + 1]] { assert!( @@ -2931,7 +2933,7 @@ mod tests { #[tokio::test] async fn project_and_device_resolution_scopes_names_and_exact_paths() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); for args in [ json!({"project":"missing"}), json!({"device":"missing"}), @@ -2977,7 +2979,7 @@ mod tests { #[tokio::test] async fn discovery_and_creation_validate_catalogs_before_writing() { let world = Arc::new(World::default()); - let tools = tools(world.clone(), Origin::default()); + let tools = self::tools(world.clone(), Origin::default()); assert_eq!( tools.call("list_projects", json!({})).await.unwrap()["projects"] .as_array() @@ -3036,7 +3038,7 @@ mod tests { #[tokio::test] async fn mixed_batch_preserves_order_and_errors_per_request() { let world = Arc::new(World::default()); - let tools = tools( + let tools = self::tools( world.clone(), Origin { chat_id: Some("chat-alpha-1".into()), @@ -3294,7 +3296,7 @@ mod tests { #[tokio::test] async fn initialize_and_list_over_jsonrpc() { let world = Arc::new(World::default()); - let tools = tools(world, Origin::default()); + let tools = self::tools(world, Origin::default()); let init = crate::jsonrpc::handle_request( &tools, json!(1), @@ -3332,7 +3334,7 @@ mod tests { #[tokio::test] async fn instructions_teach_notice_driven_orchestration() { let world = Arc::new(World::default()); - let tools = tools(world, Origin::default()); + let tools = self::tools(world, Origin::default()); let init = crate::jsonrpc::handle_request( &tools, json!(1), From 06f6d160f811bdefd737f61e7247a018f64a993d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 12:52:45 +0700 Subject: [PATCH 76/96] fix(engine): the notice text test reads eight-character short ids --- crates/engine/src/completion_notices.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 50e119ae2a..206cebd849 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -957,8 +957,8 @@ mod tests { let single = notice_text(&[completed.clone()]); assert_eq!( single, - "Side chat \"Implement login\" (chat-si) finished.\n\ - Read the full result with the Roboco `read_chat` tool, chat chat-si." + "Side chat \"Implement login\" (chat-sid) finished.\n\ + Read the full result with the Roboco `read_chat` tool, chat chat-sid." ); let mut errored = side("chat-side-5678", "Flaky test", NoticeOutcome::Errored); @@ -967,21 +967,21 @@ mod tests { waiting.detail = Some("Should we bump the major version?".into()); let merged = notice_text(&[completed, errored, waiting]); assert!(merged.starts_with("3 side chats reached outcomes:\n")); - assert!(merged.contains("- \"Implement login\" (chat-si) finished.")); - assert!(merged.contains("- \"Flaky test\" (chat-si) failed: the build command timed out.")); + assert!(merged.contains("- \"Implement login\" (chat-sid) finished.")); + assert!(merged.contains("- \"Flaky test\" (chat-sid) failed: the build command timed out.")); assert!( merged - .contains("- \"Review the API\" (chat-si) is waiting for input: Should we bump the major version?") + .contains("- \"Review the API\" (chat-sid) is waiting for input: Should we bump the major version?") ); assert!(merged.contains("`read_chat`")); // A stopped row carries no detail. let stopped = side("chat-side-cdef", "Migration", NoticeOutcome::Stopped); - assert!(notice_text(&[stopped]).contains("\"Migration\" (chat-si) was stopped.")); + assert!(notice_text(&[stopped]).contains("\"Migration\" (chat-sid) was stopped.")); // A restart-lost row says interrupted, with its one-line reason. let mut interrupted = side("chat-side-cdef", "Migration", NoticeOutcome::Interrupted); interrupted.detail = Some(INTERRUPTED_REASON.into()); let text = notice_text(&[interrupted]); - assert!(text.contains("\"Migration\" (chat-si) was interrupted: engine restart.")); + assert!(text.contains("\"Migration\" (chat-sid) was interrupted: engine restart.")); // Degenerate empty input still renders a pointer, never a panic. assert!(notice_text(&[]).contains("read_chat")); } From d9ad366ea198981b74bb4d50c9af2a2e65c3debc Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:01:22 +0700 Subject: [PATCH 77/96] fix(engine): settle the integration-test compile slips --- crates/engine/tests/account_failover.rs | 2 +- crates/engine/tests/account_sign_in_state.rs | 2 +- crates/engine/tests/completion_notices.rs | 1 - crates/engine/tests/restart_resume.rs | 8 ++++---- crates/engine/tests/side_chats.rs | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/engine/tests/account_failover.rs b/crates/engine/tests/account_failover.rs index d92b7dbb73..16ba46222a 100644 --- a/crates/engine/tests/account_failover.rs +++ b/crates/engine/tests/account_failover.rs @@ -514,7 +514,7 @@ async fn setup( let registry = HarnessRegistry::new(); let harness = FailoverHarness::new(continuation); registry.register(harness.clone()); - let core = EngineCore::assemble(&root, Arc::new(registry), HarnessId::ClaudeCode) + let mut core = EngineCore::assemble(&root, Arc::new(registry), HarnessId::ClaudeCode) .expect("engine core assembles"); // The mocked usage endpoint only ever sees a stray probe. let base = usage_server().await; diff --git a/crates/engine/tests/account_sign_in_state.rs b/crates/engine/tests/account_sign_in_state.rs index 7bfec2035a..a296d5e399 100644 --- a/crates/engine/tests/account_sign_in_state.rs +++ b/crates/engine/tests/account_sign_in_state.rs @@ -178,7 +178,7 @@ async fn setup(nested: bool) -> (EngineCore, roboco_rpc::RpcClient) { let root = dir.keep(); let registry = HarnessRegistry::new(); registry.register(Arc::new(SignedOutHarness { nested })); - let core = + let mut core = EngineCore::assemble(&root, Arc::new(registry), HarnessId::ClaudeCode).expect("engine"); let config = AgentAccountsConfig::isolated(&root); diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 3a481945c2..65567d6f8c 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -260,7 +260,6 @@ fn run_request(prompt: &str) -> RunRequest { resume: None, provider_fork: None, resume_fallback_history: None, - resume_fallback_history: None, attachments: vec![], worktree: None, } diff --git a/crates/engine/tests/restart_resume.rs b/crates/engine/tests/restart_resume.rs index 6a7c15fa3a..d97d9e62fc 100644 --- a/crates/engine/tests/restart_resume.rs +++ b/crates/engine/tests/restart_resume.rs @@ -1237,7 +1237,7 @@ fn manufacture_crashed_steer_turn(dir: &std::path::Path, consumed: bool) { { let store = DocsStore::open(dir.join("orgs/dev-org/dev-user")).unwrap(); let doc = SessionDoc::init(CHAT).unwrap(); - doc.push_message(entry( + doc.push_message(&entry( "msg-user-1", MessageRole::User, "long task", @@ -1245,7 +1245,7 @@ fn manufacture_crashed_steer_turn(dir: &std::path::Path, consumed: bool) { Some(MessageStatus::Complete), )) .unwrap(); - doc.push_message(entry( + doc.push_message(&entry( "msg-assistant-1", MessageRole::Assistant, "partial…", @@ -1253,7 +1253,7 @@ fn manufacture_crashed_steer_turn(dir: &std::path::Path, consumed: bool) { Some(MessageStatus::Streaming), )) .unwrap(); - doc.push_message(entry( + doc.push_message(&entry( "msg-user-2", MessageRole::User, STEER_TEXT, @@ -1264,7 +1264,7 @@ fn manufacture_crashed_steer_turn(dir: &std::path::Path, consumed: bool) { if consumed { // The harness consumed the steer and the model kept working — // the reply after the steer is what was streaming at the kill. - doc.push_message(entry( + doc.push_message(&entry( "msg-assistant-2", MessageRole::Assistant, "after the steer…", diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index 304ffbb553..e23d18bed8 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -2489,7 +2489,7 @@ async fn a_fork_with_a_harness_and_model_override_runs_on_that_harness() { harness: HarnessId::Mock, model: Some("mock-9".into()), reasoning: Some(ReasoningLevel::Max), - model_options: serde_json::Map::from([( + model_options: serde_json::Map::from_iter([( "contextWindow".to_string(), serde_json::json!("1m"), )]), From 234c754bbbd8c5e7143d74456dee195385f2dfa5 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:02:44 +0700 Subject: [PATCH 78/96] fix(engine): the torn steer test queries the id the tear cut short --- crates/engine/src/run_journal.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/run_journal.rs b/crates/engine/src/run_journal.rs index faf9f8cc34..1813dbc7a2 100644 --- a/crates/engine/src/run_journal.rs +++ b/crates/engine/src/run_journal.rs @@ -684,7 +684,9 @@ mod tests { drop(file); let journal = RunJournal::open(dir.path()).unwrap(); assert!(journal.steer_consumed("chat-1", "m-full-id")); - assert!(!journal.steer_consumed("chat-1", "m-full-id-trunc")); + // The torn bytes are a prefix of the id being written: that id + // never matches. + assert!(!journal.steer_consumed("chat-1", "m-full-id-truncated")); assert!(!journal.steer_consumed("chat-1", "m-full")); } From 7085b113453e30e18aab9ca71959499c3f200d3c Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:24:34 +0700 Subject: [PATCH 79/96] =?UTF-8?q?fix(harness):=20code-review=20group=20B?= =?UTF-8?q?=20=E2=80=94=20pi=20delivery,=20claude=20launch=20probe,=20limi?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 pi caps a message it keeps rejecting (two deliveries): the turn ends errored, never an Error + Completed loop B2 a failed settle probe clears stale delivery records and drains the driver-owned queue like a clean settle (no wedged mailbox) B3 Stop only takes the already-settled shortcut when nothing is in flight; a probe armed mid-run still clears the queue and aborts B4 a quiescent probe never ends the turn while a prompt or steer awaits its acknowledgement (re-arms after the delay) B5 steer images load before the call is queued: bursts keep mailbox order on the wire B6 a stranded steer recovers as its original message (attachments and config intact) B7 the settle ref capture is bounded and a Stop mid-capture closes the turn interrupted at once B8 the unbounded acceptance is documented as deliberate B9 pi fork notice uses glossary wording (chat transcript, trimmed) B10 claude resetsAt parses leniently (number, float, numeric string); a malformed value never fails the frame B11 a claude fork whose first result errors before any assistant output retries once fresh with the budgeted transcript B12 same for a failover resume carrying resume_fallback_history B13 codex rateLimitExceeded is a usage limit only with a live exhausted window; past resets are ignored B14 pi fork_boundary returns a named ForkBoundary D one prompt_with_history helper for every driver's fallback paste Tests added (not run; CI): pi late-ack, mixed image burst, reject cap, queued-behind-failing-probe, stranded image steer, Stop with armed probe; claude fork bad uuid, resume refused after launch, lenient resetsAt; codex limit classification (live vs lifted reset, bare 429). No wire changes. --- crates/harness/src/claude/mod.rs | 190 +++++-- crates/harness/src/claude/normalize.rs | 33 ++ crates/harness/src/claude/wire.rs | 40 +- crates/harness/src/codex/mod.rs | 4 +- crates/harness/src/codex/normalize.rs | 75 ++- crates/harness/src/lib.rs | 9 + crates/harness/src/opencode/mod.rs | 7 +- crates/harness/src/pi/mod.rs | 501 ++++++++++++++----- crates/harness/tests/claude.rs | 89 ++++ crates/harness/tests/codex.rs | 4 +- crates/harness/tests/fixtures/fake-claude.sh | 61 +++ crates/harness/tests/fixtures/fake-codex.sh | 4 +- crates/harness/tests/fixtures/fake-pi-rpc.sh | 213 ++++++++ crates/harness/tests/pi.rs | 250 +++++++++ 14 files changed, 1289 insertions(+), 191 deletions(-) diff --git a/crates/harness/src/claude/mod.rs b/crates/harness/src/claude/mod.rs index 450cb28cb0..c4e9a85a0e 100644 --- a/crates/harness/src/claude/mod.rs +++ b/crates/harness/src/claude/mod.rs @@ -115,6 +115,58 @@ fn mcp_config_arg(mcp: &roboco_proto::McpServer) -> String { .to_string() } +/// The launch probe's reading of the buffered stdout so far (B11/B12): an +/// errored first `result` BEFORE any assistant output refuses the launch +/// — the CLI accepted the flags but then failed the resumed session (a +/// `--resume-session-at` uuid validated late, or a session the account +/// can no longer read). Any assistant output accepts it (the launch is +/// serving the conversation). A clean `result` before any assistant +/// output also accepts: the launch itself is fine, whatever the empty +/// turn means. Only complete lines are judged — the tail of a partial +/// line rides to the session loop and is judged there. +enum LaunchReading { + Undecided, + Accepted, + Refused, +} + +fn launch_reading(head: &[u8]) -> LaunchReading { + for line in std::str::from_utf8(head) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + { + let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else { + continue; + }; + match value.get("type").and_then(serde_json::Value::as_str) { + Some("assistant") => return LaunchReading::Accepted, + Some("result") => { + let errored = value + .get("is_error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + || value.get("subtype").and_then(serde_json::Value::as_str) + == Some("error"); + return if errored { + LaunchReading::Refused + } else { + LaunchReading::Accepted + }; + } + _ => {} + } + } + LaunchReading::Undecided +} + +/// Bound on the launch probe (B11/B12): the probe buffers the stream until +/// the launch proves itself, so a wedged CLI must not hold it forever — +/// past the bound the buffered stream is handed over and the session loop +/// deals with whatever follows. Generous: a healthy launch speaks within +/// milliseconds; only Stop (the other escape) is faster. +const LAUNCH_PROBE_BOUND: Duration = Duration::from_secs(60); + /// Whether the CLI's own session store knows `session_id` — one /// `<config>/projects/*/{session_id}.jsonl` file (the same walk /// `Normalizer::for_resume` restores subagent chips from). A resume the @@ -737,7 +789,7 @@ impl ClaudeHarness { "resume of {resume} has no session file; starting fresh with the budgeted history" ); request.resume = None; - request.prompt = format!("{history}{}", request.prompt); + request.prompt = crate::prompt_with_history(&history, &request.prompt); } // A native fork (t3-orchestration-v2 ticket 18): the CLI continues // the SOURCE session as a NEW session id — `--resume <source> @@ -777,7 +829,7 @@ impl ClaudeHarness { ); request.provider_fork = None; if let Some(history) = fork.fallback_history { - request.prompt = format!("{history}{}", request.prompt); + request.prompt = crate::prompt_with_history(&history, &request.prompt); } } } @@ -792,7 +844,7 @@ impl ClaudeHarness { request.provider_fork = None; fork_flags = None; if let Some(history) = fork_fallback.take() { - request.prompt = format!("{history}{}", request.prompt); + request.prompt = crate::prompt_with_history(&history, &request.prompt); } tracing::debug!( target: "roboco_harness::claude", @@ -865,51 +917,18 @@ impl ClaudeHarness { ))); } - // The fork launch's acceptance probe (ticket 18): a CLI that - // rejects the flags exits before it ever speaks — our opening - // control request gets no answer, so stdout closes with - // nothing on it. One byte proves the CLI accepted the launch - // (its answer itself, or the init frame); EOF or a read error - // proves refusal, and the loop retries once as a fresh session - // with the budgeted transcript. An interrupt mid-probe hands - // the child to the session loop, which sees the token and ends - // the run interrupted. Every other launch — plain, resumed, - // title — is handed over unread, as before. - let mut head: Vec<u8> = Vec::new(); - if attempt == 0 && fork_flags.is_some() && !title_only { - enum Probe { - Spoke(usize), - Silent, - Interrupted, - } - let mut probe = [0u8; 1]; - let outcome = tokio::select! { - biased; - _ = controls.interrupt.cancelled() => Probe::Interrupted, - read = stdout.read(&mut probe) => match read { - Ok(n) if n > 0 => Probe::Spoke(n), - _ => Probe::Silent, - }, - }; - match outcome { - Probe::Spoke(n) => head = probe[..n].to_vec(), - Probe::Silent => { - shutdown_child(&mut child, self.kill_grace).await; - continue; - } - Probe::Interrupted => {} - } - } - - // The initial prompt as the first stdin user line (streaming-input - // mode). Ultrathink rides every user message — steers included. + // The first prompt as the first stdin user line (streaming-input + // mode) — sent BEFORE the launch probe below: a CLI that lazily + // validates its resume flags fails them only once a prompt + // arrives, so the probe must be reading when that result lands. + // Ultrathink rides every user message — steers included. // Staged image attachments are inlined as base64 image content blocks // ahead of the text (verified against the real CLI); their path refs // also ride the prompt text, so a skipped/unreadable file degrades to // the old-app behavior (the agent opens the path with its Read tool). // The line carries a uuid: the CLI then reports its // `command_lifecycle`, which decides when a `result` is a steer - // boundary instead of the turn's end. A refused fork's retry + // boundary instead of the turn's end. A refused launch's retry // delivers the budgeted transcript here — it is already in front // of the prompt. let first_command = uuid::Uuid::new_v4().to_string(); @@ -920,6 +939,93 @@ impl ClaudeHarness { ); let _ = stdin_tx.send(StdinMsg::Line(first)); + // The launch's acceptance probe (ticket 18): the CLI proves the + // launch as it speaks — anything it writes is buffered in `head` + // and handed to the session loop unchanged, so nothing is lost. + // The probe reads until one of: + // - any ASSISTANT output: the launch is good — hand over. + // - an ERRORED first `result` BEFORE any assistant output: the + // CLI accepted the flags but then failed the resume — a bad + // `--resume-session-at` uuid it validates late (B11), or a + // resumed session the account can no longer read (B12: the + // failover continuation's cross-account resume). Same + // refusal as silence: the retry below starts fresh with the + // budgeted transcript, because a fork/resume never fails the + // send. + // - silence: EOF before the CLI ever spoke — the flags were + // rejected up front (the original ticket-18 shape). + // - Stop mid-probe: hand the child to the session loop, which + // sees the token and ends the run interrupted. + // Every launch the probe covers carries a fallback history the + // retry pastes: a fork (the engine's budgeted transcript), or a + // resume with `resume_fallback_history` (the failover + // continuation). Plain launches and titles are handed over + // unread, as before. + let probe_launch = !title_only + && (fork_flags.is_some() + || (request.resume.is_some() && request.resume_fallback_history.is_some())); + let mut head: Vec<u8> = Vec::new(); + if probe_launch { + enum Probe { + Accepted, + Silent, + Refused, + Interrupted, + } + let launch_fallback = fork_fallback + .take() + .or_else(|| request.resume_fallback_history.take()); + let mut probe = [0u8; 1024]; + let bound = + tokio::time::Instant::now() + LAUNCH_PROBE_BOUND; + let outcome = loop { + tokio::select! { + biased; + _ = controls.interrupt.cancelled() => break Probe::Interrupted, + _ = tokio::time::sleep_until(bound) => break Probe::Accepted, + read = stdout.read(&mut probe) => match read { + Ok(0) | Err(_) => { + break if head.is_empty() { + Probe::Silent + } else { + Probe::Accepted + } + } + Ok(n) => { + head.extend_from_slice(&probe[..n]); + match launch_reading(&head) { + LaunchReading::Refused => break Probe::Refused, + // Assistant output spoke for the launch: + // hand the buffered stream over. + LaunchReading::Accepted => break Probe::Accepted, + LaunchReading::Undecided => {} + } + } + }, + } + }; + match outcome { + // Interrupted: the fallback stays unspent; the session + // loop ends the run interrupted. + Probe::Accepted | Probe::Interrupted => {} + Probe::Silent | Probe::Refused => { + shutdown_child(&mut child, self.kill_grace).await; + request.resume = None; + request.provider_fork = None; + fork_flags = None; + if let Some(history) = launch_fallback { + request.prompt = crate::prompt_with_history(&history, &request.prompt); + } + tracing::debug!( + target: "roboco_harness::claude", + "the CLI refused the launch (silent, or an error before any \ + assistant output); starting fresh with the budgeted history" + ); + continue; + } + } + } + // The probe's consumed byte rides ahead of the pipe (no output // is lost); an empty head is every unprobed launch. let stdout_lines = diff --git a/crates/harness/src/claude/normalize.rs b/crates/harness/src/claude/normalize.rs index f426c5b32c..993e6715e3 100644 --- a/crates/harness/src/claude/normalize.rs +++ b/crates/harness/src/claude/normalize.rs @@ -1667,6 +1667,39 @@ mod tests { ); } + #[test] + fn a_malformed_resets_at_never_fails_the_frame() { + // `resetsAt` in every shape the CLI has been seen to send: an + // integer, a float, a numeric string — all parse; anything else + // (null, words, an object) reads as unknown. None of them may fail + // the frame: the window classification rides the same event, and a + // failed deserialize would drop the whole limit report. + let shape = |resets_at: &str| { + let raw = format!( + r#"{{"type":"rate_limit_event","rate_limit_info":{{"status":"rejected","rateLimitType":"five_hour",{resets_at}}}}}"# + ); + normalize_one(&raw) + }; + for (field, expected) in [ + (r#""resetsAt":1762675400"#, Some(1_762_675_400_000i64)), + (r#""resetsAt":1762675400.0"#, Some(1_762_675_400_000)), + (r#""resetsAt":"1762675400""#, Some(1_762_675_400_000)), + (r#""resetsAt":"1762675400.5""#, Some(1_762_675_400_000)), + (r#""resetsAt":null"#, None), + (r#""resetsAt":"soon""#, None), + (r#""resetsAt":{}"#, None), + (r#""resetsAt":0"#, None), + ] { + let events = shape(field); + match events.as_slice() { + [AgentEvent::Error { kind: Some(AgentErrorKind::UsageLimit { resets_at, .. }), .. }] => { + assert_eq!(*resets_at, expected, "{field}"); + } + other => panic!("{field} must still classify the limit: {other:?}"), + } + } + } + #[test] fn assistant_error_codes_classify_limits_and_auth() { // The error message also completes its (empty) assistant message; diff --git a/crates/harness/src/claude/wire.rs b/crates/harness/src/claude/wire.rs index 135b5bb32b..5c4d0fe3c4 100644 --- a/crates/harness/src/claude/wire.rs +++ b/crates/harness/src/claude/wire.rs @@ -154,7 +154,15 @@ pub(crate) struct RateLimitInfo { pub rate_limit_type: Option<String>, /// Unix seconds when the rejected window resets — the CLI reports it on /// the blocking event (live-verified 2.1.228); absent when unknown. - #[serde(rename = "resetsAt", default)] + /// Parsed leniently ([`deserialize_resets_at]): some CLI builds send a + /// float or a numeric string here, and a malformed value must cost + /// the timestamp — never the whole frame (the window classification + /// rides the same event). + #[serde( + rename = "resetsAt", + default, + deserialize_with = "deserialize_resets_at" + )] pub resets_at: Option<u64>, /// `allowed`/`allowed_warning` when the account may spend provisioned /// overage past a rejected window. @@ -166,6 +174,36 @@ pub(crate) struct RateLimitInfo { pub overage_in_use: bool, } +/// `resetsAt` in every shape the CLI has been seen to send: an integer, +/// a float (some builds serialize JS numbers), or a numeric string — +/// parsed to unix seconds. Any other shape (null, a non-numeric string, +/// an object) reads as "unknown", and deserializing never fails the +/// frame. +fn deserialize_resets_at<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::<serde_json::Value>::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + let seconds = match value { + serde_json::Value::Null => None, + serde_json::Value::Number(number) => number + .as_u64() + .or_else(|| number.as_f64().map(|value| value.trunc() as u64)), + serde_json::Value::String(text) => { + let text = text.trim(); + text.parse::<u64>() + .ok() + .or_else(|| text.parse::<f64>().ok().map(|value| value.trunc() as u64)) + } + // Any other shape is a CLI oddity: unknown reset, never an error. + _ => None, + }; + Ok(seconds.filter(|seconds| *seconds > 0)) +} + impl RateLimitInfo { /// A rejected window that actually blocks the turn: an account spending /// provisioned overage keeps running despite the reject. diff --git a/crates/harness/src/codex/mod.rs b/crates/harness/src/codex/mod.rs index dc4985e6fe..974f22e23a 100644 --- a/crates/harness/src/codex/mod.rs +++ b/crates/harness/src/codex/mod.rs @@ -1252,7 +1252,9 @@ async fn run_session(session: Session) { // carries a fallback — the engine attaches none). A refused RESUME // delivers the same way (ticket 09). let delivered_prompt = match (&fork_fallback, &resume_fallback) { - (Some(history), _) | (_, Some(history)) => format!("{history}{}", request.prompt), + (Some(history), _) | (_, Some(history)) => { + crate::prompt_with_history(history, &request.prompt) + } (None, None) => request.prompt.clone(), }; // Model, effort and service tier ride every `turn/start` (each sticks diff --git a/crates/harness/src/codex/normalize.rs b/crates/harness/src/codex/normalize.rs index 260684b149..c56f585b00 100644 --- a/crates/harness/src/codex/normalize.rs +++ b/crates/harness/src/codex/normalize.rs @@ -199,14 +199,25 @@ pub(crate) fn rate_limit_reset(params: &Value) -> Option<RateLimitReset> { binding } -/// Map a structured turn-error code onto the structured error kind: usage -/// limits (turn-level `usageLimitExceeded`/`rateLimitExceeded`) become a -/// usage limit carrying the latest known reset; auth failures -/// (`unauthorized`) become signed out. Everything else stays unclassified. +/// Map a structured turn-error code onto the structured error kind: +/// usage limits become a usage limit carrying the latest known reset; +/// auth failures (`unauthorized`) become signed out. Everything else +/// stays unclassified. +/// +/// `usageLimitExceeded` is the plan limit itself — the error says the +/// limit is reached, so it is always classified (the snapshot only +/// refines the window and reset). `rateLimitExceeded` is AMBIGUOUS: a +/// plain 429 says nothing about the plan, and a transient one must stay +/// an unclassified error (retried like any failure), so it classifies +/// only when the snapshot proves a blocked window — an exhausted window +/// whose reset is still AHEAD. A reset already in the past is no block +/// at all (the window has since lifted), so it is ignored: the error +/// reads as transient. The same past reset is dropped from a plan limit +/// too: it would arm a continuation that fires at once into the wall. pub(crate) fn error_kind(code: &str, limit: Option<&RateLimitReset>) -> Option<AgentErrorKind> { + let limit = limit.filter(|reset| reset.resets_at > now_ms()); match code { - "usageLimitExceeded" | "usage_limit_exceeded" | "rateLimitExceeded" - | "rate_limit_exceeded" => Some(match limit { + "usageLimitExceeded" | "usage_limit_exceeded" => Some(match limit { Some(reset) => AgentErrorKind::UsageLimit { window: reset.window.clone(), resets_at: Some(reset.resets_at), @@ -216,11 +227,24 @@ pub(crate) fn error_kind(code: &str, limit: Option<&RateLimitReset>) -> Option<A resets_at: None, }, }), + "rateLimitExceeded" | "rate_limit_exceeded" => limit + .map(|reset| AgentErrorKind::UsageLimit { + window: reset.window.clone(), + resets_at: Some(reset.resets_at), + }), "unauthorized" => Some(AgentErrorKind::SignedOut), _ => None, } } +/// Epoch milliseconds now (the past-reset filter's clock). +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or_default() +} + /// The classification for a turn-level error notification: the code it /// carries, mapped with the latest rate-limit snapshot when one arrived. pub(crate) fn turn_error_kind( @@ -1228,11 +1252,16 @@ mod tests { #[test] fn error_codes_classify_limits_and_auth() { + // A reset still ahead (2100) and one already past (2020). let snapshot = RateLimitReset { window: "weekly".into(), - resets_at: 1_762_675_400_000, + resets_at: 4_102_444_800_000, + }; + let lifted = RateLimitReset { + window: "weekly".into(), + resets_at: 1_577_836_800_000, }; - // Both limit codes, both spellings, with and without a snapshot. + // Both limit codes, both spellings, with a live exhausted window. for code in [ "usageLimitExceeded", "usage_limit_exceeded", @@ -1243,18 +1272,30 @@ mod tests { error_kind(code, Some(&snapshot)), Some(AgentErrorKind::UsageLimit { window: "weekly".into(), - resets_at: Some(1_762_675_400_000), + resets_at: Some(4_102_444_800_000), }), "{code}" ); - assert_eq!( - error_kind(code, None), - Some(AgentErrorKind::UsageLimit { - window: "usage".into(), - resets_at: None, - }), - "{code} without a snapshot" - ); + } + // The plan limit classifies on the error alone; a past reset is + // ignored, never carried. + for code in ["usageLimitExceeded", "usage_limit_exceeded"] { + for limit in [None, Some(&lifted)] { + assert_eq!( + error_kind(code, limit), + Some(AgentErrorKind::UsageLimit { + window: "usage".into(), + resets_at: None, + }), + "{code} without a live snapshot" + ); + } + } + // A bare 429 (no exhausted window, or one that already lifted) is + // transient: unclassified. + for code in ["rateLimitExceeded", "rate_limit_exceeded"] { + assert_eq!(error_kind(code, None), None, "{code}"); + assert_eq!(error_kind(code, Some(&lifted)), None, "{code} past reset"); } assert_eq!(error_kind("unauthorized", None), Some(AgentErrorKind::SignedOut)); assert_eq!(error_kind("unauthorized", Some(&snapshot)), Some(AgentErrorKind::SignedOut)); diff --git a/crates/harness/src/lib.rs b/crates/harness/src/lib.rs index f740ae52fe..c21065428f 100644 --- a/crates/harness/src/lib.rs +++ b/crates/harness/src/lib.rs @@ -41,7 +41,16 @@ pub enum HarnessError { Io(#[from] std::io::Error), } +/// The prompt a fresh session receives when a native fork or resume falls +/// back to the engine's budgeted transcript: the history in front of the +/// prompt, verbatim (the engine's transcript carries its own separator). +/// Every driver delivers the fallback through this one spelling. +pub(crate) fn prompt_with_history(history: &str, prompt: &str) -> String { + format!("{history}{prompt}") +} + /// A steer prompt pushed into a live run; delivered at the harness's steering boundary. +#[derive(Clone)] pub struct SteerMessage { pub prompt: String, pub message_id: Option<String>, diff --git a/crates/harness/src/opencode/mod.rs b/crates/harness/src/opencode/mod.rs index af7214b839..df07a9092b 100644 --- a/crates/harness/src/opencode/mod.rs +++ b/crates/harness/src/opencode/mod.rs @@ -1674,10 +1674,9 @@ async fn run_session(session: Session) { // native-command routing below — the engine attaches no fallback to a // native command, so the concatenation can never hide one. let delivered_prompt = match &fork_fallback { - Some(history) => format!( - "{}{}", - roboco_proto::invocation::harness_prompt(history, HarnessId::Opencode), - request.prompt + Some(history) => crate::prompt_with_history( + &roboco_proto::invocation::harness_prompt(history, HarnessId::Opencode), + &request.prompt, ), None => request.prompt.clone(), }; diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index 81e38918e3..e56c3a1787 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -27,8 +27,12 @@ //! against the end of a run. The acknowledgement's `disposition` reports //! what became of it (`started` / `queued` / `handled`; pi < 0.99 reports //! none and the driver infers from whether a run starts). The acceptance -//! is unbounded: pi's preflight (extensions, the Roboco MCP bridge, -//! compaction) may take arbitrarily long, and only Stop interrupts it. A +//! is unbounded ON PURPOSE — there is deliberately no acceptance timeout +//! (ticket 02 removed the fixed one): a healthy preflight can take +//! arbitrarily long (extensions, the Roboco MCP bridge, compaction), so +//! a fixed budget killed slow-but-fine starts. The cost is that a WEDGED +//! pi holds its turn open until the user stops it — only Stop interrupts +//! a slow acceptance; nothing else ever times the run out. A //! rejected prompt never ends the session — the message returns to the //! driver queue and the rejection surfaces. Delivery tracking keeps one //! FIFO record per original input, created when the input goes out (the @@ -56,8 +60,21 @@ //! error (`stopReason: "error"`), exhausted `auto_retry_*`, or a failed //! `compaction_end` without `willRetry` produce `Done { Errored }` — and //! a successful compaction retry clears the error state, so a run that -//! recovered through compaction ends completed. Stop sends `clear_queue` -//! before `abort`: steers pi queued but never consumed are cancelled +//! recovered through compaction ends completed. The turn is never over +//! while an input is still awaiting its acknowledgement: a quiescent +//! probe re-arms until the preflight lands. A probe that keeps failing +//! ends the turn errored — never a silent kill — and drains the queued +//! mailbox messages like a clean settle (the next one starts the next +//! turn), so the mailbox never wedges behind the dead turn. A message pi +//! keeps REJECTING is capped, not retried forever: past two deliveries +//! it is dropped from the driver (the engine's at-least-once ledger +//! still owns it) and the turn ends errored — never +//! a loop of Error + Completed for a message that never ran. A steer +//! stranded in pi's queue at settle is recovered by `clear_queue` and +//! re-queued INTACT (its attachments and run config ride along). Stop +//! sends `clear_queue` before `abort` — and only takes the "already +//! settled" shortcut when the run truly settled, never while work is +//! still in flight: steers pi queued but never consumed are cancelled //! with the turn (the engine's at-least-once ledger already owns their //! fate), never delivered into the aborted run. //! - **Stats**: `get_session_stats.contextUsage` feeds the context meter @@ -560,10 +577,10 @@ impl PiHarness { // the user message (codex parity: the refused-fork // fallback), and a notice says what happened. let history = fork.fallback_history.as_deref().unwrap_or(""); - request.prompt = format!("{history}{}", request.prompt); + request.prompt = crate::prompt_with_history(history, &request.prompt); fork_notice = Some(format!( "pi could not fork the source session ({reason}); starting a new \ - session with a summary of the previous conversation" + session with the chat transcript, trimmed to fit" )); } ForkLaunch::Interrupted => { @@ -866,6 +883,17 @@ const SETTLE_TIMEOUT: Duration = Duration::from_secs(15); /// Bound on one settle-probe round (`get_state`, `clear_queue`): pi answers /// in milliseconds; a hang past it is one failed attempt. const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// How many times one mailbox message may be delivered before repeated +/// rejection hands it back to the engine as a failed send (B1): the +/// original delivery plus ONE re-delivery. Past it, the turn ends errored +/// — never a loop of Error + Completed for a message that never ran. +const MESSAGE_DELIVERY_ATTEMPTS: u32 = 2; +/// Bound on the settle capture's `get_entries` round trip (B7): the +/// capture must never wedge the run loop for its full 2×15s budget — +/// an interrupt landing mid-capture cuts even this short, and a lost +/// capture costs the provider turn ref (a later fork falls back to the +/// budgeted paste), never the turn. +const REF_CAPTURE_BUDGET: Duration = Duration::from_secs(3); /// Settle-probe attempts before the turn ends errored: a failed probe is /// retried, and after this many the turn fails visibly instead of the /// process being killed silently. @@ -911,6 +939,28 @@ struct Armed { struct DeliveryRecord { id: u64, text: String, + /// The original mailbox message for an input accepted into pi's steer + /// queue: a strand recovered by the settle probe's `clear_queue` + /// re-queues it INTACT — attachments and run config included — instead + /// of rebuilding it as bare text. + message: Option<SteerMessage>, +} + +/// Count one more rejection of `msg` (keyed by its engine message id, or +/// its text when it has none) and report whether the re-delivery cap is +/// reached (B1). A capped message's count is forgotten: the engine owns +/// its fate from here. +fn note_rejection(rejections: &mut HashMap<String, u32>, msg: &SteerMessage) -> Option<u32> { + let key = msg.message_id.clone().unwrap_or_else(|| msg.prompt.clone()); + let attempts = rejections.entry(key.clone()).or_insert(0); + *attempts += 1; + let attempts = *attempts; + if attempts >= MESSAGE_DELIVERY_ATTEMPTS { + rejections.remove(&key); + Some(attempts) + } else { + None + } } /// Retire `id`'s delivery record; `true` when it was still pending (the @@ -1121,26 +1171,36 @@ fn branch_user_entries(entries: &SessionEntries) -> Vec<String> { users } -/// The exclusive fork boundary for a native fork at `last_turn` (the -/// provider turn ref recorded on the boundary entry): the user entry id -/// of the turn AFTER the boundary. pi's RPC `fork` re-roots the session -/// BEFORE the named user message, so naming the next turn's user entry -/// keeps everything through the boundary turn — exactly the prefix the -/// fork copied. `Ok(None)` when the boundary IS the branch's newest turn -/// (the `--fork` copy already ends there: no RPC, fork the whole -/// session); `Err` when the ref names no entry on the active branch (a -/// stale ref, or one from a re-rooted/compacted-away branch) — the fork -/// then falls back to the budgeted paste rather than silently keeping -/// turns the boundary excluded. -fn fork_boundary(users: &[String], last_turn: &str) -> Result<Option<String>, ()> { +/// The resolved fork boundary for a native fork at `last_turn` (the +/// provider turn ref recorded on the boundary entry). pi's RPC `fork` +/// re-roots the session BEFORE the named user message, so the boundary +/// names the user entry of the turn AFTER the boundary turn — keeping +/// everything through it, exactly the prefix the fork copied. +#[derive(Debug, PartialEq, Eq)] +enum ForkBoundary { + /// Re-root the session before this user entry (one turn newer than + /// the boundary). + RootAt(String), + /// The boundary IS the branch's newest turn: the `--fork` copy + /// already ends there — fork the whole session, no RPC. + NewestTurn, + /// The ref names no entry on the active branch (a stale ref, or one + /// from a re-rooted/compacted-away branch) — the fork falls back to + /// the budgeted paste rather than silently keeping turns the + /// boundary excluded. + OffBranch, +} + +fn fork_boundary(users: &[String], last_turn: &str) -> ForkBoundary { let Some(index) = users.iter().position(|id| id == last_turn) else { - return Err(()); + return ForkBoundary::OffBranch; }; // `users` is newest-first: index 0 is the branch's newest user entry, // and one step towards the head is one turn newer. - Ok(index - .checked_sub(1) - .and_then(|newer| users.get(newer).cloned())) + match index.checked_sub(1).and_then(|newer| users.get(newer)) { + Some(entry_id) => ForkBoundary::RootAt(entry_id.clone()), + None => ForkBoundary::NewestTurn, + } } /// One bounded `get_entries` round: a request pi never answers counts as a @@ -1276,12 +1336,16 @@ async fn fork_source_session( let listing = parse_entries(&data) .ok_or_else(|| "pi returned an unreadable session tree".to_owned())?; let users = branch_user_entries(&listing); - let boundary = fork_boundary(&users, &fork.last_turn).map_err(|_| { - format!( - "the boundary entry {} is no longer on the session branch", - fork.last_turn - ) - })?; + let boundary = match fork_boundary(&users, &fork.last_turn) { + ForkBoundary::OffBranch => { + return Err(format!( + "the boundary entry {} is no longer on the session branch", + fork.last_turn + )) + } + ForkBoundary::NewestTurn => None, + ForkBoundary::RootAt(entry_id) => Some(entry_id), + }; if let Some(entry_id) = boundary { let data = client .request("fork", json!({ "entryId": entry_id })) @@ -1490,8 +1554,8 @@ async fn run_session(session: Session) { if let Some(notice) = &fork_notice { // A refused native fork (ticket 19): the fresh session carries the // budgeted transcript in front of the prompt (pasted in - // run_with_mode), so the notice says the context was summarized, - // not lost — parity with the lost-resume notice above. + // run_with_mode), so the notice says the transcript rides along + // (trimmed), not lost — parity with the lost-resume notice above. let _ = send( &event_tx, AgentEvent::Error { @@ -1519,6 +1583,11 @@ async fn run_session(session: Session) { // delivered as fresh prompts once the current turn's Done is out, // carrying their attachments and run config. let mut driver_steers: VecDeque<SteerMessage> = VecDeque::new(); + // Rejections per message (B1's re-delivery cap), keyed by the message + // id (the engine's, unique per send) or, for id-less messages, the + // prompt text: a pi that keeps rejecting one message must be capped, + // not retried forever. + let mut rejections: HashMap<String, u32> = HashMap::new(); // The settle probe: armed at `agent_settled` (and after a handled or // undispositioned acknowledgement, or a rejection), re-armed while pi // reports busy. The turn ends only when a round reports pi at rest with @@ -1639,7 +1708,16 @@ async fn run_session(session: Session) { // consumes it (the delivery signal), in // whatever order the acknowledgement // processes. A strand is recovered by the - // settle probe clear_queue. + // settle probe clear_queue — the record now + // keeps the ORIGINAL message so that + // recovery re-queues it intact, attachments + // and run config included. + if let Some(id) = delivery + && let Some(record) = + sent_deliveries.iter_mut().find(|r| r.id == id) + { + record.message = mailbox.clone(); + } // // The run own first prompt queued behind an // extension run: awaiting_prompt_echo @@ -1717,36 +1795,55 @@ async fn run_session(session: Session) { }, ) .await; - driver_steers.push_front(msg); - run_active = false; - let at_rest = !saw_activity - && probe.is_none() - && steer_calls.is_empty(); - if at_rest && !retried_at_rest { - // One immediate re-delivery per acceptance - // cycle; a pi that keeps rejecting an idle - // prompt falls back to the probe path below, - // whose re-arm delay bounds the retry rate. - retried_at_rest = true; - if let Some(msg) = driver_steers.pop_front() - && !deliver_parked_steer( - &client, - &mut armed, - &mut sent_deliveries, - &mut delivery_seq, - &mut awaiting_prompt_echo, - &mut run_active, - &mut done_current, - &mut generation, - msg, - ) - .await - { - break 'main; + // B1's re-delivery cap: past the attempts, the + // message is NOT re-armed (an endlessly + // rejecting pi would otherwise Error + Done-loop + // on it forever) — it is handed back to the + // engine as a failed send, the at-least-once + // ledger re-dispatches it on a fresh runtime, + // and this turn ends ERRORED: never Completed + // for a message that never ran. + if let Some(attempts) = note_rejection(&mut rejections, &msg) { + turn_error = Some(format!( + "pi rejected the message {attempts} times: {e}" + )); + run_active = false; + if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } + } else { + driver_steers.push_front(msg); + run_active = false; + let at_rest = !saw_activity + && probe.is_none() + && steer_calls.is_empty(); + if at_rest && !retried_at_rest { + // One immediate re-delivery per acceptance + // cycle; a pi that keeps rejecting an idle + // prompt falls back to the probe path below, + // whose re-arm delay bounds the retry rate. + retried_at_rest = true; + if let Some(parked) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut sent_deliveries, + &mut delivery_seq, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + parked, + ) + .await + { + break 'main; + } + } else if probe.is_none() { + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); } - } else if probe.is_none() { - probe_generation = generation; - probe = Some(rearm_probe(&client, true)); } } None => { @@ -1797,20 +1894,28 @@ async fn run_session(session: Session) { // pi is idle yet still held messages (steers // stranded by the settle race): they were cleared // and re-queue, in send order, as the next turn's - // prompts. Their delivery records retire — pi will - // never echo them — so the re-delivery's own echo - // consumes the fresh records it creates. + // prompts — INTACT (B6): a stranded steer's record + // keeps the original mailbox message, so its + // re-delivery carries the attachments and run + // config it was sent with, and its delivery record + // retires (pi will never echo it) so the + // re-delivery's own echo consumes the fresh record + // it creates. A stranded text with no record (the + // run's own first prompt) re-queues as bare text. probe_failures = 0; - for text in &texts { - if let Some(pos) = sent_deliveries + let mut recovered: VecDeque<SteerMessage> = VecDeque::new(); + for text in texts { + let record = sent_deliveries .iter() - .position(|record| record.text == *text) - { - sent_deliveries.remove(pos); - } + .position(|record| record.text == text) + .and_then(|pos| sent_deliveries.remove(pos)); + let message = record + .and_then(|record| record.message) + .unwrap_or_else(|| SteerMessage::text(text)); + recovered.push_back(message); } - for text in texts.into_iter().rev() { - driver_steers.push_front(SteerMessage::text(text)); + for message in recovered.into_iter().rev() { + driver_steers.push_front(message); } probe_generation = generation; probe = Some(rearm_probe(&client, false)); @@ -1824,6 +1929,18 @@ async fn run_session(session: Session) { probe_failures = 0; probe_generation = generation; probe = Some(rearm_probe(&client, false)); + } else if armed.is_some() || !steer_calls.is_empty() { + // A prompt or steer is still awaiting pi's + // acknowledgement (B4): generation equality + // cannot see an input that is in preflight — the + // bump lands when its acknowledgement does — + // and the turn is not over while one is. Wait it + // out: re-arm (after the delay — a slow + // preflight must not hot-loop the probe) and + // re-check once the acceptance resolves. + probe_failures = 0; + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); } else { probe_failures = 0; if let Some(usage) = stats @@ -1843,10 +1960,46 @@ async fn run_session(session: Session) { // when the entry's segment finishes at the // `Done` below. Best-effort: a failed read costs // the ref — a later fork falls back to the - // budgeted paste — never the turn. - if let Some(reference) = - capture_turn_ref(&client, &session_pointer, &mut ref_cursor) - .await + // budgeted paste — never the turn. The capture + // is BOUNDED and interruptible (B7): its round + // trips must never wedge the run loop for their + // full 2×15s budget — a Stop landing mid-capture + // is read at once and cuts the wait short (the + // ref is lost, never the turn), and the Done + // below waits at most the short budget. + let capture = + capture_turn_ref(&client, &session_pointer, &mut ref_cursor); + tokio::pin!(capture); + let mut stopped = false; + let captured = tokio::select! { + reference = &mut capture => reference, + _ = interrupt.cancelled(), if !interrupt_sent => { + stopped = true; + None + } + _ = tokio::time::sleep(REF_CAPTURE_BUDGET) => None, + }; + if stopped { + // Stop landed mid-capture: the turn already + // settled with nothing in flight, so it closes + // out as interrupted at once (the "already + // settled" shortcut of the interrupt arm). + interrupted = true; + done_current = true; + done_after_interrupt = true; + let _ = send( + &event_tx, + AgentEvent::Done { + status: DoneStatus::Interrupted, + result: None, + error: None, + session_id: Some(session_pointer.clone()), + }, + ) + .await; + break 'main; + } + if let Some(reference) = captured && !send(&event_tx, AgentEvent::TurnRef { reference }).await { break 'main; @@ -1880,7 +2033,7 @@ async fn run_session(session: Session) { // A queued message becomes the next turn; // otherwise the session parks — the caller owns // teardown. - if let Some(msg) = driver_steers.pop_front() + if let Some(parked) = driver_steers.pop_front() && !deliver_parked_steer( &client, &mut armed, @@ -1890,7 +2043,7 @@ async fn run_session(session: Session) { &mut run_active, &mut done_current, &mut generation, - msg, + parked, ) .await { @@ -1907,8 +2060,22 @@ async fn run_session(session: Session) { // messages' ledger entries survive for the // engine's re-dispatch. The counter resets so // the next turn's probe starts a fresh budget. + // + // The mailbox must not wedge behind the dead + // turn (B2): the delivery records clear — no + // later turn's user echo may consume a stale + // one — and the driver-owned messages drain + // below exactly like after a clean settle (the + // next one becomes the next turn), so nothing + // lines up behind the dead turn forever and a + // later mailbox message still gets its turn. probe_failures = 0; + sent_deliveries.clear(); + turn_error = None; done_current = true; + if interrupted { + done_after_interrupt = true; + } if !send( &event_tx, AgentEvent::Done { @@ -1925,6 +2092,25 @@ async fn run_session(session: Session) { { break 'main; } + if interrupted { + break 'main; + } + if let Some(parked) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut sent_deliveries, + &mut delivery_seq, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + parked, + ) + .await + { + break 'main; + } } else { probe_generation = generation; probe = Some(rearm_probe(&client, true)); @@ -1969,7 +2155,14 @@ async fn run_session(session: Session) { // Accepted into pi steer queue: the delivery // record stays — the user message echo // consumes it, in whatever order this - // acknowledgement processes. + // acknowledgement processes. The record keeps + // the message so a strand recovered by + // clear_queue re-queues it intact (B6). + if let Some(record) = + sent_deliveries.iter_mut().find(|r| r.id == delivery) + { + record.message = Some(msg.clone()); + } } Disposition::Handled => { // pi handled it without starting a run: the @@ -2002,7 +2195,12 @@ async fn run_session(session: Session) { // turn). The delivery record retires without a // boundary: pi never consumed this input, so the // engine at-least-once ledger keeps its entry until - // the re-delivery. + // the re-delivery. Past B1's re-delivery cap the + // message is handed back to the engine as a failed + // send (the turn ends errored — never a loop of + // Error + Completed for a message that never ran); + // a rejection landing after the turn already ended + // (a probe failure closed it) goes straight back too. retire_delivery(&mut sent_deliveries, delivery); let _ = send( &event_tx, @@ -2012,38 +2210,57 @@ async fn run_session(session: Session) { }, ) .await; - driver_steers.push_front(msg); let at_rest = !run_active && !saw_activity && probe.is_none() && armed.is_none() && steer_calls.is_empty(); - if at_rest && !retried_at_rest { - // Same one-shot immediate re-delivery budget as a - // rejected parked prompt: bounded by the probe - // re-arm delay if pi keeps rejecting. - retried_at_rest = true; - if let Some(msg) = driver_steers.pop_front() - && !deliver_parked_steer( - &client, - &mut armed, - &mut sent_deliveries, - &mut delivery_seq, - &mut awaiting_prompt_echo, - &mut run_active, - &mut done_current, - &mut generation, - msg, - ) - .await - { - break 'main; + if let Some(attempts) = note_rejection(&mut rejections, &msg) { + if done_current { + // The turn already ended (a probe failure + // closed it): there is no turn left to fail — + // the Error above surfaced the rejection, and + // the engine's ledger still owns the message. + } else { + turn_error = Some(format!( + "pi rejected the message {attempts} times: {e}" + )); + if at_rest { + // Nothing else will settle the turn: the + // probe closes it errored. + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); + } + } + } else { + driver_steers.push_front(msg); + if at_rest && (done_current || !retried_at_rest) { + // Same one-shot immediate re-delivery budget as a + // rejected parked prompt: bounded by the probe + // re-arm delay if pi keeps rejecting. + retried_at_rest = true; + if let Some(parked) = driver_steers.pop_front() + && !deliver_parked_steer( + &client, + &mut armed, + &mut sent_deliveries, + &mut delivery_seq, + &mut awaiting_prompt_echo, + &mut run_active, + &mut done_current, + &mut generation, + parked, + ) + .await + { + break 'main; + } + } else if at_rest && probe.is_none() { + // The immediate budget is spent: the probe ends + // the turn and its Done pops the message back. + probe_generation = generation; + probe = Some(rearm_probe(&client, true)); } - } else if at_rest && probe.is_none() { - // The immediate budget is spent: the probe ends - // the turn and its Done pops the message back. - probe_generation = generation; - probe = Some(rearm_probe(&client, true)); } } }, @@ -2269,10 +2486,22 @@ async fn run_session(session: Session) { // ref so a fork from it continues natively // instead of pasting (spec story 59: // interrupted replies are fork boundaries - // too). Best-effort, as at every settle. - if let Some(reference) = - capture_turn_ref(&client, &session_pointer, &mut ref_cursor) - .await + // too). Best-effort, as at every settle — + // and bounded (B7): the round trip never + // delays the Interrupted Done by more than + // the short budget. + let reference = tokio::time::timeout( + REF_CAPTURE_BUDGET, + capture_turn_ref( + &client, + &session_pointer, + &mut ref_cursor, + ), + ) + .await + .ok() + .flatten(); + if let Some(reference) = reference && !send(&event_tx, AgentEvent::TurnRef { reference }).await { break 'main; @@ -2404,8 +2633,17 @@ async fn run_session(session: Session) { sent_deliveries.push_back(DeliveryRecord { id: delivery, text: msg.prompt.clone(), + message: None, }); - steer_calls.push(steer_request(&client, msg, delivery)); + // Images load BEFORE the call is queued (B5): the + // steer calls run concurrently, and a call that + // loads its images inside the future can be beaten + // to the wire by a later, text-only steer — pi would + // receive the burst out of order. Loading here keeps + // the FuturesUnordered's completion order and the + // wire's send order the same: mailbox order. + let images = load_image_contents(&msg.attachments).await; + steer_calls.push(steer_request(&client, msg, delivery, images)); } else if probe.is_some() || !steer_calls.is_empty() || !driver_steers.is_empty() { // The settle probe or a steer acknowledgement is // still winding down: FIFO behind it. @@ -2446,7 +2684,18 @@ async fn run_session(session: Session) { _ = interrupt.cancelled(), if !interrupt_sent => { interrupt_sent = true; interrupted = true; - if probe.is_some() { + // An armed probe does NOT mean the turn settled (B3): the + // probe also runs while work is live (a handled prompt + // during an extension run, a rejection mid-run). Only a + // probe with nothing else in flight is "already settled" — + // otherwise Stop must clear pi's queue and abort exactly + // like any live turn, or a steer pi never consumed would + // survive into the aborted run. + let settled = !run_active + && !saw_activity + && armed.is_none() + && steer_calls.is_empty(); + if probe.is_some() && settled { // The turn already settled; the probe was the only thing // in flight. Close out as interrupted. let _ = probe.take(); @@ -2464,7 +2713,8 @@ async fn run_session(session: Session) { .await; break 'main; } - let in_flight = run_active || armed.is_some() || saw_activity; + let in_flight = + run_active || armed.is_some() || saw_activity || !steer_calls.is_empty(); if in_flight { // clear_queue FIRST: steers pi accepted but never // consumed must not survive into the abort — they are @@ -2572,13 +2822,19 @@ fn prompt_acceptance( /// while streaming and starts a run when idle). Carries the mailbox /// message so a rejection can hand it back to the queue verbatim, its /// delivery-record id so the acknowledgement (or a rejection) can retire -/// it, and stages the message's attachments as inline images — a steer +/// it, and the message's attachments staged as inline images — a steer /// delivers its images exactly like a turn-start prompt (the path refs -/// ride the text as a fallback either way). -fn steer_request(client: &PiClient, msg: SteerMessage, delivery: u64) -> SteerCall { +/// ride the text as a fallback either way). The images arrive LOADED (the +/// caller loads them before queueing, so a burst's wire order is its +/// mailbox order — see the steering arm). +fn steer_request( + client: &PiClient, + msg: SteerMessage, + delivery: u64, + images: Vec<Value>, +) -> SteerCall { let client = client.clone(); Box::pin(async move { - let images = load_image_contents(&msg.attachments).await; let mut params = json!({ "message": msg.prompt.clone(), "streamingBehavior": "steer", @@ -2641,6 +2897,7 @@ async fn deliver_parked_steer( sent_deliveries.push_back(DeliveryRecord { id: delivery, text: msg.prompt.clone(), + message: None, }); *armed = Some(Armed { acceptance: prompt_acceptance(client, &msg.prompt, images), @@ -2862,14 +3119,14 @@ mod tests { .iter() .map(|id| id.to_string()) .collect(); - // The boundary is the latest turn: Ok(None) — the --fork copy - // already ends there. - assert_eq!(fork_boundary(&users, "u3"), Ok(None)); + // The boundary is the latest turn: the --fork copy already ends + // there, no RPC fork. + assert_eq!(fork_boundary(&users, "u3"), ForkBoundary::NewestTurn); // An earlier boundary names the turn after it. - assert_eq!(fork_boundary(&users, "u2"), Ok(Some("u3".to_owned()))); - assert_eq!(fork_boundary(&users, "u1"), Ok(Some("u2".to_owned()))); + assert_eq!(fork_boundary(&users, "u2"), ForkBoundary::RootAt("u3".to_owned())); + assert_eq!(fork_boundary(&users, "u1"), ForkBoundary::RootAt("u2".to_owned())); // A stale ref refuses rather than forking somewhere wrong. - assert_eq!(fork_boundary(&users, "gone"), Err(())); + assert_eq!(fork_boundary(&users, "gone"), ForkBoundary::OffBranch); } /// No settings anywhere: the driver selects the mode itself. diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index 29d63d7864..d87c0e3a12 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -316,6 +316,95 @@ async fn a_fork_the_cli_refuses_falls_back_to_a_fresh_session_with_the_budgeted_ ); } +/// A fork the CLI accepts and then FAILS (B11): a `--resume-session-at` +/// uuid it validates only once the first prompt arrives — the CLI answers +/// initialize, prints its init frame, and errors before any assistant +/// output. That errored first result is the same refusal as the silent +/// exit: the driver retries once as a fresh session with the budgeted +/// transcript pasted in front of the prompt, and the send never fails. +#[tokio::test] +async fn a_fork_that_errors_after_launch_retries_fresh_with_the_budget() { + let dir = tempfile::tempdir().unwrap(); + claude_session_store(dir.path(), "fork-bad-uuid-src", &["msg-uuid-7", "msg-newer-uuid"]); + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:fork-bad-uuid"); + req.provider_fork = Some(roboco_proto::ProviderFork { + session_id: "fork-bad-uuid-src".into(), + last_turn: "msg-uuid-7".into(), + fallback_history: Some(FORK_FALLBACK_HISTORY.into()), + }); + let events = run_to_end(&harness, req, controls).await; + + // The fallback session served the turn (the fake fails the retry + // unless the prompt carries the budgeted history AND the user message, + // with no session flags at all). + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "sess-fork-bad-fresh" + )), + "the late-refused fork fell back to a fresh session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "bad uuid fallback answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-fork-bad-fresh".into()), + }) + ); +} + +/// A resume the CLI accepts at launch and then REFUSES once the turn runs +/// (B12 — the account-failover continuation's cross-account resume, the +/// research-03 risk): the errored first result, before any assistant +/// output, retries fresh with the fallback history. The continuation never +/// fails the send. +#[tokio::test] +async fn a_resume_refused_after_launch_retries_fresh_with_the_fallback_history() { + let dir = tempfile::tempdir().unwrap(); + // The session exists in the store, so the pre-launch check passes — + // only the running turn reveals the refusal. + claude_session_store(dir.path(), "resume-refused-src", &["msg-uuid-7"]); + let harness = ClaudeHarness::new() + .with_executable(fixture_path()) + .with_config_root(dir.path()); + let (controls, _steer, _token) = controls("A"); + let mut req = request("scenario:resume-refused"); + req.resume = Some("resume-refused-src".into()); + req.resume_fallback_history = Some(FORK_FALLBACK_HISTORY.into()); + let events = run_to_end(&harness, req, controls).await; + + assert!( + events.iter().any(|e| matches!( + e, + AgentEvent::SessionStarted { session_id, .. } if session_id == "sess-resume-fresh" + )), + "the refused resume fell back to a fresh session: {events:?}" + ); + assert!(events.iter().any(|e| matches!( + e, + AgentEvent::TextDelta { text } if text == "resume fallback answer" + ))); + assert_eq!( + events.last(), + Some(&AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-resume-fresh".into()), + }) + ); +} + /// A source session the CLI's store no longer has: the fork launch is /// skipped outright — no session flags — and the budgeted transcript rides /// in front of the prompt on the fresh session. diff --git a/crates/harness/tests/codex.rs b/crates/harness/tests/codex.rs index 520bd1865a..824a9731a3 100644 --- a/crates/harness/tests/codex.rs +++ b/crates/harness/tests/codex.rs @@ -752,7 +752,7 @@ async fn a_usage_limited_turn_carries_the_reset_from_the_latest_notification() { "You have exceeded your usage limit".to_owned(), Some(AgentErrorKind::UsageLimit { window: "5-hour".into(), - resets_at: Some(1_735_689_600_000), + resets_at: Some(4_102_444_800_000), }), ) ], @@ -791,7 +791,7 @@ async fn a_rate_limited_turn_completing_with_the_error_also_classifies() { "rate limited, retry later".to_owned(), Some(AgentErrorKind::UsageLimit { window: "weekly".into(), - resets_at: Some(1_736_208_000_000), + resets_at: Some(4_102_444_801_000), }), ) ], diff --git a/crates/harness/tests/fixtures/fake-claude.sh b/crates/harness/tests/fixtures/fake-claude.sh index 262340ead1..ca02ee511b 100755 --- a/crates/harness/tests/fixtures/fake-claude.sh +++ b/crates/harness/tests/fixtures/fake-claude.sh @@ -22,6 +22,7 @@ for arg in "$@"; do --resume=fork-latest-src) fork_src=fork-latest-src ;; --resume=fork-earlier-src) fork_src=fork-earlier-src ;; --resume=fork-refused-src) fork_src=fork-refused-src ;; + --resume=fork-bad-uuid-src) fork_src=fork-bad-uuid-src ;; esac done if [ -n "$fork_src" ]; then @@ -36,6 +37,14 @@ if [ -n "$fork_src" ]; then echo "error: unknown option '--resume-session-at'" >&2 exit 2 ;; + fork-bad-uuid-src) + # The launch carries the boundary uuid the CLI validates only once + # the first prompt arrives (the scenario arm below errors then). + case " $* " in + *"--resume-session-at=msg-uuid-7"*) ;; + *) echo "bad-uuid launch missing resume-session-at: $*" >&2; exit 26 ;; + esac + ;; esac fi @@ -142,6 +151,58 @@ case "$first" in emit '{"type":"result","subtype":"success","result":"fresh fallback","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-fresh"}' ;; +*scenario:fork-bad-uuid*) + # B11: the CLI ACCEPTS the fork flags (it answers initialize and prints + # its init frame) and then fails the resumed session when the first + # prompt arrives — a --resume-session-at uuid it validates late. The + # errored first result, before any assistant output, is the same + # refusal as the silent exit: the driver retries fresh with the + # budgeted history, no session flags at all. + if [ -n "$fork_src" ]; then + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-bad-uuid-new"}' + emit '{"type":"result","subtype":"error_during_execution","result":"invalid resume point: msg-uuid-7","errors":["invalid resume point"],"is_error":true,"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-fork-bad-uuid-new"}' + else + for arg in "$@"; do + case "$arg" in + --resume=*|--fork-session|--resume-session-at=*) echo "bad-uuid fallback still carries session flags: $arg" >&2; exit 27 ;; + esac + done + for want in 'PINEAPPLE' 'scenario:fork-bad-uuid'; do + case "$first" in *"$want"*) ;; *) echo "bad-uuid fallback prompt missing: $want" >&2; exit 27 ;; esac + done + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-fork-bad-fresh"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"bad uuid fallback answer"}}}' + emit '{"type":"assistant","uuid":"msg-fork-bad-fresh","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"bad uuid fallback answer"}]}}' + emit '{"type":"result","subtype":"success","result":"bad uuid fallback","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-fork-bad-fresh"}' + fi + ;; + +*scenario:resume-refused*) + # B12: a resume the CLI accepts at launch and then REFUSES once the turn + # runs (research 03: a cross-account resume the login can no longer + # read). The errored first result, before any assistant output, refuses + # the launch: the driver retries fresh with the fallback history — a + # failover continuation never fails the send. + has_resume=false + for arg in "$@"; do + case "$arg" in + --resume=*) has_resume=true ;; + esac + done + if $has_resume; then + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-resume-refused"}' + emit '{"type":"result","subtype":"error_during_execution","result":"session unreadable under this login","errors":["session unreadable"],"is_error":true,"usage":{"input_tokens":0,"output_tokens":0},"session_id":"sess-resume-refused"}' + else + for want in 'PINEAPPLE' 'scenario:resume-refused'; do + case "$first" in *"$want"*) ;; *) echo "resume-refused fallback prompt missing: $want" >&2; exit 28 ;; esac + done + emit '{"type":"system","subtype":"init","model":"claude-fable-5","tools":[],"cwd":"/tmp","session_id":"sess-resume-fresh"}' + emit '{"type":"stream_event","parent_tool_use_id":null,"event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"resume fallback answer"}}}' + emit '{"type":"assistant","uuid":"msg-resume-fresh","parent_tool_use_id":null,"message":{"content":[{"type":"text","text":"resume fallback answer"}]}}' + emit '{"type":"result","subtype":"success","result":"resume fallback","errors":[],"usage":{"input_tokens":1,"output_tokens":1},"session_id":"sess-resume-fresh"}' + fi + ;; + *scenario:fork-missing*) # The source session's file is gone from the CLI's store: the driver # skips the fork launch outright — no session flags, the budgeted history diff --git a/crates/harness/tests/fixtures/fake-codex.sh b/crates/harness/tests/fixtures/fake-codex.sh index 9d977240f7..1a8ced0cb1 100755 --- a/crates/harness/tests/fixtures/fake-codex.sh +++ b/crates/harness/tests/fixtures/fake-codex.sh @@ -577,7 +577,7 @@ case "$turnline" in # the 5-hour window is exhausted, the weekly one is not. emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' - emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"primary":{"usedPercent":100,"windowDurationMins":300,"resetsAt":1735689600},"secondary":{"usedPercent":61,"windowDurationMins":10080,"resetsAt":1736208000}}}}' + emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"primary":{"usedPercent":100,"windowDurationMins":300,"resetsAt":4102444800},"secondary":{"usedPercent":61,"windowDurationMins":10080,"resetsAt":4102444801}}}}' emit '{"method":"turn/failed","params":{"turn":{"id":"t-1","error":{"message":"You have exceeded your usage limit","codexErrorInfo":"usageLimitExceeded"}}}}' ;; @@ -586,7 +586,7 @@ case "$turnline" in # weekly window as the exhausted one and no primary window reported. emit "{\"id\":$tid,\"result\":{\"turn\":{\"id\":\"t-1\"}}}" emit '{"method":"turn/started","params":{"turn":{"id":"t-1"}}}' - emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"secondary":{"usedPercent":100,"windowDurationMins":10080,"resetsAt":1736208000}}}}' + emit '{"method":"account/rateLimits/updated","params":{"rateLimits":{"secondary":{"usedPercent":100,"windowDurationMins":10080,"resetsAt":4102444801}}}}' emit '{"method":"turn/completed","params":{"turn":{"id":"t-1","status":"failed","error":{"message":"rate limited, retry later","codexErrorInfo":"rateLimitExceeded"}}}}' ;; diff --git a/crates/harness/tests/fixtures/fake-pi-rpc.sh b/crates/harness/tests/fixtures/fake-pi-rpc.sh index 99e68c1648..683c73d555 100755 --- a/crates/harness/tests/fixtures/fake-pi-rpc.sh +++ b/crates/harness/tests/fixtures/fake-pi-rpc.sh @@ -276,6 +276,35 @@ def run_prompt(req): user_message(text) turn_and_settle("reply:" + text) return + if text == "old-pi-stop": + # B3: pi < 0.99 (no disposition) arms the settle probe while the run + # is still live. A Stop then must NOT take the already-settled + # shortcut: it clears the queue and aborts like any live turn. The + # abort is recorded in the session file so the test can see it. + streaming = True + response(req) + user_message(text) + emit({"type": "agent_start"}) + emit({"type": "turn_start"}) + assistant_chunks(["working"]) + while True: + command = read_command() + kind = command["type"] + if kind == "get_state": + response(command, state_data()) + elif kind == "get_session_stats": + response(command, stats_data()) + elif kind == "clear_queue": + response(command, {"steering": [], "followUp": []}) + elif kind == "abort": + with open(session_file, "a") as handle: + handle.write("abort\n") + streaming = False + emit({"type": "agent_settled"}) + response(command) + return + else: + reject(command, "unexpected old-pi-stop command: " + kind) if text == "slow-preflight": # pi preflight (input handlers, before_agent_start hooks, the # Roboco MCP bridge, compaction) runs longer than any fixed @@ -628,6 +657,190 @@ def run_prompt(req): else: reject(command, "unexpected command: " + command["type"]) return + if text == "late-ack": + # B4: a steer races the settle. The run settles while the steer is + # still in preflight (its acknowledgement has not landed), and the + # settle probe reads pi at REST. The driver must not end the turn + # on that quiescent reading while an input awaits its + # acknowledgement: the turn ends only after the ack lands, the + # acked steer runs, and ITS settle probes clean. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["working"]) + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + # Still in preflight: not in the queue yet (get_state reports no + # pending message), and the run settles with it unacknowledged. + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + emit({"type": "agent_end", "messages": [], "willRetry": False}) + emit({"type": "agent_settled"}) + responded = False + while True: + command = read_command() + kind = command["type"] + if kind == "get_state": + response(command, state_data()) + elif kind == "get_session_stats": + response(command, stats_data()) + if not responded: + # The acknowledgement lands late, after the settle + # probe already read pi at rest. + responded = True + response(steer, {"disposition": "started"}) + user_message(steer["message"]) + turn_and_settle("reply-to-late-steer") + elif kind == "get_entries": + data = entries_data(command) + assert data is not None, command + response(command, data) + elif kind == "clear_queue": + drained = {"steering": list(steering_queue), "followUp": []} + del steering_queue[:] + response(command, drained) + else: + reject(command, "unexpected late-ack command: " + kind) + return + if text == "steer-burst-mixed": + # B5: a burst of steers where the MIDDLE one carries images: the + # driver must deliver the burst in mailbox order — the image steer + # cannot be overtaken on the wire by the text steer queued after + # it, so pi receives exactly [text, image, text]. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["first part "]) + steers = [] + for wants_image in (False, True, False): + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + if wants_image: + images = steer.get("images") + assert isinstance(images, list) and len(images) == 1, steer + expected = b"\x89PNG\r\n\x1a\n" + b"roboco burst image" + assert base64.b64decode(images[0]["data"]) == expected, images + else: + assert "images" not in steer, steer + steers.append(steer["message"]) + steering_queue.append(steer["message"]) + emit({"type": "queue_update", "steering": list(steering_queue), "followUp": []}) + response(steer, {"disposition": "queued"}) + assert steers == ["mixed one", "mixed two", "mixed three"], steers + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + del steering_queue[:] + emit({"type": "queue_update", "steering": [], "followUp": []}) + for message in steers: + user_message(message) + turn_and_settle("reply-to-mixed-burst") + return + if text == "reject-cap": + # B1: pi rejects the parked message on every delivery. The driver + # caps the re-delivery after the retries: the message is handed + # back to the engine as a failed send and the turn ends ERRORED — + # never an endless loop of Error + Completed for a message that + # never ran. + user_message(text) + turn_and_settle("reply:" + text) + # Close the prompted turn fully (probe + stats + refs) before the + # parked message arrives, the way reject-while-streaming does. + probe = read_command() + assert probe["type"] == "get_state", probe + response(probe, state_data()) + stats = read_command() + assert stats["type"] == "get_session_stats", stats + response(stats, stats_data()) + refs = read_command() + assert refs["type"] == "get_entries", refs + response(refs, entries_data(refs)) + while True: + command = read_command() + kind = command["type"] + if kind == "prompt": + assert command.get("streamingBehavior") == "steer", command + reject(command, "A prompt is already running (busy)") + elif kind == "get_state": + response(command, state_data()) + elif kind == "get_session_stats": + response(command, stats_data()) + elif kind == "get_entries": + data = entries_data(command) + assert data is not None, command + response(command, data) + else: + reject(command, "unexpected reject-cap command: " + kind) + if text == "probe-fail-mailbox": + # B2: a message lands while the settle probe is in flight (the + # first answer is held open), so the driver queues it behind the + # turn; the probe then fails out and the turn ends errored. The + # queued message must still run as the next turn: the mailbox + # never wedges behind the dead turn. + user_message(text) + turn_and_settle("reply:" + text) + for attempt in range(3): + probe = read_command() + assert probe["type"] == "get_state", probe + if attempt == 0: + time.sleep(1.0) + reject(probe, "wedged") + while True: + command = read_command() + kind = command["type"] + if kind == "prompt": + assert command.get("streamingBehavior") == "steer", command + response(command, {"disposition": "started"}) + user_message(command["message"]) + turn_and_settle("reply:" + command["message"]) + elif kind == "get_state": + response(command, state_data()) + elif kind == "get_session_stats": + response(command, stats_data()) + elif kind == "get_entries": + data = entries_data(command) + assert data is not None, command + response(command, data) + else: + reject(command, "unexpected probe-fail-mailbox command: " + kind) + if text == "steer-strand-image": + # B6: a steer carrying images strands in pi queue at settle. The + # clear_queue recovery must re-queue the ORIGINAL message — the + # re-delivered prompt carries its images again, never bare text. + user_message(text) + emit({"type": "turn_start"}) + assistant_chunks(["reply part"]) + steer = read_command() + assert steer["type"] == "prompt" and steer.get("streamingBehavior") == "steer", steer + images = steer.get("images") + assert isinstance(images, list) and len(images) == 1, steer + steering_queue.append(steer["message"]) + response(steer, {"disposition": "queued"}) + emit({"type": "turn_end", "message": {"role": "assistant", "content": [], "stopReason": "stop"}, "toolResults": []}) + emit({"type": "agent_end", "messages": [], "willRetry": False}) + emit({"type": "agent_settled"}) + expected = b"\x89PNG\r\n\x1a\n" + b"roboco strand image" + while True: + command = read_command() + kind = command["type"] + if kind == "get_state": + response(command, state_data()) + elif kind == "clear_queue": + drained = {"steering": list(steering_queue), "followUp": []} + del steering_queue[:] + response(command, drained) + elif kind == "get_session_stats": + response(command, stats_data()) + elif kind == "get_entries": + data = entries_data(command) + assert data is not None, command + response(command, data) + elif kind == "prompt": + # The recovered message re-delivers with its images intact. + recovered = command.get("images") + assert isinstance(recovered, list) and len(recovered) == 1, command + assert base64.b64decode(recovered[0]["data"]) == expected, recovered + response(command, {"disposition": "started"}) + user_message(command["message"]) + turn_and_settle("reply-to-recovered-image-steer") + return + else: + reject(command, "unexpected steer-strand-image command: " + kind) if text == "settle-race": # The settle probe is in flight when the user message arrives: the # driver must queue it behind the turn (or join the run) — never diff --git a/crates/harness/tests/pi.rs b/crates/harness/tests/pi.rs index 51178fd324..2da1dae5cb 100644 --- a/crates/harness/tests/pi.rs +++ b/crates/harness/tests/pi.rs @@ -1373,6 +1373,256 @@ async fn a_compaction_recovered_run_completes() { ); } +/// A burst mixing an image steer stays in mailbox order (B5): the driver +/// loads a steer's images before queueing its wire call, so the +/// FuturesUnordered's completion order cannot overtake the image steer +/// with the text steer queued after it — pi receives exactly +/// [text, image, text]. The fixture asserts the wire order and the image +/// block itself; here the burst folds in with its boundaries. +#[tokio::test] +async fn a_burst_with_an_image_steer_stays_in_order() { + let image = tempfile::tempdir().unwrap(); + let png = image.path().join("burst.png"); + std::fs::write(&png, b"\x89PNG\r\n\x1a\nroboco burst image").unwrap(); + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("steer-burst-mixed"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "first part ") + && !sent; + events.push(event); + if steer_now { + sent = true; + for (text, with_image) in [ + ("mixed one", None), + ("mixed two", Some(png.display().to_string())), + ("mixed three", None), + ] { + steer + .send(SteerMessage { + prompt: text.into(), + message_id: None, + attachments: with_image.map(|path| vec![path]).unwrap_or_default(), + config: None, + }) + .await + .unwrap(); + } + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + let steered = events + .iter() + .filter(|e| matches!(e, AgentEvent::Steered { .. })) + .count(); + assert_eq!(steered, 3, "{events:?}"); + assert!(text_of(&events).contains("reply-to-mixed-burst"), "{events:?}"); +} + +/// A steer whose acknowledgement lands AFTER the settle does not end the +/// turn early (B4): the quiescent probe reads pi at rest while the input +/// is still in preflight, and the driver must wait it out — the turn ends +/// once, after the acked steer runs and settles in its own turn. +#[tokio::test] +async fn a_late_acknowledgement_does_not_end_the_turn_early() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("late-ack"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while let Some(event) = stream.next().await { + let event = event.unwrap(); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "working") + && !sent; + events.push(event); + if steer_now { + sent = true; + steer.send(SteerMessage::text("late steer")).await.unwrap(); + } + if matches!(events.last(), Some(AgentEvent::Done { .. })) { + break; + } + } + // Exactly one Done, and only after the late steer ran: the reply to + // the steer is in the SAME turn (no Done between the settle and the + // ack), and the turn completes. + assert_eq!(dones(&events), vec![(DoneStatus::Completed, None)], "{events:?}"); + assert!(text_of(&events).contains("reply-to-late-steer"), "{events:?}"); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Steered { .. })), "{events:?}"); +} + +/// A message pi keeps rejecting is capped, not retried forever (B1): past +/// the re-delivery attempts the message is handed back to the engine as a +/// failed send and the turn ends ERRORED — one Error naming the attempts, +/// and NEVER a Done{Completed} for a message that never ran. +#[tokio::test] +async fn an_endlessly_rejected_message_is_capped_and_ends_the_turn_errored() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("reject-cap"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while dones(&events).len() < 2 { + let event = tokio::time::timeout(Duration::from_secs(20), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + // The prompted turn completes and parks; the parked message goes + // out after the first Done. + let send_now = + matches!(&event, AgentEvent::Done { status: DoneStatus::Completed, .. }) && !sent; + events.push(event); + if send_now { + sent = true; + steer.send(SteerMessage::text("always rejected")).await.unwrap(); + } + } + drop(steer); + let dones = dones(&events); + assert_eq!(dones.len(), 2, "{events:?}"); + assert_eq!(dones[0], (DoneStatus::Completed, None), "{events:?}"); + match &dones[1] { + (DoneStatus::Errored, Some(error)) => assert!( + error.contains("pi rejected the message 2 times"), + "the cap ends the turn errored with the attempt count: {error}" + ), + other => panic!("the capped rejection ends the turn errored: {other:?}"), + } + // One rejection Error per attempt — and no third attempt (the cap). + let rejections = events + .iter() + .filter(|e| matches!(e, AgentEvent::Error { message, .. } if message.contains("rejected"))) + .count(); + assert_eq!(rejections, 2, "{events:?}"); +} + +/// The mailbox never wedges behind a dead turn (B2): a message queued +/// behind the settle probe still runs as the next turn after the probe +/// fails out and the turn ends errored. +#[tokio::test] +async fn a_message_queued_behind_a_failing_probe_still_runs() { + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("probe-fail-mailbox"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while dones(&events).len() < 2 { + let event = tokio::time::timeout(Duration::from_secs(20), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let send_now = matches!( + &event, + AgentEvent::TextDelta { text } if text == "reply:probe-fail-mailbox" + ) && !sent; + events.push(event); + if send_now { + // The fixture holds the first probe answer open: the message + // lands behind the settling turn. + sent = true; + tokio::time::sleep(Duration::from_millis(300)).await; + steer.send(SteerMessage::text("queued during the probe")).await.unwrap(); + } + } + drop(steer); + let dones = dones(&events); + assert_eq!( + dones.len(), + 2, + "the failed turn, then the queued message's own turn: {dones:?}" + ); + assert_eq!(dones[0].0, DoneStatus::Errored); + assert_eq!(dones[1].0, DoneStatus::Completed, "{events:?}"); + assert!( + text_of(&events).contains("reply:queued during the probe"), + "the queued message ran its own turn: {events:?}" + ); +} + +/// A stranded steer is recovered INTACT (B6): the images it was sent with +/// ride the re-delivery too — the recovery re-queues the original +/// message, never a bare-text copy. The fixture asserts the re-delivered +/// prompt's image block on the wire. +#[tokio::test] +async fn a_stranded_steer_recovers_with_its_images() { + let image = tempfile::tempdir().unwrap(); + let png = image.path().join("strand.png"); + std::fs::write(&png, b"\x89PNG\r\n\x1a\nroboco strand image").unwrap(); + let (controls, steer, _token) = controls(); + let mut stream = harness().run(request("steer-strand-image"), controls).await.unwrap(); + let mut events = Vec::new(); + let mut sent = false; + while dones(&events).len() < 2 { + let event = tokio::time::timeout(Duration::from_secs(15), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let steer_now = matches!(&event, AgentEvent::TextDelta { text } if text == "reply part") + && !sent; + events.push(event); + if steer_now { + sent = true; + steer + .send(SteerMessage { + prompt: "stranded with an image".into(), + message_id: None, + attachments: vec![png.display().to_string()], + config: None, + }) + .await + .unwrap(); + } + } + // Recovered via clear_queue after the first Done, re-delivered as the + // next turn's prompt with its image (the fixture checks the wire). + assert_eq!( + dones(&events), + vec![(DoneStatus::Completed, None), (DoneStatus::Completed, None)], + "{events:?}" + ); + assert!( + text_of(&events).contains("reply-to-recovered-image-steer"), + "the recovered steer ran its own turn: {events:?}" + ); + assert!(events.iter().any(|e| matches!(e, AgentEvent::Steered { .. })), "{events:?}"); +} + +/// A Stop while the settle probe is armed during a LIVE run (B3: an old pi +/// with no disposition arms it at acceptance) still clears the queue and +/// aborts — the "already settled" shortcut is only for a truly settled +/// turn. The fixture records the abort in its session file. +#[tokio::test] +async fn stop_with_a_probe_armed_mid_run_still_aborts() { + let cwd = tempfile::tempdir().unwrap(); + let (controls, _steer, token) = controls(); + let req = request_cwd("old-pi-stop", &cwd.path().display().to_string()); + let mut stream = harness().run(req, controls).await.unwrap(); + let mut events = Vec::new(); + while dones(&events).is_empty() { + let event = tokio::time::timeout(Duration::from_secs(15), stream.next()) + .await + .expect("progress") + .expect("stream open") + .expect("stream event"); + let stop_now = matches!(&event, AgentEvent::TextDelta { text } if text == "working"); + events.push(event); + if stop_now { + // Past the probe's re-arm delay: it is armed (pi reports busy). + tokio::time::sleep(Duration::from_millis(400)).await; + token.cancel(); + } + } + assert_eq!(dones(&events), vec![(DoneStatus::Interrupted, None)], "{events:?}"); + let recorded = + std::fs::read_to_string(cwd.path().join(".pi-fixture-session.jsonl")).unwrap(); + assert!(recorded.contains("abort\n"), "Stop must abort the live run: {recorded}"); +} + /// Several quick steers are all delivered, in order: pi (steering mode /// "all") queues each while streaming and delivers them together at the /// next step boundary. The fixture asserts the send order on the wire; From 90dee663e39f901dd1f538ccb09dc9777607df27 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:51:54 +0700 Subject: [PATCH 80/96] fix(engine): notice details cap by characters; the notices mock streams its channel --- crates/engine/src/completion_notices.rs | 8 +++----- crates/engine/tests/completion_notices.rs | 7 +++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 206cebd849..3da3c14dbf 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -515,11 +515,9 @@ pub(crate) fn one_line(text: Option<&str>) -> Option<String> { if line.chars().count() <= NOTICE_DETAIL_MAX { return Some(line.to_owned()); } - let mut cut = NOTICE_DETAIL_MAX; - while cut > 0 && !line.is_char_boundary(cut) { - cut -= 1; - } - Some(format!("{}…", &line[..cut])) + // Cap by characters, not bytes: multibyte text keeps the full budget. + let cut: String = line.chars().take(NOTICE_DETAIL_MAX).collect(); + Some(format!("{cut}…")) } /// The one-line summary of the question or approval a side chat is waiting diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 65567d6f8c..09b451a5bc 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -136,7 +136,7 @@ impl Harness for Scripted { .pop_front() .unwrap_or(Turn::Hang); let session = format!("sess-{}", self.run_count()); - let (tx, mut rx) = tokio::sync::mpsc::channel::<Result<AgentEvent, HarnessError>>(32); + let (tx, rx) = tokio::sync::mpsc::channel::<Result<AgentEvent, HarnessError>>(32); let steered = self.steered.clone(); let mut steering = controls.steering; let request_input = controls.request_input; @@ -242,7 +242,10 @@ impl Harness for Scripted { }, } }); - Ok(rx.boxed()) + Ok(futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|event| (event, rx)) + }) + .boxed()) } } From c7d8872c99215396a4dc228b838f0945592d724c Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:55:46 +0700 Subject: [PATCH 81/96] fix(engine): the scheduled-resume reload test arms resets in the future --- crates/engine/src/scheduled_resumes.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 289fe6d107..f7d2733a64 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -302,22 +302,25 @@ mod tests { #[test] fn entries_and_fired_floors_survive_a_store_reload() { + // `advances` also guards against the present, so the resets sit in + // the future. + let base = now_ms() + 3_600_000; let path = temp_path(); let first = ScheduledResumes::default(); first.set_path(path.clone()); - first.arm("chat-live", 5_000); - first.arm("chat-done", 6_000); - first.consume("chat-done", 6_000); - first.note_fired("chat-done", 6_000); + first.arm("chat-live", base); + first.arm("chat-done", base + 1_000); + first.consume("chat-done", base + 1_000); + first.note_fired("chat-done", base + 1_000); let reloaded = ScheduledResumes::default(); reloaded.set_path(path); - assert_eq!(reloaded.scheduled("chat-live").unwrap().fire_at, 5_000); + assert_eq!(reloaded.scheduled("chat-live").unwrap().fire_at, base); assert!(reloaded.scheduled("chat-done").is_none()); assert_eq!(reloaded.pending().len(), 1); // The fired floor survives the restart. - assert!(reloaded.advances("chat-done", 7_000)); - assert!(!reloaded.advances("chat-done", 6_000)); + assert!(reloaded.advances("chat-done", base + 2_000)); + assert!(!reloaded.advances("chat-done", base + 1_000)); // A reset in the past never advances. assert!(!reloaded.advances("chat-live", 1)); } From 4239f5a189281fe4b039266e699593a8468416e8 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:57:33 +0700 Subject: [PATCH 82/96] =?UTF-8?q?refactor:=20code-review=20section=20D=20?= =?UTF-8?q?=E2=80=94=20shared=20notice=20verbs=20and=20side-chat=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NoticeOutcome::verb (crates/doc) is the one outcome vocabulary: the engine's notice text and the desktop notice card both read it (the card's "stopped"/"interrupted"/"waiting for input" drifted from the engine's "was stopped"/...); the web mirror follows. - roboco_proto::SIDE_CHAT_MESSAGE_HEADER_TAIL: the notifying side-chat message header's tail, shared by the MCP server that writes it and the transcript that folds it (the MCP use lands with group C). - agent_accounts: failover_candidates' doc moved off failover_signed_out; rank_failover_candidates' doc says the org penalty is the primary key. Tests updated (not run; CI): desktop + web notice verb tables. No wire changes. --- crates/doc/src/parts.rs | 16 ++++++++++++ crates/engine/src/agent_accounts.rs | 25 ++++++++++--------- crates/engine/src/completion_notices.rs | 10 ++++---- crates/proto/src/agent.rs | 6 +++++ crates/ui/src/transcript.rs | 24 ++++++------------ web/packages/app/src/lib/transcript.ts | 10 ++++---- .../transcript-completion-notice.test.ts | 6 ++--- 7 files changed, 56 insertions(+), 41 deletions(-) diff --git a/crates/doc/src/parts.rs b/crates/doc/src/parts.rs index 83076064a1..98fcab99dc 100644 --- a/crates/doc/src/parts.rs +++ b/crates/doc/src/parts.rs @@ -135,6 +135,22 @@ pub enum NoticeOutcome { WaitingForInput, } +impl NoticeOutcome { + /// The outcome's verb phrase — the ONE vocabulary the engine's notice + /// text (`"title" (id) <verb>`) and every client's notice card share, so + /// the card and the prose an agent reads never drift (the web mirrors + /// it in `noticeOutcomeVerb`). + pub fn verb(self) -> &'static str { + match self { + Self::Completed => "finished", + Self::Errored => "failed", + Self::Stopped => "was stopped", + Self::Interrupted => "was interrupted", + Self::WaitingForInput => "is waiting for input", + } + } +} + /// One side chat a completion notice reports: its id and title, the /// recorded outcome, and the one-line reason (error text, restart reason or /// question summary) when the outcome carries one. diff --git a/crates/engine/src/agent_accounts.rs b/crates/engine/src/agent_accounts.rs index be19be8bd2..762f3d746c 100644 --- a/crates/engine/src/agent_accounts.rs +++ b/crates/engine/src/agent_accounts.rs @@ -1002,13 +1002,6 @@ impl AgentAccounts { .find(|account| account.harness == harness && account.active) } - /// The ranked failover candidates for `harness` (orchestration v2, - /// ticket 09) — the CACHED rows, no network: the forced probe is the - /// engine's call on the CHOSEN candidate only ([`Self::probe_account`]). - /// `limited_account_id` names the account the chat's limit hit (the - /// limit record's account, else the harness's active login when the - /// record carried none). See [`rank_failover_candidates`] for the - /// exclusion and ranking rules. /// The harness's OTHER accounts that are signed out (ticket 09's /// picker): never switch targets — the ranking excludes them — but the /// picker lists them with a "Sign in again" action (the existing login @@ -1032,6 +1025,13 @@ impl AgentAccounts { .unwrap_or_default() } + /// The ranked failover candidates for `harness` (orchestration v2, + /// ticket 09) — the CACHED rows, no network: the forced probe is the + /// engine's call on the CHOSEN candidate only ([`Self::probe_account`]). + /// `limited_account_id` names the account the chat's limit hit (the + /// limit record's account, else the harness's active login when the + /// record carried none). See [`rank_failover_candidates`] for the + /// exclusion and ranking rules. pub async fn failover_candidates( &self, harness: HarnessId, @@ -3894,11 +3894,12 @@ fn failover_group( /// instead); /// - accounts still limited (`limited_until` in the future). /// -/// **Ranked:** lowest usage first — the account's most-used window -/// (`0.0` when the meters are unknown); among equals, accounts OUTSIDE the -/// limited account's org/workspace rank first (a shared pool may hit the -/// same wall again). The pre-rank order (slot creation order) breaks the -/// remaining ties so the ranking is deterministic. +/// **Ranked:** accounts OUTSIDE the limited account's org/workspace first +/// (a shared pool may hit the same wall again) — the org penalty is the +/// PRIMARY key, outranking usage; then lowest usage first — the account's +/// most-used window (`0.0` when the meters are unknown). The pre-rank order +/// (slot creation order) breaks the remaining ties so the ranking is +/// deterministic. fn rank_failover_candidates( candidates: Vec<FailoverCandidate>, limited_id: Option<&str>, diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 01b83ff037..130f1c884b 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -995,12 +995,12 @@ fn read_line(sides: &[NoticeSide]) -> String { /// `"title" (short id) outcome` — the one-line form for one side chat. fn side_line(side: &NoticeSide) -> String { + let verb = side.outcome.verb(); let outcome = match side.outcome { - NoticeOutcome::Completed => "finished".into(), - NoticeOutcome::Errored => with_detail(side, "failed"), - NoticeOutcome::Stopped => "was stopped".into(), - NoticeOutcome::Interrupted => with_detail(side, "was interrupted"), - NoticeOutcome::WaitingForInput => with_detail(side, "is waiting for input"), + NoticeOutcome::Completed | NoticeOutcome::Stopped => verb.into(), + NoticeOutcome::Errored + | NoticeOutcome::Interrupted + | NoticeOutcome::WaitingForInput => with_detail(side, verb), }; format!("\"{}\" ({}) {}", side.title, short_id(&side.chat_id), outcome) } diff --git a/crates/proto/src/agent.rs b/crates/proto/src/agent.rs index e1ea603350..b7ba2e960c 100644 --- a/crates/proto/src/agent.rs +++ b/crates/proto/src/agent.rs @@ -35,6 +35,12 @@ pub enum HarnessId { /// the engine's scheduled resumes join it in later tickets). pub const RESUME_CONTINUE_PROMPT: &str = "Continue where you left off."; +/// The tail of the header the Roboco MCP server puts on a parent's message +/// to its notifying side chat (orchestration v2): `[Message from Roboco +/// chat <label><tail>]`. One spelling for the server that writes it and the +/// transcript that folds it into a concise attribution. +pub const SIDE_CHAT_MESSAGE_HEADER_TAIL: &str = ". You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`."; + /// Durable user preference for one agent's independently-installed CLI. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)] #[serde(rename_all = "kebab-case")] diff --git a/crates/ui/src/transcript.rs b/crates/ui/src/transcript.rs index e341062350..53f3299f44 100644 --- a/crates/ui/src/transcript.rs +++ b/crates/ui/src/transcript.rs @@ -8596,9 +8596,7 @@ fn agent_message_display(text: &str) -> String { { let suffix = format!(" ({id})"); label.strip_suffix(&suffix).unwrap_or(label) - } else if let Some(label) = header.strip_suffix( - ". You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.", - ) { + } else if let Some(label) = header.strip_suffix(roboco_proto::SIDE_CHAT_MESSAGE_HEADER_TAIL) { // A parent's message to its notifying side chat (orchestration // v2): the header teaches the notice flow instead of inviting a // reply, and carries no separate id — the label holds it. @@ -8806,17 +8804,11 @@ fn short_chat_id(id: &str) -> &str { &id[..cut] } -/// The outcome verb a notice side row shows — the same vocabulary the -/// notice's plain text uses (`side_line` in the engine), so the card and -/// the prose an agent reads agree. +/// The outcome verb a notice side row shows — the ONE shared vocabulary +/// ([`NoticeOutcome::verb`]) the engine's notice text uses too, so the card +/// and the prose an agent reads agree. fn notice_outcome_verb(outcome: NoticeOutcome) -> &'static str { - match outcome { - NoticeOutcome::Completed => "finished", - NoticeOutcome::Errored => "failed", - NoticeOutcome::Stopped => "stopped", - NoticeOutcome::Interrupted => "interrupted", - NoticeOutcome::WaitingForInput => "waiting for input", - } + outcome.verb() } /// `<verb>: <detail>` when the row carries a one-liner, else `<verb>` — the @@ -10481,9 +10473,9 @@ mod tests { for (outcome, verb) in [ (NoticeOutcome::Completed, "finished"), (NoticeOutcome::Errored, "failed"), - (NoticeOutcome::Stopped, "stopped"), - (NoticeOutcome::Interrupted, "interrupted"), - (NoticeOutcome::WaitingForInput, "waiting for input"), + (NoticeOutcome::Stopped, "was stopped"), + (NoticeOutcome::Interrupted, "was interrupted"), + (NoticeOutcome::WaitingForInput, "is waiting for input"), ] { assert_eq!(notice_outcome_verb(outcome), verb); } diff --git a/web/packages/app/src/lib/transcript.ts b/web/packages/app/src/lib/transcript.ts index e9a9f42843..bd60a3b2be 100644 --- a/web/packages/app/src/lib/transcript.ts +++ b/web/packages/app/src/lib/transcript.ts @@ -1495,8 +1495,8 @@ export function shortChatId(id: string): string { /** * The outcome verb a notice side row shows — the same vocabulary the - * notice's plain text uses (`side_line` in the engine), so the card and the - * prose an agent reads agree. Mirrors `notice_outcome_verb` (transcript.rs). + * notice's plain text uses, so the card and the prose an agent reads agree. + * Mirrors the one shared source, `NoticeOutcome::verb` (crates/doc parts.rs). */ export function noticeOutcomeVerb(side: NoticeSide): string { switch (side.outcome) { @@ -1505,11 +1505,11 @@ export function noticeOutcomeVerb(side: NoticeSide): string { case "errored": return "failed"; case "stopped": - return "stopped"; + return "was stopped"; case "interrupted": - return "interrupted"; + return "was interrupted"; case "waitingForInput": - return "waiting for input"; + return "is waiting for input"; } } diff --git a/web/packages/app/tests/transcript-completion-notice.test.ts b/web/packages/app/tests/transcript-completion-notice.test.ts index 17f19cef0a..d316aac39d 100644 --- a/web/packages/app/tests/transcript-completion-notice.test.ts +++ b/web/packages/app/tests/transcript-completion-notice.test.ts @@ -192,9 +192,9 @@ describe("the notice side-row helpers", () => { it("maps every outcome to a verb, matching the notice's own text", () => { expect(noticeOutcomeVerb(side("c", "t", "completed"))).toBe("finished"); expect(noticeOutcomeVerb(side("c", "t", "errored"))).toBe("failed"); - expect(noticeOutcomeVerb(side("c", "t", "stopped"))).toBe("stopped"); - expect(noticeOutcomeVerb(side("c", "t", "interrupted"))).toBe("interrupted"); - expect(noticeOutcomeVerb(side("c", "t", "waitingForInput"))).toBe("waiting for input"); + expect(noticeOutcomeVerb(side("c", "t", "stopped"))).toBe("was stopped"); + expect(noticeOutcomeVerb(side("c", "t", "interrupted"))).toBe("was interrupted"); + expect(noticeOutcomeVerb(side("c", "t", "waitingForInput"))).toBe("is waiting for input"); }); it("appends the one-line reason, and a blank reason reads as none", () => { From eba49b3675f66dcfc72dc59cfe2c2e714f1763d4 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:57:33 +0700 Subject: [PATCH 83/96] fix(ui,mcp,web): code-review group C + glossary strings C1 desktop failover candidates: a failed load is remembered per chat (no refetch every render) with a "Retry accounts" pill C2 fork / continue dialogs take their stale-load guard from a monotonic Shell counter (a reopened dialog never receives an old load) C3 the failover outcome notice is keyed by chat id C4 cancel_sign_in's CancelAgentLogin is detached, never in action_task C5 paste-code submit sets "submitting" only after the engine lookup C6 a refused harness switch restores the previous config and reports the reason (desktop failure chip via HarnessSwitchFailed; web draft revert + failure notice) C7 the harness switch has its own task slot (harness_switch_task) C8 harness_switchable reads the staleness-checked indicator C9 user-made side chats never inherit the parent's agent_origin C10 web Resume sends only the continue prompt (bare send): staged files, review comments and the typed draft stay untouched C11 web surface-registry: every hook above the picker early returns C12 web banner Resume-at-reset / Cancel errors surface via the notice C13 web fork dialog: a fork settling after close never writes into a reopened dialog C14 web Limited banner gated by the send overlay (limitedBannerShows) C15 RESUME_CONTINUE_PROMPT emitted by wiregen into @roboco/proto constants; the web re-exports it C16 MCP instructions: the parent hears of a side chat's reply only when it did not opt out (notify: false) C17 MCP fork_chat sends targetDeviceId like the desktop dialog; AgentOrigin serialized, not hand-built C18 continue dialog default pick via resolve_default_harness (test calls it); the Limited strip renders from a LimitedAction model the test asserts; harness_switch test asserts no resumable session instead of the internal empty-string marker Glossary: continue dialog/banner (harness, chat), fork dialog note (transcript), MCP fork instructions (chat, harness). Wire: additive generated constant RESUME_CONTINUE_PROMPT (web/packages/proto/src/generated/constants.ts, wiregen constants_ts). Tests added/updated (not run; CI): desktop refused-switch revert, stale Working unlock, user side chat origin, Limited strip actions; MCP instructions + fork payload; web bare Resume, fork close race, limitedBannerShows. --- crates/engine/tests/harness_switch.rs | 9 +- crates/roboco-mcp/src/jsonrpc.rs | 13 +- crates/roboco-mcp/src/tools.rs | 28 +-- crates/ui/src/account_usage.rs | 11 +- crates/ui/src/composer.rs | 10 + crates/ui/src/pickers.rs | 153 ++++++++++++++- crates/ui/src/shell.rs | 175 +++++++++++++++--- crates/ui/src/shell/continue_on_harness.rs | 64 ++++--- crates/ui/src/shell/fork_dialog.rs | 17 +- crates/ui/src/shell/side_chats.rs | 37 ++++ crates/ui/src/state.rs | 11 +- crates/wiregen/src/main.rs | 5 + .../app/src/components/composer-pickers.tsx | 8 +- web/packages/app/src/components/composer.tsx | 87 +++++---- .../app/src/components/fork-dialog.tsx | 2 +- .../app/src/components/limited-banner.tsx | 16 +- .../app/src/components/surface-registry.tsx | 107 ++++++----- web/packages/app/src/lib/limited.ts | 27 ++- web/packages/app/src/routes/chat-page.tsx | 7 +- web/packages/app/src/state/fork-dialog.ts | 9 + .../app/tests/composer-resume-switch.test.ts | 25 +++ web/packages/app/tests/fork-dialog.test.ts | 36 ++++ web/packages/app/tests/limited.test.ts | 30 ++- web/packages/proto/src/generated/constants.ts | 3 + 24 files changed, 676 insertions(+), 214 deletions(-) diff --git a/crates/engine/tests/harness_switch.rs b/crates/engine/tests/harness_switch.rs index 6935858d52..55ab5aad45 100644 --- a/crates/engine/tests/harness_switch.rs +++ b/crates/engine/tests/harness_switch.rs @@ -301,13 +301,14 @@ async fn switch_writes_the_seam_clears_the_session_and_saves_the_harness() { // new harness. expect_seams(&core, HarnessId::Pi, 1); assert_eq!(entries(&core).len(), before + 1); - // The row: new harness and model, and the session cleared — the - // empty-string tombstone, not a missing field (the journal scan must - // not resurrect the mock session later). + // The row: new harness and model, and no session left to resume (how + // the clear is spelled is the engine's business; that the old session + // never rides a resume — not even after a restart's journal scan — is + // asserted by the post-switch send tests below). let row = chat_row(&core); assert_eq!(row.config.as_ref().map(|c| c.harness), Some(HarnessId::Pi)); assert_eq!(row.config.as_ref().and_then(|c| c.model.as_deref()), Some("pi-default")); - assert_eq!(row.harness_session_id.as_deref(), Some("")); + assert!(row.harness_session_id.as_deref().is_none_or(str::is_empty)); assert!(row.harness_session_cwd.as_deref().is_none_or(|cwd| cwd.is_empty())); // Refusals: the same harness, an unregistered one, an unknown chat. diff --git a/crates/roboco-mcp/src/jsonrpc.rs b/crates/roboco-mcp/src/jsonrpc.rs index 15e14be65b..f2f935109a 100644 --- a/crates/roboco-mcp/src/jsonrpc.rs +++ b/crates/roboco-mcp/src/jsonrpc.rs @@ -51,8 +51,9 @@ for fire-and-forget children that should not wake you. `wait_for_turn`, and \ synchronous asks; they block your turn, so never use them to babysit \ long-running work.\n\ \n\ -If you are a side chat created by another chat: your final reply is delivered \ -to your parent automatically when your turn ends. Do not report progress or \ +If you are a side chat created by another chat: unless it opted out at \ +creation (`notify: false`), your parent gets a completion notice when your \ +turn ends and reads your final reply from it. Do not report progress or \ acknowledge messages with `send_message`; answer the task in your reply and \ end your turn.\n\ \n\ @@ -69,11 +70,11 @@ side` for a child with an explicit parent or your origin chat. Omitted kind \ keeps legacy defaults: side with an origin/parent, standalone otherwise. Side \ chats cannot create chats.\n\ \n\ -Forking: `fork_chat` branches a conversation into a new side chat — by \ -default the one you are speaking from, from its latest reply. `reply` forks \ -from any earlier assistant reply (an entry id from `read_chat`; errored and \ +Forking: `fork_chat` branches a chat into a new side chat — by default the \ +one you are speaking from, from its latest reply. `reply` forks from any \ +earlier assistant reply (an entry id from `read_chat`; errored and \ interrupted replies count), `harness` and `model` move the fork onto another \ -agent (it then starts from a size-limited transcript of the source), and \ +harness (it then starts from a size-limited transcript of the source), and \ `prompt` with `wait` behave like `create_chat`'s. A fork you make is a side \ chat you created: it notifies you by default (`notify: false` opts out), and \ side chats cannot fork chats.\n\ diff --git a/crates/roboco-mcp/src/tools.rs b/crates/roboco-mcp/src/tools.rs index acabd63f07..46506edd40 100644 --- a/crates/roboco-mcp/src/tools.rs +++ b/crates/roboco-mcp/src/tools.rs @@ -855,10 +855,7 @@ impl Tools { mutate["parentChatId"] = json!(parent); } if let Some(origin) = &agent_origin { - mutate["agentOrigin"] = json!({ - "chatId": origin.chat_id, - "notify": origin.notify, - }); + mutate["agentOrigin"] = json!(origin); } if let Some(branch) = args .branch @@ -1057,10 +1054,13 @@ impl Tools { .clone() .unwrap_or_else(|| source.id.clone()), }; + // The desktop fork dialog's payload: the copy runs on the SOURCE's + // device, named explicitly like the UI does. let mut params = json!({ "chatId": chat_id, "sourceChatId": source.id, "parentChatId": parent_chat_id, + "targetDeviceId": source.device_id, }); // Overrides ride only when chosen — the same wire shape the fork // dialog sends — so an engine predating tickets 21/22/23 still @@ -1087,10 +1087,7 @@ impl Tools { params["model"] = json!(model); } if let Some(origin) = &agent_origin { - params["agentOrigin"] = json!({ - "chatId": origin.chat_id, - "notify": origin.notify, - }); + params["agentOrigin"] = json!(origin); } let chat = self.roboco.fork_side_chat(params).await?; if let Some(title) = args @@ -1393,7 +1390,8 @@ impl Tools { }); if notifying { format!( - "[Message from Roboco chat {label}. You are a side chat created by that chat; your final reply is delivered to it automatically. Do not report or acknowledge with `send_message`.]\n\n{text}" + "[Message from Roboco chat {label}{}]\n\n{text}", + roboco_proto::SIDE_CHAT_MESSAGE_HEADER_TAIL ) } else { format!( @@ -2229,6 +2227,8 @@ mod tests { assert_eq!(params["chatId"], chat_id); assert_eq!(params["sourceChatId"], "chat-alpha-1"); assert_eq!(params["parentChatId"], "chat-alpha-1"); + // The source's device, named like the desktop fork dialog does. + assert_eq!(params["targetDeviceId"], "dev-local"); assert_eq!( params["agentOrigin"], json!({ "chatId": "chat-alpha-1", "notify": true }) @@ -3380,9 +3380,15 @@ mod tests { // The notify opt-out and the side-chat no-report rule. assert!(instructions.contains("notify: false")); + // ... and told accurately: the parent hears of the reply only when + // it did not opt out with notify: false. + assert!( + instructions.contains("unless it opted out at creation (`notify: false`)"), + "side chats must be told when their reply reaches the parent" + ); assert!( - instructions.contains("your final reply is delivered to your parent"), - "side chats must be told their reply is delivered automatically" + !instructions.contains("delivered to your parent automatically"), + "the reply is not delivered when the parent opted out" ); assert!(instructions.contains("Do not report progress or acknowledge")); diff --git a/crates/ui/src/account_usage.rs b/crates/ui/src/account_usage.rs index 6340710fc1..a5be20da2e 100644 --- a/crates/ui/src/account_usage.rs +++ b/crates/ui/src/account_usage.rs @@ -380,12 +380,14 @@ impl AccountUsage { return; } let login_id = start.login_id.clone(); - *submitting = true; let Some(engine) = crate::request_routing::device_target(self.state.read(cx), self.target.as_deref()).ok() else { return; }; + // Only once the submit can actually go out: a bail above must not + // leave the button stuck on "Verifying…". + *submitting = true; let params = self.params(serde_json::json!({ "loginId": login_id, "code": code })); self.sign_in_task = Some(cx.spawn(async move |this, cx| { let result = engine.call(methods::COMPLETE_AGENT_LOGIN, params).await; @@ -504,11 +506,14 @@ impl AccountUsage { .ok(), ) { let params = self.params(serde_json::json!({ "loginId": login_id })); - self.action_task = Some(cx.spawn(async move |_, _| { + // Detached, never in `action_task`: replacing that slot would + // cancel an in-flight account switch. + cx.spawn(async move |_, _| { if let Err(err) = engine.call(methods::CANCEL_AGENT_LOGIN, params).await { tracing::debug!(error = %err, "CancelAgentLogin failed (best-effort)"); } - })); + }) + .detach(); } cx.notify(); } diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 1a5ea9734d..0ed90e369c 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -5762,6 +5762,7 @@ pub struct Composer { /// A side chat's composer: its footer keeps only the context ring. side_chat: bool, _picker_focus: Subscription, + _harness_switch_failed: Subscription, _input_events: Subscription, _dictation_events: Subscription, dictation_activation: Option<Subscription>, @@ -5860,6 +5861,14 @@ impl Composer { cx.notify(); }, ); + // A refused one-way harness switch (ticket 24): the pickers already + // restored the previous config; the reason rides the failure chip. + let harness_switch_failed = cx.subscribe( + &pickers, + |this: &mut Self, _, event: &crate::pickers::HarnessSwitchFailed, cx| { + this.show_error(event.0.clone(), cx); + }, + ); let observe = cx.observe(&state, |this: &mut Self, _, cx| this.on_state_changed(cx)); let input_events = cx.subscribe(&input, |this: &mut Self, _, event, cx| match event { ComposerInputEvent::Submitted => this.on_submit(cx), @@ -6015,6 +6024,7 @@ impl Composer { account_usage, side_chat: false, _picker_focus: picker_focus, + _harness_switch_failed: harness_switch_failed, _input_events: input_events, _dictation_events: dictation_events, dictation_activation: None, diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 745d103414..06cd15306f 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -560,6 +560,13 @@ pub(crate) struct ReturnComposerFocus; impl gpui::EventEmitter<ReturnComposerFocus> for Pickers {} +/// The engine refused a one-way harness switch (t3-orchestration-v2 ticket +/// 24): the picker already restored the previous config; the composer shows +/// the reason on its failure chip. +pub(crate) struct HarnessSwitchFailed(pub SharedString); + +impl gpui::EventEmitter<HarnessSwitchFailed> for Pickers {} + /// A model picked while bound to thread naming (see [`Pickers::new_for_titles`]); /// the owner persists it with `SetTitleSettings`. pub struct TitleModelPicked(pub TitleSettings); @@ -683,6 +690,9 @@ pub struct Pickers { /// Last mid-session switch failure (shown in the ref popover). switch_error: Option<String>, mutate_task: Option<Task<()>>, + /// In-flight one-way harness switch (ticket 24): its own slot, so a + /// model/reasoning mutate never drops a switch mid-flight. + harness_switch_task: Option<Task<()>>, /// Room measured between the model chip and the window's bottom — the /// new-chat model menu sizes its list to what actually fits below the /// composer. @@ -886,6 +896,7 @@ impl Pickers { switch_task: None, switch_error: None, mutate_task: None, + harness_switch_task: None, _search_events: search_events, _state_observe: state_observe, _catalog_observe: catalog_observe, @@ -2018,8 +2029,10 @@ impl Pickers { /// defaults unless the user chose them — stamp the row optimistically, /// then tell the engine, which writes the "Switched to <harness>" seam, /// drops the old harness session, and arms the budgeted history for the - /// next send. A failed switch leaves the engine's row untouched; the - /// chats watch reverts the optimistic stamp. + /// next send. A refused switch leaves the engine's row untouched, so the + /// optimistic stamp reverts to the previous config at once and the + /// reason surfaces on the composer's failure chip + /// ([`HarnessSwitchFailed`]). fn switch_chat_harness(&mut self, chat_id: &str, harness: HarnessId, cx: &mut Context<Self>) { let busy = { let state = self.state.read(cx); @@ -2037,11 +2050,13 @@ impl Pickers { // Model options reset to the new harness's defaults unless the user // chose them (see [`Self::default_picks`]). let (model, reasoning, model_options) = self.default_picks(harness); - let sandbox = self + let previous = self .state .read(cx) .selected_chat_row() - .and_then(|chat| chat.config.as_ref()) + .and_then(|chat| chat.config.clone()); + let sandbox = previous + .as_ref() .map(|config| config.sandbox) .unwrap_or(SandboxLevel::WorkspaceWrite); let mut config = ChatConfig { @@ -2060,7 +2075,7 @@ impl Pickers { return; }; let chat_id = chat_id.to_string(); - self.mutate_task = Some(cx.spawn(async move |_, _| { + self.harness_switch_task = Some(cx.spawn(async move |this, cx| { let params = serde_json::json!({ "chatId": chat_id, "harness": config.harness, @@ -2068,12 +2083,39 @@ impl Pickers { "reasoning": config.reasoning, "modelOptions": config.model_options, }); - if let Err(err) = engine.call(methods::SWITCH_HARNESS, params).await { - tracing::warn!(error = %err, "harness switch failed"); - } + let Err(err) = engine.call(methods::SWITCH_HARNESS, params).await else { + return; + }; + tracing::warn!(error = %err, "harness switch failed"); + this.update(cx, |pickers, cx| { + pickers.revert_harness_switch(&chat_id, previous, err.to_string(), cx); + }) + .ok(); })); } + /// Undo a refused harness switch's optimistic stamp (the engine kept + /// the previous config) and surface the reason on the composer's + /// failure chip. + fn revert_harness_switch( + &mut self, + chat_id: &str, + previous: Option<ChatConfig>, + reason: String, + cx: &mut Context<Self>, + ) { + if let Some(previous) = previous { + self.state.update(cx, |state, cx| { + state.apply_chat_config(chat_id, previous); + cx.notify(); + }); + } + cx.emit(HarnessSwitchFailed( + format!("Could not switch harness: {reason}").into(), + )); + cx.notify(); + } + // ---- keyboard ---- /// The traits popover's reasoning ladder (model levels, falling back to @@ -6121,7 +6163,7 @@ mod tests { // remembered): the old harness's model never lingers. assert_eq!(config.as_ref().and_then(|c| c.model.as_deref()), None); assert!(config.as_ref().map(|c| c.model_options.is_empty()).unwrap_or(false)); - assert!(pickers.mutate_task.is_none(), "no engine — no RPC"); + assert!(pickers.harness_switch_task.is_none(), "no engine — no RPC"); }); let (state, pickers) = side_chat_picker(true, cx); state.update(cx, |state, _| { @@ -6250,6 +6292,32 @@ mod tests { }); } + /// A Working row gone stale (a dead engine stops heartbeating) reads as + /// not working — the same staleness check the status dot uses — so the + /// picker does not stay locked forever. + #[gpui::test] + fn a_stale_working_row_does_not_lock_the_picker(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + cx.set_global(Theme::dark()); + let (state, pickers) = working_chat(roboco_proto::SessionStatus::Working, cx); + assert!(pickers.read(cx).harness_locked(cx)); + state.update(cx, |state, _| { + state.apply_sessions(vec![roboco_proto::Session { + last_completed_turn: None, + chat_id: "main".into(), + device_id: "local".into(), + status: roboco_proto::SessionStatus::Working, + started_at: None, + updated_at: chrono::Utc::now() + - chrono::Duration::milliseconds(roboco_proto::view::SESSION_STALE_MS + 5_000), + limit: None, + scheduled_resume: None, + }]); + }); + assert!(!pickers.read(cx).harness_locked(cx), "a stale Working row unlocks"); + }); + } + #[gpui::test] fn switching_an_idle_chat_tells_the_engine_the_resolved_picks( cx: &mut gpui::TestAppContext, @@ -6319,6 +6387,73 @@ mod tests { ); } + /// A switch the engine REFUSES reverts the optimistic stamp to the + /// previous config and reports the reason for the composer's failure + /// chip — the row never keeps showing a harness the chat is not on. + #[gpui::test] + fn a_refused_harness_switch_restores_the_previous_config(cx: &mut gpui::TestAppContext) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + let (out, mut requests) = tokio::sync::mpsc::channel::<String>(256); + let (replies, inbound) = tokio::sync::mpsc::channel::<String>(256); + let failures = std::rc::Rc::new(std::cell::RefCell::new(Vec::<SharedString>::new())); + let (state, pickers, _subscription) = cx.update(|cx| { + cx.set_global(Theme::dark()); + let (state, pickers) = working_chat(roboco_proto::SessionStatus::Idle, cx); + state.update(cx, |state, _| { + state.set_test_engine(crate::state::EngineHandle::from_test_client( + roboco_rpc::RpcClient::new(out, inbound), + )); + }); + let seen = failures.clone(); + let subscription = cx.subscribe(&pickers, move |_, event: &HarnessSwitchFailed, _| { + seen.borrow_mut().push(event.0.clone()); + }); + pickers.update(cx, |pickers, cx| { + pickers + .models + .insert(HarnessId::Codex, Loadable::Ready(Vec::new())); + pickers.pick_harness(HarnessId::Codex, cx); + }); + (state, pickers, subscription) + }); + let pump = |cx: &mut gpui::TestAppContext| { + cx.run_until_parked(); + for _ in 0..16 { + runtime.block_on(async { + tokio::task::yield_now().await; + }); + } + cx.run_until_parked(); + }; + pump(cx); + let frame: serde_json::Value = + serde_json::from_str(&requests.try_recv().expect("the switch reached the engine")) + .unwrap(); + assert!(frame.to_string().contains("SwitchHarness"), "{frame}"); + let reply = serde_json::json!({ "id": frame["id"], "err": "chat is busy" }); + runtime.block_on(async { + replies.send(reply.to_string()).await.unwrap(); + }); + pump(cx); + cx.update(|cx| { + let config = state + .read(cx) + .selected_chat_row() + .and_then(|chat| chat.config.clone()) + .expect("row config"); + assert_eq!(config.harness, HarnessId::ClaudeCode, "the stamp reverted"); + assert_eq!(config.model.as_deref(), Some("saved-model")); + assert_eq!(pickers.read(cx).resolved(cx).harness, Some(HarnessId::ClaudeCode)); + }); + let failures = failures.borrow(); + assert_eq!(failures.len(), 1, "{failures:?}"); + assert!(failures[0].contains("chat is busy"), "{failures:?}"); + } + #[gpui::test] fn missing_selected_harness_is_not_reintroduced_into_the_rail(cx: &mut gpui::TestAppContext) { cx.update(|cx| { diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 3fe61f19da..4ac386ff55 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -210,13 +210,35 @@ struct FailoverState { )>, /// The chat whose candidate load is in flight. loading: Option<String>, + /// The chat whose last candidate load FAILED: the render pass does not + /// re-fetch it (that spun the RPC every frame); the strip offers Retry. + failed: Option<String>, /// The chat whose switch is in flight (the popover's rows disable). switching: Option<String>, popup: popover::Popup<()>, - /// The last switch outcome — a warning-toned strip line ("Resumed on - /// …", "no other account", …) that lives until the next switch or the - /// chat's own state supersedes it. - notice: Option<SharedString>, + /// The last switch outcome, keyed by its chat — a warning-toned strip + /// line ("Resumed on …", "no other account", …) that lives until the + /// next switch or the chat's own state supersedes it, and never shows + /// on another chat. + notice: Option<(String, SharedString)>, +} + +/// One pill of the Limited banner (orchestration v2), in strip order — the +/// model the strip renders from ([`Shell::limited_strip_actions`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LimitedAction { + /// Arm the durable continuation (a reset still ahead, nothing armed). + ResumeAtReset, + /// Cancel the armed continuation. + CancelResume, + /// Send the continue prompt now. + Resume, + /// Open the "Resume on another account" picker. + Failover, + /// Re-ask for the failover candidates after a failed load. + RetryFailover, + /// Open the "Continue on another harness" dialog. + ContinueOnHarness, } /// How long a loaded candidate list stays fresh while the banner shows. @@ -1832,6 +1854,10 @@ pub struct Shell { /// switches to. `Some` while open; the confirm performs ticket 24's /// one-way switch and then sends the continue prompt. continue_dialog: Option<continue_on_harness::ContinueOnHarnessDialog>, + /// Monotonic open counter for the fork and continue dialogs: each open + /// takes a fresh number as its stale-load guard, so a catalog load from + /// a closed dialog can never land on a reopened one. + dialog_generation: u64, /// The add-space palette (device tabs + folder search), `Some` while open. add_space: Option<AddSpaceFlow>, /// The New project palette's collapsed-breadcrumbs (`…`) menu. @@ -2242,6 +2268,7 @@ impl Shell { delete_space_confirm: None, fork_dialog: None, continue_dialog: None, + dialog_generation: 0, add_space: None, command_palette: None, pending_workspace_command: None, @@ -4474,18 +4501,30 @@ impl Shell { } } + /// A fresh stale-load guard for a dialog being opened (see + /// `dialog_generation`). + fn next_dialog_generation(&mut self) -> u64 { + self.dialog_generation += 1; + self.dialog_generation + } + // ── Resume on another account (orchestration v2, ticket 09) ───────────── /// Load or refresh the banner's candidate list through the engine's /// ranking RPC. Cheap enough to call from the strip's render: a chat /// change or an expired TTL re-loads, everything else is a no-op. A - /// load failure clears the cache so the next pass retries — the banner - /// keeps offering plain Resume either way. + /// load failure is remembered for its chat: render passes stop + /// re-fetching it, and the strip's Retry ([`Self::retry_failover`]) or a + /// popover open asks again — the banner keeps offering plain Resume + /// either way. fn track_failover(&mut self, chat_id: &str, cx: &mut Context<Self>) { let fresh = self.failover.loaded.as_ref().is_some_and(|(chat, at, _)| { chat == chat_id && at.elapsed() < FAILOVER_LIST_TTL }); - if fresh || self.failover.loading.as_deref() == Some(chat_id) { + if fresh + || self.failover.loading.as_deref() == Some(chat_id) + || self.failover.failed.as_deref() == Some(chat_id) + { return; } let Some(engine) = crate::request_routing::selected_target(self.state.read(cx)).ok() @@ -4509,11 +4548,13 @@ impl Shell { shell.failover.loading = None; match result { Ok(candidates) => { + shell.failover.failed = None; shell.failover.loaded = Some((chat, std::time::Instant::now(), candidates)); } Err(err) => { shell.failover.loaded = None; + shell.failover.failed = Some(chat); tracing::debug!(error = %err, "failover candidates failed to load"); } } @@ -4524,6 +4565,61 @@ impl Shell { .detach(); } + /// The Limited banner's pills for `chat_id`, in strip order. Resume at + /// reset only while a reset is still ahead and nothing is armed (a due + /// or unknown reset leaves the honest offer as plain Resume); Cancel + /// once one is armed (ticket 07); "Resume on another account" only for + /// the harnesses whose logins switch AND when the engine's list offers + /// something (ticket 09); Retry after that list failed to load; and the + /// harness escape hatch always (ticket 25). + fn limited_strip_actions( + &self, + chat_id: &str, + now: chrono::DateTime<Utc>, + cx: &App, + ) -> Vec<LimitedAction> { + let state = self.state.read(cx); + let limit = state.limit_for(chat_id, now); + let scheduled = state.scheduled_resume_for(chat_id, now).is_some(); + let switchable = limit.as_ref().is_some_and(|limit| { + matches!( + limit.harness, + roboco_proto::HarnessId::ClaudeCode | roboco_proto::HarnessId::Codex + ) + }); + let armable = !scheduled + && limit + .as_ref() + .and_then(|limit| limit.resets_at) + .is_some_and(|at| at > now.timestamp_millis()); + let mut actions = Vec::new(); + if armable { + actions.push(LimitedAction::ResumeAtReset); + } + if scheduled { + actions.push(LimitedAction::CancelResume); + } + actions.push(LimitedAction::Resume); + if switchable && self.failover_list(chat_id).is_some() { + actions.push(LimitedAction::Failover); + } + if self.failover.failed.as_deref() == Some(chat_id) { + actions.push(LimitedAction::RetryFailover); + } + actions.push(LimitedAction::ContinueOnHarness); + actions + } + + /// The strip's Retry after a failed candidate load: forget the failure + /// and ask the engine once more. + fn retry_failover(&mut self, chat_id: &str, cx: &mut Context<Self>) { + if self.failover.failed.as_deref() == Some(chat_id) { + self.failover.failed = None; + } + self.track_failover(chat_id, cx); + cx.notify(); + } + /// The banner's candidate list for `chat_id`, when it is loaded and /// offers something: healthy candidates, signed-out rows to sign back /// in (stories 47/50), or both. `None` while a load is in flight, or @@ -4550,6 +4646,7 @@ impl Shell { .failover .loaded .take_if(|(loaded, _, _)| *loaded != chat); + self.failover.failed = None; self.failover.popup.open(()); self.track_failover(&chat, cx); cx.notify(); @@ -4582,6 +4679,7 @@ impl Shell { self.failover.switching = Some(chat.clone()); self.failover.notice = None; let params = serde_json::json!({ "chatId": chat, "accountId": account_id }); + let notice_chat = chat.clone(); cx.spawn(async move |this, cx| { let result = engine .call(methods::RESUME_ON_ANOTHER_ACCOUNT, params) @@ -4598,7 +4696,7 @@ impl Shell { Ok(outcome) => failover_outcome_notice(outcome), Err(err) => format!("Could not switch account: {err}").into(), }; - shell.failover.notice = Some(notice); + shell.failover.notice = Some((notice_chat, notice)); // A FAILED switch also rides the composer's failure chip: it // survives the banner disappearing (the chip is chat-scoped // and dismissable) while the strip line is transient. @@ -10107,19 +10205,18 @@ impl Shell { } (None, _) => "Usage limit reached".into(), }; - // "Resume on another account" (ticket 09) — only for the - // harnesses whose logins switch. The chip hides when the - // engine's list offers nothing: no healthy candidate AND no - // signed-out row to sign back in (every other account - // limited, an API key, or in use) — then only Resume (and, - // with ticket 07, Resume at reset) remains. - let has_candidates = limit.as_ref().is_some_and(|limit| { - matches!( - limit.harness, - roboco_proto::HarnessId::ClaudeCode | roboco_proto::HarnessId::Codex - ) - }) && self.failover_list(&chat_id).is_some(); - let notice = self.failover.notice.clone(); + // Which pills the banner offers — one model, shared with the + // tests (see [`Self::limited_strip_actions`]). + let actions = self.limited_strip_actions(&chat_id, now, cx); + let has_candidates = actions.contains(&LimitedAction::Failover); + let failover_failed = actions.contains(&LimitedAction::RetryFailover); + let retry_chat_id = chat_id.clone(); + let notice = self + .failover + .notice + .as_ref() + .filter(|(chat, _)| *chat == chat_id) + .map(|(_, notice)| notice.clone()); let popup_open = self.failover.popup.is_open(); let failover_menu = popup_open.then(|| { self.failover_card(cx) @@ -10132,14 +10229,8 @@ impl Shell { this.failover.popup.note_trigger_press(); }); let toggle = cx.listener(|this, _, _, cx| this.toggle_failover(cx)); - // Resume at reset is offered only when there is a reset time - // still ahead to wait for; a due or unknown reset leaves the - // honest offer as plain Resume. - let armable = scheduled.is_none() - && limit - .as_ref() - .and_then(|limit| limit.resets_at) - .is_some_and(|at| at > now.timestamp_millis()); + let armable = actions.contains(&LimitedAction::ResumeAtReset); + let cancellable = actions.contains(&LimitedAction::CancelResume); let arm_chat_id = chat_id.clone(); let cancel_chat_id = chat_id.clone(); strip @@ -10181,7 +10272,7 @@ impl Shell { .child(SharedString::from("Resume at reset")), ) }) - .when_some(scheduled, |strip, _| { + .when(cancellable, |strip| { strip.child( div() .id("limited-cancel-resume") @@ -10258,6 +10349,30 @@ impl Shell { }), ) }) + .when(failover_failed, |el| { + el.child( + div() + .id("limited-failover-retry") + .flex_none() + .flex() + .items_center() + .rounded(px(Theme::CONTROL_RADIUS * 0.5)) + .px(px(Theme::SPACE_SM + 2.0)) + .py(px(1.0)) + .text_color(theme.warning.opacity(0.9)) + .border_1() + .border_color(theme.warning.opacity(0.45)) + .cursor_pointer() + .hover(|s| s.bg(theme.warning.opacity(0.12))) + .on_click(cx.listener(move |this, _, _, cx| { + this.retry_failover(&retry_chat_id, cx); + })) + .tooltip(crate::settings::widgets::text_tooltip( + "Could not load the other accounts — try again", + )) + .child(SharedString::from("Retry accounts")), + ) + }) .when_some(notice, |el, notice| { el.child( div() diff --git a/crates/ui/src/shell/continue_on_harness.rs b/crates/ui/src/shell/continue_on_harness.rs index 6c718a2a6a..ebb68fb5bf 100644 --- a/crates/ui/src/shell/continue_on_harness.rs +++ b/crates/ui/src/shell/continue_on_harness.rs @@ -41,8 +41,8 @@ pub(super) struct ContinueOnHarnessDialog { harnesses: Loadable<Vec<HarnessDescriptor>>, /// Model lists per harness, loaded once per dialog per harness. models: std::collections::HashMap<HarnessId, Loadable<Vec<Model>>>, - /// Stale-response guard: bumped when the dialog closes, so an in-flight - /// catalog load lands on a closed (or reopened) dialog and no-ops. + /// Stale-response guard: this open's number from the shell's monotonic + /// counter, so a load from a closed (or earlier) dialog no-ops. generation: u64, /// The model list's scroll. model_scroll: gpui::ScrollHandle, @@ -96,6 +96,7 @@ impl Shell { let Some(chat) = self.state.read(cx).selected_chat_row().cloned() else { return; }; + let generation = self.next_dialog_generation(); self.continue_dialog = Some(ContinueOnHarnessDialog { current_harness: chat.config.as_ref().map(|config| config.harness), chat, @@ -103,19 +104,17 @@ impl Shell { model: None, harnesses: Loadable::Idle, models: Default::default(), - generation: 0, + generation, model_scroll: gpui::ScrollHandle::new(), }); self.ensure_continue_loads(cx); cx.notify(); } - /// Close the dialog without continuing. The generation bump retires - /// in-flight catalog loads. + /// Close the dialog without continuing. In-flight catalog loads retire + /// on their generation: a reopened dialog takes a fresh one from the + /// shell's monotonic counter. pub(super) fn close_continue_dialog(&mut self, cx: &mut Context<Self>) { - if let Some(dialog) = self.continue_dialog.as_mut() { - dialog.generation += 1; - } self.continue_dialog = None; cx.notify(); } @@ -318,15 +317,7 @@ impl Shell { }, Err(error) => Loadable::Error(error.to_string()), }; - // No pick yet: the first OFFERED harness (the current one is - // already excluded from the offered list) becomes the pick - // once the catalog lands — one click confirms. - let first = dialog.offered().first().map(|descriptor| descriptor.id); - if dialog.harness.is_none() - && let Some(harness) = first - { - dialog.harness = Some(harness); - } + dialog.resolve_default_harness(); cx.notify(); }) .ok(); @@ -426,8 +417,8 @@ impl Shell { .child(div().mt(px(6.0)).child(popover::dialog_body( &theme, "This chat hit its usage limit. Switching harnesses is one-way: the chat runs \ - on the one you pick from here on, and the new agent receives a size-limited \ - transcript of the conversation, then the continue prompt.", + on the one you pick from here on, and the new harness receives a size-limited \ + transcript of the chat, then the continue prompt.", ))); // ── Harness: one chip per offered harness (never the current one); @@ -651,6 +642,18 @@ impl ContinueOnHarnessDialog { .unwrap_or_default(); continue_offered_harnesses(catalog, self.current_harness) } + + /// No pick yet: the first OFFERED harness (the current one is already + /// excluded from the offered list) becomes the pick once the catalog + /// lands — one click confirms. + fn resolve_default_harness(&mut self) { + let first = self.offered().first().map(|descriptor| descriptor.id); + if self.harness.is_none() + && let Some(harness) = first + { + self.harness = Some(harness); + } + } } #[cfg(test)] @@ -813,14 +816,8 @@ mod tests { ]; let dialog = shell.continue_dialog.as_mut().unwrap(); dialog.harnesses = Loadable::Ready(catalog); - // The default resolution the load completion applies — - // exercised through the dialog's own offered list here. - let first = dialog.offered().first().map(|d| d.id); - if dialog.harness.is_none() - && let Some(harness) = first - { - dialog.harness = Some(harness); - } + // The default resolution the load completion applies. + dialog.resolve_default_harness(); assert_eq!( shell.continue_dialog.as_ref().unwrap().harness, Some(HarnessId::Pi), @@ -956,9 +953,16 @@ mod tests { state.sessions = vec![session]; state.select_chat(Some("limited".into()), cx); }); - // The Limited branch builds — including the new pill. - let strip = shell.render_status_strip(640.0, cx); - drop(strip); + // The Limited branch builds, and its model offers the pill + // (Resume first, the harness escape hatch last). + let _strip = shell.render_status_strip(640.0, cx); + let actions = shell.limited_strip_actions("limited", Utc::now(), cx); + assert!(actions.contains(&LimitedAction::Resume), "{actions:?}"); + assert_eq!( + actions.last(), + Some(&LimitedAction::ContinueOnHarness), + "{actions:?}" + ); // The pill's click opens this dialog. shell.open_continue_on_harness(cx); assert!(shell.continue_dialog.is_some()); diff --git a/crates/ui/src/shell/fork_dialog.rs b/crates/ui/src/shell/fork_dialog.rs index 32485dafaf..51acbf60b5 100644 --- a/crates/ui/src/shell/fork_dialog.rs +++ b/crates/ui/src/shell/fork_dialog.rs @@ -40,8 +40,8 @@ pub(super) struct ForkDialog { harnesses: Loadable<Vec<HarnessDescriptor>>, /// Model lists per harness, loaded once per dialog per harness. models: std::collections::HashMap<HarnessId, Loadable<Vec<Model>>>, - /// Stale-response guard: bumped when the dialog closes, so an in-flight - /// catalog load lands on a closed (or reopened) dialog and no-ops. + /// Stale-response guard: this open's number from the shell's monotonic + /// counter, so a load from a closed (or earlier) dialog no-ops. generation: u64, /// The model list's scroll. model_scroll: gpui::ScrollHandle, @@ -142,6 +142,7 @@ impl Shell { .config .as_ref() .and_then(|config| config.model.clone()); + let generation = self.next_dialog_generation(); self.fork_dialog = Some(ForkDialog { source, parent_id, @@ -150,19 +151,17 @@ impl Shell { model, harnesses: Loadable::Idle, models: std::collections::HashMap::new(), - generation: 0, + generation, model_scroll: gpui::ScrollHandle::new(), }); self.ensure_fork_loads(cx); cx.notify(); } - /// Close the dialog without forking. The generation bump retires - /// in-flight catalog loads. + /// Close the dialog without forking. In-flight catalog loads retire on + /// their generation: a reopened dialog takes a fresh one from the + /// shell's monotonic counter. pub(super) fn close_fork_dialog(&mut self, cx: &mut Context<Self>) { - if let Some(dialog) = self.fork_dialog.as_mut() { - dialog.generation += 1; - } self.fork_dialog = None; cx.notify(); } @@ -593,7 +592,7 @@ impl Shell { .text_color(theme.text_muted) .child(SharedString::from( "A different harness starts fresh and receives a \ - size-limited transcript of this chat, not its full history.", + size-limited transcript of this chat, not its full transcript.", )), ); } diff --git a/crates/ui/src/shell/side_chats.rs b/crates/ui/src/shell/side_chats.rs index ce1cfc358b..ee022208a0 100644 --- a/crates/ui/src/shell/side_chats.rs +++ b/crates/ui/src/shell/side_chats.rs @@ -137,6 +137,10 @@ impl Shell { chat.last_seen_at = None; chat.harness_session_id = None; chat.harness_session_cwd = None; + // A user-made side chat is never agent-created: the parent's own + // origin (an agent spawned IT) must not ride the copy, or the new + // chat would owe completion notices nobody asked for. + chat.agent_origin = None; chat.room_gen = Some(2); self.open_side_chat_tab(chat, key, true, cx); } @@ -562,6 +566,39 @@ mod tests { .unwrap(); } + /// A user-made side chat under an agent-created parent never inherits + /// the parent's agent origin: user side chats never notify anyone. + #[gpui::test] + fn a_user_side_chat_does_not_inherit_the_parents_agent_origin(cx: &mut TestAppContext) { + let dir = tempfile::tempdir().unwrap(); + let window = shell_window(dir.path(), cx); + window + .update(cx, |shell, _, cx| { + shell.active_chat = "main".into(); + let main: roboco_proto::Chat = serde_json::from_value(serde_json::json!({ + "id": "main", "deviceId": "local", "cwd": "/tmp/main", + "archived": false, "createdAt": Utc::now(), + "agentOrigin": { "chatId": "orchestrator", "notify": true }, + })) + .unwrap(); + assert!(main.agent_origin.is_some()); + shell.state.update(cx, |state, _| { + state.chats = vec![main]; + state.selected_chat = Some("main".into()); + }); + shell.toggle_right_pane(cx); + shell.create_child_chat(None, cx); + let id = shell.side_chat_seq; + let side = shell.side_chats[&id].state.clone(); + let chat_id = side.read(cx).selected_chat.clone().unwrap(); + let row = side.read(cx).selected_chat_row().cloned().unwrap(); + assert_eq!(row.agent_origin, None); + let create = side.read(cx).unsaved_side_chat_create(&chat_id).unwrap(); + assert!(create.get("agentOrigin").is_none(), "{create}"); + }) + .unwrap(); + } + /// Review feedback on the side-chat pane: its links resolve, a closed /// tab keeps its draft, its model menu owns the digit shortcuts, and /// creation failures are shown. diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index f55f4d81c0..d2b2b4640c 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -2157,14 +2157,13 @@ impl AppState { /// now (t3-orchestration-v2 ticket 24): an existing chat whose turn is /// not in flight — neither Working nor parked on a question — and whose /// send is not pending. The picker stays locked while the chat works; - /// the engine refuses the switch for the same window. + /// the engine refuses the switch for the same window. Reads the + /// staleness-checked indicator (a pending send reads as Working): a + /// dead engine's frozen Working row must not lock the picker forever. pub(crate) fn harness_switchable(&self, chat_id: &str, now: DateTime<Utc>) -> bool { - if self.send_pending(chat_id, now) { - return false; - } !matches!( - self.session_for(chat_id).map(|session| session.status), - Some(roboco_proto::SessionStatus::Working | roboco_proto::SessionStatus::AwaitingInput) + self.indicator_for(chat_id, now), + Indicator::Working | Indicator::AwaitingInput ) } diff --git a/crates/wiregen/src/main.rs b/crates/wiregen/src/main.rs index e4a9e64d10..f7a38409ab 100644 --- a/crates/wiregen/src/main.rs +++ b/crates/wiregen/src/main.rs @@ -420,6 +420,11 @@ fn constants_ts() -> String { export const SUBAGENT_INPUT_KEEP: readonly string[] = [{}];\n", input_keep.join(", ") )); + out.push_str(&format!( + "\n/** The continue prompt a Limited chat's Resume sends (orchestration v2). */\n\ + export const RESUME_CONTINUE_PROMPT = {:?};\n", + roboco_proto::RESUME_CONTINUE_PROMPT + )); out } diff --git a/web/packages/app/src/components/composer-pickers.tsx b/web/packages/app/src/components/composer-pickers.tsx index d0a344e8bb..59fd0ce7e6 100644 --- a/web/packages/app/src/components/composer-pickers.tsx +++ b/web/packages/app/src/components/composer-pickers.tsx @@ -110,9 +110,10 @@ export interface ComposerPickersProps { * (it also writes the "Switched to <harness>" seam and drops the old * harness session, none of which `setChatConfig` does). Absent: the * switch is unavailable and the pick stays locked (the subagent-less - * hosts that never wired it). + * hosts that never wired it). `previous` is the draft before the + * optimistic flip — what a refused switch restores. */ - readonly onSwitchHarness?: (next: DraftConfig) => void; + readonly onSwitchHarness?: (next: DraftConfig, previous: DraftConfig) => void; /** * Whether the chat's harness picker is unlocked for that one-way switch * (ticket 24 — the desktop's `harness_switchable`): true only while the @@ -331,9 +332,10 @@ export function ComposerPickers(props: ComposerPickersProps) { return; } rememberHarness(harness); + const previous = draft; const next = switchHarnessDraft(chatConfig, harness, model ?? undefined); onDraft(next); - onSwitchHarness(next); + onSwitchHarness(next, previous); } function pickHarness(harness: HarnessId): void { diff --git a/web/packages/app/src/components/composer.tsx b/web/packages/app/src/components/composer.tsx index 75fb3dacf7..7379226a2b 100644 --- a/web/packages/app/src/components/composer.tsx +++ b/web/packages/app/src/components/composer.tsx @@ -2541,16 +2541,17 @@ export function Composer({ // new harness with its default picks); the draft is already flipped // optimistically, and the SwitchHarness RPC tells the engine, which // writes the "Switched to <harness>" seam, drops the old harness - // session, and arms the budgeted history for the next send. A failed - // switch surfaces here in the failure notice — the engine's row stayed - // on the old harness, and the chats watch reverts the row the desktop - // stamped. A re-pick of the chat's CURRENT row harness is a UI no-op — + // session, and arms the budgeted history for the next send. A refused + // switch surfaces here in the failure notice and restores the previous + // draft at once — the engine's row stayed on the old harness, and no + // chats-watch frame will come to undo the optimistic flip (the desktop's + // `revert_harness_switch`). A re-pick of the chat's CURRENT row harness is a UI no-op — // nothing to seam (the desktop's guard; the engine would only refuse // with "Chat is already on …"). One switch per chat at a time: a double // click must not seam twice. const switchInFlightRef = useRef<string | null>(null); const switchHarness = useCallback( - (next: DraftConfig) => { + (next: DraftConfig, previous: DraftConfig) => { if (switchInFlightRef.current === chat.id) { return; } @@ -2564,6 +2565,9 @@ export function Composer({ modelOptions: next.modelOptions, }) .catch((error: unknown) => { + // Only while the draft still shows this switch's flip: a later + // pick (or a reseed) wins. + setDraft((current) => (current === next ? previous : current)); setFailure({ message: `Could not switch the harness: ${describeSendError(error)}`, key: chat.id, @@ -2618,8 +2622,18 @@ export function Composer({ // reply). The desktop's `confirm_continue_on_harness` stamps the row the // same way so its `resolved()` reads the new harness; the web's stamp is // this parameter, applied over the draft with the sandbox kept. + // + // `bare`: a send that is NOT the composer's draft — the Limited banner's + // Resume (`resume_limited`). It sends only `typed`: staged files, review + // comments and the typed draft stay where they are, untouched either way + // the send ends. const send = useCallback( - async (typed: string, queue: boolean, runOverride?: Partial<DraftConfig>): Promise<void> => { + async ( + typed: string, + queue: boolean, + runOverride?: Partial<DraftConfig>, + bare = false, + ): Promise<void> => { // Existing busy chats always queue; compatibility was checked before // taking the draft (the capability gate below). if (queue) { @@ -2731,17 +2745,22 @@ export function Composer({ markSideChatSaved(chatId); } // Snapshot-and-clear NOW (`takeAttachments`): the strip empties the - // instant you hit send; a failure hands the files back by id. - const taken = staged; - setStagedByChat((current) => { - const next = { ...current }; - delete next[pageChatId]; - return next; - }); + // instant you hit send; a failure hands the files back by id. A bare + // send takes nothing. + const taken: typeof staged = bare ? [] : staged; + if (!bare) { + setStagedByChat((current) => { + const next = { ...current }; + delete next[pageChatId]; + return next; + }); + } // `take_review_comments` (composer.rs:6130-6136): the comment block is // staged onto the composer key the draft was opened against — for the // new-chat canvas that is `""`, the pre-mint key. - const takenComments = reviewCommentStore.takeComments(chat.id); + const takenComments: ReturnType<typeof reviewCommentStore.takeComments> = bare + ? [] + : reviewCommentStore.takeComments(chat.id); // `typed` keeps the user's own words for the failure hand-back below // (restoring a folded prompt would paste the trailer as literal text). // The echo cleanup key: null until an echo actually goes up, so a @@ -2791,8 +2810,10 @@ export function Composer({ }); pushedEchoId = messageId; } - setText(""); - chatDrafts.clear(pageChatId); + if (!bare) { + setText(""); + chatDrafts.clear(pageChatId); + } setFailure(null); setBusy(true); // Whole-send upload accounting (`begin_upload_progress`): the percent @@ -2896,8 +2917,10 @@ export function Composer({ if (pushedEchoId !== null) { echoStore.removeEcho(pushedEchoId); } - setText(typed); - chatDrafts.set(chatDraftKey(pageChatId, session.engine.baseUrl), typed); + if (!bare) { + setText(typed); + chatDrafts.set(chatDraftKey(pageChatId, session.engine.baseUrl), typed); + } // The taken comments stage back under the chat's key // (`add_review_comment(&restore_key, …)`, composer.rs:6663-6667). reviewCommentStore.restoreComments(pageChatId, takenComments); @@ -2935,10 +2958,9 @@ export function Composer({ // NORMAL run — same send path, same guards, same queue semantics as // typing it — never a special engine mode. The run clears the limit // record when it starts; messages queued on the chat stay behind the - // continuation. A draft the user was typing (and files they staged) is - // theirs, not part of the resume: the send clears the composer, so hand - // the slots straight back — `send` takes its own copies, so restoring - // them after it settles cannot leak into the sent message. + // continuation. A draft the user was typing (and the files and review + // comments they staged) is theirs, not part of the resume: the send is + // BARE — it carries only the continue prompt and never touches them. // // `override` (ticket 25, "Continue on another harness"): the switch's // landed picks — the continuation must carry the NEW harness and model @@ -2948,26 +2970,9 @@ export function Composer({ // `apply_chat_config` stamp before `resume_limited`. const resumeLimited = useCallback( (override?: Partial<DraftConfig>) => { - const draftText = textRef.current; - const stagedSnapshot = staged; - void send(RESUME_CONTINUE_PROMPT, false, override).then(() => { - if (draftText.length > 0) { - setText(draftText); - chatDrafts.set(textDraftKey, draftText); - } - if (stagedSnapshot.length > 0) { - setStagedByChat((current) => { - const fresh = current[chat.id] ?? []; - const merged = [ - ...stagedSnapshot.filter((att) => !fresh.some((f) => f.id === att.id)), - ...fresh, - ]; - return { ...current, [chat.id]: merged }; - }); - } - }); + void send(RESUME_CONTINUE_PROMPT, false, override, true); }, - [chat.id, staged, send, textDraftKey], + [send], ); // The banner's handle assignment: the strip above the composer holds the diff --git a/web/packages/app/src/components/fork-dialog.tsx b/web/packages/app/src/components/fork-dialog.tsx index e64b310abb..0bdf8f86c7 100644 --- a/web/packages/app/src/components/fork-dialog.tsx +++ b/web/packages/app/src/components/fork-dialog.tsx @@ -89,7 +89,7 @@ export const ForkDialogLayer = memo(function ForkDialogLayer() { {crossHarness && ( <div className="harness-choice-note"> A different harness starts fresh and receives a size-limited transcript of this chat, - not its full history. + not its full transcript. </div> )} {state.error !== null && <div className="harness-choice-error">{state.error}</div>} diff --git a/web/packages/app/src/components/limited-banner.tsx b/web/packages/app/src/components/limited-banner.tsx index 34fc12299a..216d70e5a3 100644 --- a/web/packages/app/src/components/limited-banner.tsx +++ b/web/packages/app/src/components/limited-banner.tsx @@ -229,7 +229,11 @@ export function LimitedBanner({ disabled={chipsDisabled} onClick={() => { if (client !== null) { - void scheduleResumeAtReset(client, chatId); + void scheduleResumeAtReset(client, chatId).catch((cause: unknown) => { + setNotice( + `Could not schedule the resume: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + }); } }} > @@ -244,7 +248,11 @@ export function LimitedBanner({ disabled={chipsDisabled} onClick={() => { if (client !== null) { - void cancelScheduledResume(client, chatId); + void cancelScheduledResume(client, chatId).catch((cause: unknown) => { + setNotice( + `Could not cancel the resume: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + }); } }} > @@ -511,8 +519,8 @@ function ContinueOnHarnessPicker({ <MenuHeading>Continue on another harness</MenuHeading> <div className="continue-card-body"> This chat hit its usage limit. Switching harnesses is one-way: the chat runs on the one - you pick from here on, and the new agent receives a size-limited transcript of the - conversation, then the continue prompt. + you pick from here on, and the new harness receives a size-limited transcript of the + chat, then the continue prompt. </div> <div className="harness-choice-heading">Harness</div> <div className="harness-choice-chips"> diff --git a/web/packages/app/src/components/surface-registry.tsx b/web/packages/app/src/components/surface-registry.tsx index be070ac6fc..c33529f4c6 100644 --- a/web/packages/app/src/components/surface-registry.tsx +++ b/web/packages/app/src/components/surface-registry.tsx @@ -420,19 +420,10 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str const availableWidth = width === null ? null : Math.min(Math.max(width, 0), COMPOSER_MAX_WIDTH); - if (session === null || sideChatId === null) { - return <SurfacePicker chatId={chatId} />; - } - // The row is gone for good (deleted): the picker, exactly the desktop's - // `render_side_chat` fallback. A just-created chat renders from its seed - // below. - if (effectiveChat === null || store === null) { - return <SurfacePicker chatId={chatId} />; - } - - const onOpenSubagent = (payload: SubagentOpen): void => { - rightPaneStore.addSubagentSurface(chatId, payload); - }; + // Every hook runs on every render — ABOVE the picker fallbacks below (the + // rules of hooks: a row that appears or vanishes must not change the + // hook order). + // // "Fork from here" (t3-orchestration-v2 tickets 21/22/26): a settled // reply in THIS side chat's transcript forks the side chat — the copy // hangs under its PARENT (a sibling, the desktop's `ForkFromEntry` rule) @@ -451,44 +442,12 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str }, [session, effectiveChat, chatId], ); - const onRetryDelivery = (): void => { - if (session.client.state !== "connected") { - return; - } - // Same shape as the chat page's `retry_send` half: restart the grace - // clocks (same message ids, so the overlay returns to Sending), then - // re-deliver through the engine's RETRY_DELIVERY. - echoStore.restartGrace(sideChatId, Date.now()); - void session.client - .call(methods.RETRY_DELIVERY, { chatId: sideChatId }) - .catch((error: unknown) => { - sidebarNotice.set( - `Could not retry delivery: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - }; - // The draft mirror (`onDraftChange` → `sideChatDrafts` + the text seed in - // `chatDrafts`); the staged seed reads the mirror back on mount. - const onDraftChange = (draft: { text: string; staged: readonly StagedAttachment[] }): void => { - sideChatDrafts.set(sideChatId, draft); - chatDrafts.set(sideChatId, draft.text); - }; - const deliveryDegraded = chatDeliveryDegraded(sessionWatch?.connectivity.value?.state); - const indicator = displayStatusFor(effectiveChat, statusRow, now); - const turnStartedAt = startedAtOf(statusRow); // The workspace commands' side-chat host (the composer's dispatch, the // side-chat pane's targets): the pane commands dock into THIS pane's strip // (the parent chat's pane — the host the side-chat tab itself lives in, // the web shape of the desktop's side-chat panel), `/rename` targets THIS // side chat, and the globals navigate like the chat page's. const navigate = useNavigate(); - // A completion notice's side row (ticket 15): open the side chat it - // reports — the route select, the web shape of the desktop's `open_chat` - // (shell/tabs.rs:205). A notice inside a side chat's own pane points at - // ITS side chats the same way. - const onOpenChat = (target: string): void => { - void navigate({ to: "/chat/$chatId", params: { chatId: target } }); - }; const workspaceCommands = useMemo<WorkspaceCommandActions>( () => ({ newChat: () => { @@ -502,19 +461,25 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str }, renameChat: () => { rightPaneStore.revealSurface(chatId, "files"); - chatRenameStore.begin(sideChatId); + if (sideChatId !== null) { + chatRenameStore.begin(sideChatId); + } }, }), [chatId, navigate, sideChatId], ); + const workspaceRoot = effectiveChat?.cwd ?? null; const markdownSurface = useMemo( () => ({ - workspaceRoot: effectiveChat.cwd, + workspaceRoot, // A side chat's file link opens an editor keyed to the SIDE CHAT (one // tab per (chat, path)), docked into this pane's strip — the desktop's // `activate_session_link` binding the editor to the side chat's own // checkout. openWorkspaceFile: (path: string, line: number | null, column: number | null) => { + if (sideChatId === null) { + return; + } rightPaneStore.addFileSurface( chatId, path, @@ -523,9 +488,55 @@ function SideChatSurface({ surfaceId, chatId }: { surfaceId: string; chatId: str ); }, }), - [effectiveChat.cwd, chatId, sideChatId], + [workspaceRoot, chatId, sideChatId], ); + if (session === null || sideChatId === null) { + return <SurfacePicker chatId={chatId} />; + } + // The row is gone for good (deleted): the picker, exactly the desktop's + // `render_side_chat` fallback. A just-created chat renders from its seed + // below. + if (effectiveChat === null || store === null) { + return <SurfacePicker chatId={chatId} />; + } + + const onOpenSubagent = (payload: SubagentOpen): void => { + rightPaneStore.addSubagentSurface(chatId, payload); + }; + const onRetryDelivery = (): void => { + if (session.client.state !== "connected") { + return; + } + // Same shape as the chat page's `retry_send` half: restart the grace + // clocks (same message ids, so the overlay returns to Sending), then + // re-deliver through the engine's RETRY_DELIVERY. + echoStore.restartGrace(sideChatId, Date.now()); + void session.client + .call(methods.RETRY_DELIVERY, { chatId: sideChatId }) + .catch((error: unknown) => { + sidebarNotice.set( + `Could not retry delivery: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }; + // The draft mirror (`onDraftChange` → `sideChatDrafts` + the text seed in + // `chatDrafts`); the staged seed reads the mirror back on mount. + const onDraftChange = (draft: { text: string; staged: readonly StagedAttachment[] }): void => { + sideChatDrafts.set(sideChatId, draft); + chatDrafts.set(sideChatId, draft.text); + }; + const deliveryDegraded = chatDeliveryDegraded(sessionWatch?.connectivity.value?.state); + const indicator = displayStatusFor(effectiveChat, statusRow, now); + const turnStartedAt = startedAtOf(statusRow); + // A completion notice's side row (ticket 15): open the side chat it + // reports — the route select, the web shape of the desktop's `open_chat` + // (shell/tabs.rs:205). A notice inside a side chat's own pane points at + // ITS side chats the same way. + const onOpenChat = (target: string): void => { + void navigate({ to: "/chat/$chatId", params: { chatId: target } }); + }; + return ( <div className="side-chat-surface" ref={hostRef}> <div className="side-chat-transcript"> diff --git a/web/packages/app/src/lib/limited.ts b/web/packages/app/src/lib/limited.ts index 6e801c83ee..56b40dbf72 100644 --- a/web/packages/app/src/lib/limited.ts +++ b/web/packages/app/src/lib/limited.ts @@ -8,8 +8,9 @@ import type { ScheduledResume, SessionLimit, } from "@roboco/proto"; +import type { ChatStatus } from "@roboco/engine-client"; import { offeredHarnesses } from "./model-rows"; -import { formatLimitReset, formatScheduledResume } from "./view"; +import { effectiveIndicator, formatLimitReset, formatScheduledResume } from "./view"; /** * Limited chats on the web — the peer of the desktop's banner/failover @@ -23,12 +24,26 @@ import { formatLimitReset, formatScheduledResume } from "./view"; */ /** - * The continue prompt the fixed actions send — `roboco_proto::RESUME_CONTINUE_PROMPT` - * ("Continue where you left off."). Not wiregen'd to TS (it is a const, - * not a type), so the web carries its own copy; the Rust side owns the - * engine's run of the same words. + * The continue prompt the fixed actions send — `roboco_proto::RESUME_CONTINUE_PROMPT`, + * emitted by wiregen into `@roboco/proto`'s constants (one source of truth + * for the engine's and the web's words). */ -export const RESUME_CONTINUE_PROMPT = "Continue where you left off."; +export { RESUME_CONTINUE_PROMPT } from "@roboco/proto"; + +/** + * Whether the chat page's Limited banner shows for the chat's raw status + * row — gated by the send overlay exactly like `ChatRow.limited` + * (lib/view.ts) and the desktop's `indicator_for`: a send in flight + * (Resume's own continue prompt included) reads as Working, never Limited, + * so Resume cannot be pressed twice. Pure. + */ +export function limitedBannerShows( + sending: boolean, + session: ChatStatus | undefined, + now: number, +): boolean { + return !sending && effectiveIndicator(session, now) === "limited"; +} /** How long a loaded candidate list stays fresh while the banner shows. */ export const FAILOVER_LIST_TTL_MS = 30_000; diff --git a/web/packages/app/src/routes/chat-page.tsx b/web/packages/app/src/routes/chat-page.tsx index 1a81dd09e0..f73aafbfdc 100644 --- a/web/packages/app/src/routes/chat-page.tsx +++ b/web/packages/app/src/routes/chat-page.tsx @@ -12,7 +12,8 @@ import { engineRegistry, engineStatesOf, useFleetRegistry, useFleetSnapshot } fr import { useNow, useWatchSnapshot } from "../state/hooks"; import { useTitlebar } from "../state/chrome"; import { emitShortcut } from "../state/shortcuts"; -import { chatPageRow, effectiveIndicator, type ChatRow } from "../lib/view"; +import { chatPageRow, type ChatRow } from "../lib/view"; +import { limitedBannerShows } from "../lib/limited"; import { JumpPill, StatusStrip, @@ -1227,7 +1228,9 @@ export function ConversationPage() { const sessionRow = hasSelection ? snapshot.statuses.rows.find((row) => row.chatId === chatId) ?? null : null; - const sessionRowLimited = effectiveIndicator(sessionRow ?? undefined, now) === "limited"; + // Gated by the send overlay (`limitedBannerShows`): a send in flight — + // Resume's own continue prompt included — steps the banner aside. + const sessionRowLimited = limitedBannerShows(sending, sessionRow ?? undefined, now); // The session row's `started_at` — the working trailer's timer base // (`session_for(chat_id).started_at` on the desktop). diff --git a/web/packages/app/src/state/fork-dialog.ts b/web/packages/app/src/state/fork-dialog.ts index 920f22e639..c1b3dd6782 100644 --- a/web/packages/app/src/state/fork-dialog.ts +++ b/web/packages/app/src/state/fork-dialog.ts @@ -204,11 +204,20 @@ export class ForkDialogStore { title: childChatTitle(chat), }); endSideChatCreate(); + // The dialog may have been closed (and even reopened for another + // fork) while this one was in flight: only THIS spec's dialog closes + // — the same staleness guard the catalog loads use. + if (this.#state.spec !== spec) { + return; + } this.#state = { ...CLOSED }; this.#commit(); }) .catch((cause: unknown) => { endSideChatCreate(); + if (this.#state.spec !== spec) { + return; // closed or reopened under us: the reason has no dialog + } this.#state = { ...this.#state, creating: false, diff --git a/web/packages/app/tests/composer-resume-switch.test.ts b/web/packages/app/tests/composer-resume-switch.test.ts index db634beefa..9b55310e5d 100644 --- a/web/packages/app/tests/composer-resume-switch.test.ts +++ b/web/packages/app/tests/composer-resume-switch.test.ts @@ -21,6 +21,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest import type { Chat } from "@roboco/proto"; import type { DraftConfig } from "../src/lib/composer-actions"; import { RESUME_CONTINUE_PROMPT } from "../src/lib/limited"; +import { newFileComment } from "../src/lib/review-comments"; +import { reviewCommentStore } from "../src/state/review-comments"; import { Composer } from "../src/components/composer"; import { PickerCatalog } from "../src/state/picker-catalog"; import type { EngineSession } from "../src/state/engine-session"; @@ -227,4 +229,27 @@ describe("resumeLimited with a switched-picks override (tickets 25/26)", () => { expect(request.harness).toBe("claude-code"); expect(request.model).toBe("sonnet"); }); + + it("Resume sends only the continue prompt and leaves staged review comments alone", async () => { + const staged = newFileComment("src/lib.rs", 3, "rename this"); + reviewCommentStore.addComment("chat-limited", staged); + try { + mountComposer(); + await flush(); + act(() => { + hostRef.current!(); + }); + await flush(); + const request = (queueCommand()!.command as Record<string, unknown>).request as Record< + string, + unknown + >; + // Exactly the continue prompt: no comment block folded in. + expect(request.prompt).toBe(RESUME_CONTINUE_PROMPT); + // The staged comment is still the user's, untouched by the resume. + expect(reviewCommentStore.stagedFor("chat-limited").map((c) => c.id)).toEqual([staged.id]); + } finally { + reviewCommentStore.purgeChat("chat-limited"); + } + }); }); diff --git a/web/packages/app/tests/fork-dialog.test.ts b/web/packages/app/tests/fork-dialog.test.ts index 1e4347d6ed..891346c91c 100644 --- a/web/packages/app/tests/fork-dialog.test.ts +++ b/web/packages/app/tests/fork-dialog.test.ts @@ -374,4 +374,40 @@ describe("ForkDialogStore", () => { expect(state.spec?.source.id).toBe("source-2"); expect(state.harness).toBe("codex"); }); + + it("a fork settling after close never writes into a reopened dialog", async () => { + let fail: (cause: Error) => void = () => {}; + const pending = new Promise<Chat>((_, reject) => { + fail = reject; + }); + const { client } = fakeClient(() => pending); + forkDialogStore.open({ + source: chatRow(), + parentId: "source-1", + boundary: null, + paneChatId: "pane-i", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + forkDialogStore.confirm(); + // Closed while the fork is in flight, then reopened for another source. + forkDialogStore.close(); + forkDialogStore.open({ + source: chatRow({ id: "source-2" }), + parentId: "source-2", + boundary: null, + paneChatId: "pane-i", + client, + engineBaseUrl: ENGINE, + }); + await flush(); + fail(new Error("the old fork failed")); + await flush(); + const state = forkDialogStore.getSnapshot(); + expect(state.open).toBe(true); + expect(state.spec?.source.id).toBe("source-2"); + expect(state.error).toBeNull(); + expect(state.creating).toBe(false); + }); }); diff --git a/web/packages/app/tests/limited.test.ts b/web/packages/app/tests/limited.test.ts index 558ce8302a..c2dd145d0c 100644 --- a/web/packages/app/tests/limited.test.ts +++ b/web/packages/app/tests/limited.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { EngineClient } from "@roboco/engine-client"; +import type { ChatStatus, EngineClient } from "@roboco/engine-client"; import type { AgentAccount, AgentUsageWindow, @@ -18,6 +18,7 @@ import { failoverRows, harnessOffersFailover, limitedBannerLabel, + limitedBannerShows, listResumeAccountCandidates, resumeAtResetArmable, resumeOnAnotherAccount, @@ -82,6 +83,33 @@ describe("RESUME_CONTINUE_PROMPT", () => { }); }); +// ── The banner's gate ──────────────────────────────────────────────────── + +describe("limitedBannerShows", () => { + const limitedRow: ChatStatus = { + chatId: "chat", + deviceId: "device-1", + status: "errored", + startedAt: null, + updatedAt: new Date(NOW).toISOString(), + lastCompletedTurn: null, + limit: limit({ resetsAt: ms(60) }), + }; + + it("shows for a Limited row with no send in flight", () => { + expect(limitedBannerShows(false, limitedRow, NOW)).toBe(true); + }); + + it("steps aside while a send is in flight, so Resume cannot be pressed twice", () => { + expect(limitedBannerShows(true, limitedRow, NOW)).toBe(false); + }); + + it("never shows for a plain error or no row", () => { + expect(limitedBannerShows(false, { ...limitedRow, limit: null }, NOW)).toBe(false); + expect(limitedBannerShows(false, undefined, NOW)).toBe(false); + }); +}); + // ── The banner's label and arms ────────────────────────────────────────── describe("limitedBannerLabel", () => { diff --git a/web/packages/proto/src/generated/constants.ts b/web/packages/proto/src/generated/constants.ts index 6cffe3b90c..c34fc17170 100644 --- a/web/packages/proto/src/generated/constants.ts +++ b/web/packages/proto/src/generated/constants.ts @@ -32,3 +32,6 @@ export const SUBAGENT_MODEL_KEYS: readonly string[] = ["model", "modelId", "mode /** The only spawn-input keys preserved in a doc-side tool call. */ export const SUBAGENT_INPUT_KEEP: readonly string[] = ["model", "modelId", "model_id", "subagent_model", "subagent_type"]; + +/** The continue prompt a Limited chat's Resume sends (orchestration v2). */ +export const RESUME_CONTINUE_PROMPT = "Continue where you left off."; From 16668c28ac7eee61f21bd39eada432ee0b2d9107 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 13:59:52 +0700 Subject: [PATCH 84/96] =?UTF-8?q?test:=20settle=20the=20merge=20=E2=80=94?= =?UTF-8?q?=20claude=20fallback=20results,=20eight-char=20short=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR branch now forwards a result frame's text on Done: the two new Claude launch-probe fallback tests (B11/B12) expect it. The short-id helpers keep eight characters ("chat-sid"): the engine's char-boundary test and the web notice tests asserted seven. --- crates/engine/src/completion_notices.rs | 2 +- crates/harness/tests/claude.rs | 6 ++++-- .../app/tests/transcript-completion-notice-control.test.ts | 2 +- web/packages/app/tests/transcript-completion-notice.test.ts | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 7b8677b523..60309329a5 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -1110,7 +1110,7 @@ mod tests { #[test] fn short_id_cuts_at_a_char_boundary() { // ASCII ids keep the full eight chars. - assert_eq!(short_id("chat-side-1234"), "chat-si"); + assert_eq!(short_id("chat-side-1234"), "chat-sid"); assert_eq!(short_id("short"), "short"); // A multibyte id never panics: the cut backs up to the nearest // boundary (the review's byte-slice crash). diff --git a/crates/harness/tests/claude.rs b/crates/harness/tests/claude.rs index ddb6bca839..eebd8e3252 100644 --- a/crates/harness/tests/claude.rs +++ b/crates/harness/tests/claude.rs @@ -362,7 +362,8 @@ async fn a_fork_that_errors_after_launch_retries_fresh_with_the_budget() { events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + // The fallback turn's final text rides the Done like any turn's. + result: Some("bad uuid fallback".into()), error: None, session_id: Some("sess-fork-bad-fresh".into()), }) @@ -404,7 +405,8 @@ async fn a_resume_refused_after_launch_retries_fresh_with_the_fallback_history() events.last(), Some(&AgentEvent::Done { status: DoneStatus::Completed, - result: None, + // The fallback turn's final text rides the Done like any turn's. + result: Some("resume fallback".into()), error: None, session_id: Some("sess-resume-fresh".into()), }) diff --git a/web/packages/app/tests/transcript-completion-notice-control.test.ts b/web/packages/app/tests/transcript-completion-notice-control.test.ts index b6eef7f0cb..388a92493a 100644 --- a/web/packages/app/tests/transcript-completion-notice-control.test.ts +++ b/web/packages/app/tests/transcript-completion-notice-control.test.ts @@ -102,7 +102,7 @@ describe("the completion notice's open control", () => { const ids = Array.from( container!.querySelectorAll(".completion-notice-side-id"), ).map((node) => node.textContent); - expect(ids).toEqual(["chat-si", "chat-si"]); + expect(ids).toEqual(["chat-sid", "chat-sid"]); const outcomes = Array.from( container!.querySelectorAll(".completion-notice-side-outcome"), ).map((node) => node.textContent); diff --git a/web/packages/app/tests/transcript-completion-notice.test.ts b/web/packages/app/tests/transcript-completion-notice.test.ts index d316aac39d..6b802a106f 100644 --- a/web/packages/app/tests/transcript-completion-notice.test.ts +++ b/web/packages/app/tests/transcript-completion-notice.test.ts @@ -181,7 +181,7 @@ describe("the completion notice row", () => { describe("the notice side-row helpers", () => { it("shortens an id to eight characters without splitting a character", () => { - expect(shortChatId("chat-side-1234")).toBe("chat-si"); + expect(shortChatId("chat-side-1234")).toBe("chat-sid"); expect(shortChatId("short")).toBe("short"); // A surrogate pair never splits. const wide = "😀😀😀😀😀😀😀😀😀"; From ffc04d6c3ecd57b70df2a62779d8aa8bd16fbcf2 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 14:13:08 +0700 Subject: [PATCH 85/96] fix(doc,harness): notice sides live in roboco-doc; the pi tests import ForkBoundary --- crates/doc/src/queue.rs | 6 +++--- crates/harness/src/pi/mod.rs | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/queue.rs b/crates/doc/src/queue.rs index 0907309cfb..3514d5bf90 100644 --- a/crates/doc/src/queue.rs +++ b/crates/doc/src/queue.rs @@ -63,7 +63,7 @@ pub struct QueuedMessage { /// never as a user message. `None` for every other row. A later capped /// delivery merges its sides into the existing row's. #[serde(default, skip_serializing_if = "Option::is_none")] - pub completion_notice: Option<Vec<roboco_proto::NoticeSide>>, + pub completion_notice: Option<Vec<crate::NoticeSide>>, } /// Why a queued row is not currently eligible for automatic or explicit @@ -540,10 +540,10 @@ mod tests { fn completion_notice_sides_round_trip_and_old_rows_read_as_plain() { let doc = doc(); let mut parked = item("notice:1", "Side chat reached outcomes."); - parked.completion_notice = Some(vec![roboco_proto::NoticeSide { + parked.completion_notice = Some(vec![crate::NoticeSide { chat_id: "chat-side-1234".into(), title: "Implement login".into(), - outcome: roboco_proto::NoticeOutcome::Completed, + outcome: crate::NoticeOutcome::Completed, detail: None, }]); doc.push_queued(&parked).unwrap(); diff --git a/crates/harness/src/pi/mod.rs b/crates/harness/src/pi/mod.rs index e56c3a1787..f47e2a30f5 100644 --- a/crates/harness/src/pi/mod.rs +++ b/crates/harness/src/pi/mod.rs @@ -3009,7 +3009,8 @@ fn image_mime_type(path: &std::path::Path, bytes: &[u8]) -> Option<&'static str> mod tests { use super::steering_mode_configured; use super::{ - SessionEntries, branch_user_entries, fork_boundary, newest_user_entry, parse_entries, + ForkBoundary, SessionEntries, branch_user_entries, fork_boundary, newest_user_entry, + parse_entries, }; use std::path::Path; From 44000a263787986c2e3bb387780fe7d92773b323 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 14:18:40 +0700 Subject: [PATCH 86/96] fix(engine,ui): settle the review-fix compile slips; parked notices release under their row id; a refused switch drops the local picks --- crates/engine/src/completion_notices.rs | 6 +++--- crates/engine/src/doc_host.rs | 7 ++++++- crates/engine/src/engine_settings.rs | 2 +- crates/engine/src/scheduled_resumes.rs | 2 +- crates/engine/src/sessions.rs | 2 ++ crates/engine/tests/completion_notices.rs | 21 ++++++++++++++------- crates/engine/tests/side_chats.rs | 2 +- crates/ui/examples/appshots-fixture.rs | 2 ++ crates/ui/src/pickers.rs | 7 +++++++ crates/ui/src/state.rs | 1 + 10 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/completion_notices.rs b/crates/engine/src/completion_notices.rs index 60309329a5..512dd60670 100644 --- a/crates/engine/src/completion_notices.rs +++ b/crates/engine/src/completion_notices.rs @@ -355,7 +355,7 @@ impl CompletionNotices { pub(crate) fn settle(&self, chat_id: &str) -> u64 { let mut settles = lock(&self.settles); let marks = settles.entry(chat_id.to_string()).or_default(); - let serial = marks.last().map(|mark| mark.serial).unwrap_or(0) + 1; + let serial = marks.back().map(|mark| mark.serial).unwrap_or(0) + 1; marks.push_back(SettleMark { serial, at: now_ms(), @@ -371,7 +371,7 @@ impl CompletionNotices { pub(crate) fn settle_serial(&self, chat_id: &str) -> u64 { lock(&self.settles) .get(chat_id) - .and_then(|marks| marks.last()) + .and_then(|marks| marks.back()) .map(|mark| mark.serial) .unwrap_or(0) } @@ -662,7 +662,7 @@ pub(crate) fn chat_archived(engine: &SessionsEngine, chat_id: &str) -> bool { let Some(host) = engine.host() else { return false; }; - match host.workspace().and_then(|ws| ws.chat(chat_id)) { + match host.workspace().map(|ws| ws.chat(chat_id)) { Some(Ok(Some(chat))) => chat.archived, Some(Ok(None)) => true, // the chat is gone _ => false, // the row is unreadable: keep talking, never eat a report diff --git a/crates/engine/src/doc_host.rs b/crates/engine/src/doc_host.rs index 6d04037071..cfd2d00573 100644 --- a/crates/engine/src/doc_host.rs +++ b/crates/engine/src/doc_host.rs @@ -1216,6 +1216,7 @@ impl DocHost { edited_at: None, delivery_gate: None, origin_chat_id: origin_chat_id.map(str::to_string), + completion_notice: None, })?; handle.publish_queue(); // Same reasoning as a command: the user is acting in this chat again. @@ -2022,11 +2023,14 @@ impl DocHost { // it and the turn starts on the notice's text with the notice's id // (the loop counter still reads it as notice-started). if let Some(sides) = item.completion_notice.clone() { - let entry = crate::completion_notices::notice_entry( + let mut entry = crate::completion_notices::notice_entry( self.inner.config.device_id.as_str(), &sides, &item.text, ); + // The row's own id (already `notice:`-prefixed): the dispatch + // below writes under `message_id`, and only a shared id dedupes. + entry.id = message_id.clone(); if let Err(err) = handle.doc().push_message(&entry) { tracing::warn!(chat = %chat_id, error = %err, "parked completion-notice entry write failed"); @@ -3137,6 +3141,7 @@ impl DocHost { edited_at: None, delivery_gate: None, origin_chat_id, + completion_notice: None, }; if handle.doc.read_queue()?.iter().any(|row| row.id == item.id) { return Ok(()); // a redelivered command: already held diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs index eedf67cbc2..644fb2bcba 100644 --- a/crates/engine/src/engine_settings.rs +++ b/crates/engine/src/engine_settings.rs @@ -106,7 +106,7 @@ fn write_file(path: &Path, settings: &EngineSettings) -> std::io::Result<()> { /// the rename stays on one filesystem. fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write; - let dir = path.parent().unwrap_or_else(|| path.as_path()); + let dir = path.parent().unwrap_or(Path::new(".")); let mut staged = tempfile::NamedTempFile::new_in(dir)?; staged.write_all(bytes)?; staged.flush()?; diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 733feb5b1a..6ebb79be90 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -270,7 +270,7 @@ fn write_file(path: &Path, file: &ScheduledResumeFile) -> std::io::Result<()> { /// the rename stays on one filesystem. fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write; - let dir = path.parent().unwrap_or_else(|| path.as_path()); + let dir = path.parent().unwrap_or(Path::new(".")); let mut staged = tempfile::NamedTempFile::new_in(dir)?; staged.write_all(bytes)?; staged.flush()?; diff --git a/crates/engine/src/sessions.rs b/crates/engine/src/sessions.rs index a8c432326b..f6509d9bd1 100644 --- a/crates/engine/src/sessions.rs +++ b/crates/engine/src/sessions.rs @@ -784,6 +784,7 @@ impl SessionsEngine { message_id: Option<String>, origin_chat_id: Option<String>, startup_retry: bool, + sent_at: Option<i64>, ) -> futures::future::BoxFuture<'a, Result<String, EngineError>> { Box::pin(self.dispatch_inner( chat_id, @@ -1776,6 +1777,7 @@ impl SessionsEngine { request, Some(user_id), origin_chat_id, + None, ) .await { diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 6a614bd8d5..94fbd2e05a 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -1446,13 +1446,20 @@ async fn a_deleted_parent_is_owed_nothing() { ) .await; - // The parent is deleted (the delete path drops what it is owed). - core.workspace.delete_chat(PARENT).unwrap(); - assert_eq!( - ledger_rows(dir.path()), - 0, - "the deleted parent's pending outcomes go with the chat" - ); + // The parent is deleted through the RPC (the delete path purges the + // chat, dropping what it is owed). + client + .call( + roboco_rpc::methods::MUTATE, + serde_json::json!({ "op": "deleteChat", "chatId": PARENT }), + ) + .await + .expect("deleteChat"); + wait_for( + || ledger_rows(dir.path()) == 0, + "the deleted parent's pending outcomes to go with the chat", + ) + .await; // Nothing wakes the gone chat: no notice turn was started for it. tokio::time::sleep(Duration::from_millis(300)).await; diff --git a/crates/engine/tests/side_chats.rs b/crates/engine/tests/side_chats.rs index ade537ef4e..221cb3ab8f 100644 --- a/crates/engine/tests/side_chats.rs +++ b/crates/engine/tests/side_chats.rs @@ -1463,7 +1463,7 @@ async fn a_fork_onto_the_boundarys_harness_drops_the_current_configs_picks() { harness: HarnessId::ClaudeCode, model: Some("claude-sonnet".into()), reasoning: Some(roboco_proto::ReasoningLevel::High), - model_options: serde_json::Map::from([( + model_options: serde_json::Map::from_iter([( "claudeOption".into(), serde_json::json!("on"), )]), diff --git a/crates/ui/examples/appshots-fixture.rs b/crates/ui/examples/appshots-fixture.rs index 7652518b29..7df616c0dd 100644 --- a/crates/ui/examples/appshots-fixture.rs +++ b/crates/ui/examples/appshots-fixture.rs @@ -136,6 +136,7 @@ fn main() -> anyhow::Result<()> { let queue = vec![ roboco_doc::QueuedMessage { origin_chat_id: None, + completion_notice: None, id: "queued-review".into(), text: body, attachments: paths.clone(), @@ -147,6 +148,7 @@ fn main() -> anyhow::Result<()> { }, roboco_doc::QueuedMessage { origin_chat_id: None, + completion_notice: None, id: "queued-text".into(), text: "Then check the spacing and keyboard navigation.".into(), attachments: vec![], diff --git a/crates/ui/src/pickers.rs b/crates/ui/src/pickers.rs index 06cd15306f..8ab51ff3ff 100644 --- a/crates/ui/src/pickers.rs +++ b/crates/ui/src/pickers.rs @@ -2110,6 +2110,13 @@ impl Pickers { cx.notify(); }); } + // The refused pick still sits in the local picks, which shadow the + // chat row: drop them so the picker reads the restored row again. + if self.state.read(cx).selected_chat.as_deref() == Some(chat_id) { + self.config.harness = None; + self.config.model = None; + self.config.reasoning = None; + } cx.emit(HarnessSwitchFailed( format!("Could not switch harness: {reason}").into(), )); diff --git a/crates/ui/src/state.rs b/crates/ui/src/state.rs index d2b2b4640c..a0b6c92718 100644 --- a/crates/ui/src/state.rs +++ b/crates/ui/src/state.rs @@ -5477,6 +5477,7 @@ mod tests { }; let row = |id: &str| roboco_doc::QueuedMessage { origin_chat_id: None, + completion_notice: None, id: id.into(), text: "held".into(), attachments: vec![], From 812ec3912cff7e82a7eaeff47b45f62c62922e2a Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 14:21:19 +0700 Subject: [PATCH 87/96] fix(engine): atomic writes discard the persisted file handle --- crates/engine/src/engine_settings.rs | 2 +- crates/engine/src/scheduled_resumes.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/engine_settings.rs b/crates/engine/src/engine_settings.rs index 644fb2bcba..780a986cda 100644 --- a/crates/engine/src/engine_settings.rs +++ b/crates/engine/src/engine_settings.rs @@ -111,7 +111,7 @@ fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { staged.write_all(bytes)?; staged.flush()?; staged.as_file().sync_all()?; - staged.persist(path).map_err(|err| err.error) + staged.persist(path).map(drop).map_err(|err| err.error) } fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { diff --git a/crates/engine/src/scheduled_resumes.rs b/crates/engine/src/scheduled_resumes.rs index 6ebb79be90..a982c9d496 100644 --- a/crates/engine/src/scheduled_resumes.rs +++ b/crates/engine/src/scheduled_resumes.rs @@ -275,7 +275,7 @@ fn write_file_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { staged.write_all(bytes)?; staged.flush()?; staged.as_file().sync_all()?; - staged.persist(path).map_err(|err| err.error) + staged.persist(path).map(drop).map_err(|err| err.error) } fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { From e2bfa708495f98251acdc22a6cd45a2319d24726 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 14:43:47 +0700 Subject: [PATCH 88/96] fix(engine): the failover test seeds the usage cache in its camelCase shape --- crates/engine/tests/account_failover.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/tests/account_failover.rs b/crates/engine/tests/account_failover.rs index 759dd4f02c..7a51db8fd8 100644 --- a/crates/engine/tests/account_failover.rs +++ b/crates/engine/tests/account_failover.rs @@ -378,8 +378,9 @@ fn write_slot( .unwrap_or_else(|| serde_json::json!({ "entries": {} })); cache["entries"][&format!("claude-code:{account_key}")] = serde_json::json!({ "usage": { "windows": serde_json::from_str::<serde_json::Value>(usage).unwrap() }, - "checked_at": chrono::Utc::now().timestamp_millis(), - "sign_in": sign_in, + "fetchedAt": chrono::Utc::now().timestamp_millis(), + "checkedAt": chrono::Utc::now().timestamp_millis(), + "signIn": sign_in, }); std::fs::write(&cache_path, cache.to_string()).expect("usage cache"); } From 51cf5ad8dc4e3234d8b69c020604d179469eba6d Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 16:58:42 +0700 Subject: [PATCH 89/96] fix(engine): agent_origin tests fork a settled turn, drop the core before restart, confirm steers - user_created_chats_are_never_marked_agent_created seeded only a user entry; the fork RPC copies through the latest complete reply, so it refused. Seed a settled turn like the agent-fork test does. - agent_created_side_chats_record_their_creator_and_notify_durably reassembled while the first EngineCore (and its data-dir instance lock) was still alive. Drop it first, as restart_resume does. - steer_commands_stamp_their_origin_on_the_user_entry: HeldHarness drained steers without a Steered boundary, so the run exit re-dispatched the unconfirmed steer as a fresh turn that hung past the single finish. The harness now confirms each steer like a step-boundary agent. --- crates/engine/tests/agent_origin.rs | 79 +++++++++++++++-------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/crates/engine/tests/agent_origin.rs b/crates/engine/tests/agent_origin.rs index 32d0f0fac2..e69c461d83 100644 --- a/crates/engine/tests/agent_origin.rs +++ b/crates/engine/tests/agent_origin.rs @@ -32,7 +32,8 @@ const PARENT: &str = "chat-parent-1"; /// A turn that runs until the test says so, so "busy" is a state the test /// controls. Steers are accepted through the mailbox (the entry is written -/// before the harness ever sees the prompt). +/// before the harness ever sees the prompt) and confirmed with a `Steered` +/// boundary. struct HeldHarness { steering: SteeringMode, finish: tokio::sync::broadcast::Sender<()>, @@ -78,39 +79,56 @@ impl Harness for HeldHarness { self.prompts.lock().unwrap().push(request.prompt.clone()); let mut finish = self.finish.subscribe(); let mut steering = controls.steering; - let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + let _ = tx.try_send(Ok(AgentEvent::SessionStarted { harness: HarnessId::Mock, model: "mock-1".into(), tools: vec![], cwd: request.cwd.clone(), session_id: "sess-origin".into(), assistant_message_id: format!("a-{}", request.prompt), - })]); - let done = futures::stream::once(async move { + })); + tokio::spawn(async move { + let done = || { + Ok(AgentEvent::Done { + status: DoneStatus::Completed, + result: None, + error: None, + session_id: Some("sess-origin".into()), + }) + }; loop { tokio::select! { _ = finish.recv() => { - return Ok(AgentEvent::Done { - status: DoneStatus::Completed, - result: None, - error: None, - session_id: Some("sess-origin".into()), - }); + let _ = tx.send(done()).await; + break; } steer = steering.recv() => { if steer.is_none() { - return Ok(AgentEvent::Done { - status: DoneStatus::Completed, - result: None, - error: None, - session_id: Some("sess-origin".into()), - }); + let _ = tx.send(done()).await; + break; + } + // Confirm the steer's boundary like a real + // step-boundary agent: an accepted steer that never + // gets one is re-dispatched as a fresh turn when the + // run exits (a sent message must never silently not + // run), and that turn would hang past the test's + // one `finish`. + if tx + .send(Ok(AgentEvent::Steered { + assistant_message_id: None, + next_assistant_message_id: None, + })) + .await + .is_err() + { + break; } } } } }); - Ok(started.chain(done).boxed()) + Ok(futures::stream::poll_fn(move |cx| rx.poll_recv(cx)).boxed()) } } @@ -249,6 +267,9 @@ async fn agent_created_side_chats_record_their_creator_and_notify_durably() { let _ = harness.finish.send(()); core.shutdown().await; drop(client); + // The data-dir instance lock lives as long as the core: drop it before + // the restart, exactly like an app restart releases it. + drop(core); // The row is durable: the restart reads it back exactly. let restarted = @@ -319,27 +340,9 @@ async fn user_created_chats_are_never_marked_agent_created() { .expect("createChat"); // A fork of a chat with history: the copy lists under the parent but is - // user-made. - let source = core.doc_host.open("plain").unwrap(); - source - .doc() - .push_message(&SessionMessageEntry { - origin_chat_id: None, - id: "u1".into(), - role: MessageRole::User, - parts: vec![MessagePart::Text { - id: "t1".into(), - text: "remember this".into(), - }], - created_at: 1, - device_id: core.device_id.clone(), - status: Some(roboco_doc::MessageStatus::Complete), - continuation_of: None, - duration_ms: None, - provider_turn: None, - }) - .unwrap(); - drop(source); + // user-made. The fork button copies through the latest complete reply, + // so the source needs a settled turn, not just a user message. + settled_turn(&core, "plain"); client .call( roboco_rpc::methods::FORK_SIDE_CHAT, From 70f1945275dfc4aad72148e793be1d94932c10dc Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 17:12:45 +0700 Subject: [PATCH 90/96] fix(engine): continue_on_harness, limited_chats and completion_notices tests match the engine - continue_on_harness queue_run sends from the chat row's cwd (/tmp): queued rows dispatch from the row and a harness session only resumes from the cwd it was opened in, so the follow-up never resumed the continuation's session. - The Limit stand-in steers at the turn boundary like the Claude driver and the opening turn runs the held Run's model, so the mid-turn Run is held instead of replacing the limited turn; the test waits for the hold before ending the turn. - Waits for a new turn key on the mock holding the run (prompt recorded, after subscribing to finish) instead of Working, which publishes before run() subscribes and could drop the finish signal. - The loop-cap test's last wait is for the 27th notice entry; 26 was already true before round 27. --- crates/engine/tests/completion_notices.rs | 2 +- crates/engine/tests/continue_on_harness.rs | 46 ++++++++++++++++------ crates/engine/tests/limited_chats.rs | 13 +++--- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 94fbd2e05a..c7f82e7e5e 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -2200,7 +2200,7 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { queue_run(&client, SIDE, "round 27", "m-run-27", Some(PARENT)).await; wait_idle(&core, SIDE).await; wait_for( - || notice_entries(&core, PARENT).len() == 26, + || notice_entries(&core, PARENT).len() == 27, "the count reset lets the next notice start a turn again", ) .await; diff --git a/crates/engine/tests/continue_on_harness.rs b/crates/engine/tests/continue_on_harness.rs index eff81aaed7..2465393974 100644 --- a/crates/engine/tests/continue_on_harness.rs +++ b/crates/engine/tests/continue_on_harness.rs @@ -72,8 +72,11 @@ impl Harness for Limit { fn display_name(&self) -> &str { "Limit" } + // A turn-boundary agent like the Claude driver it stands in for: a Run + // sent mid-turn on the same runtime is held for the turn's end, not + // dispatched over the live turn. fn supports_steering(&self) -> bool { - false + true } fn steering_mode(&self) -> SteeringMode { SteeringMode::TurnBoundary @@ -89,8 +92,10 @@ impl Harness for Limit { request: RunRequest, _controls: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { - self.prompts.lock().unwrap().push(request.prompt.clone()); + // Subscribe before recording the prompt: a test that sees the prompt + // may send `finish` at once. let mut finish = self.finish.subscribe(); + self.prompts.lock().unwrap().push(request.prompt.clone()); Ok(futures::stream::once(async move { // The turn runs until the test says it ends — then it dies on // the usage limit exactly the way the driver reports one. @@ -173,10 +178,10 @@ impl Harness for Capture { request: RunRequest, _: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + let mut finish = self.finish.subscribe(); self.requests.lock().unwrap().push(request.clone()); let reply = self.reply.to_string(); let session = self.session.to_string(); - let mut finish = self.finish.subscribe(); Ok(futures::stream::once(async move { // The turn runs until the test says it ends. let _ = finish.recv().await; @@ -236,7 +241,9 @@ fn queue_run( model: model.map(str::to_string), reasoning: None, model_options: Default::default(), - cwd: ".".into(), + // The chat row's cwd: queued rows dispatch from the row, and a + // harness session only resumes from the cwd it was opened in. + cwd: "/tmp".into(), sandbox: SandboxLevel::WorkspaceWrite, auto_approve: true, attachments: Vec::new(), @@ -521,7 +528,16 @@ async fn continuing_on_another_harness_switches_then_sends_the_continue_prompt() #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_full_request_held_across_the_switch_runs_on_the_new_harness() { let (core, limited, limited_prompts, pi, pi_requests, _tmp) = setup(); - queue_run(&core, "Remember PINEAPPLE", HarnessId::ClaudeCode, None, "m-open"); + // The opening turn runs the same claude model the held Run is sent + // with: only a Run the live runtime would carry is held — any other + // config replaces the runtime instead. + queue_run( + &core, + "Remember PINEAPPLE", + HarnessId::ClaudeCode, + Some("claude-model"), + "m-open", + ); start_limited_turn(&limited_prompts).await; // A full Run command sent while the turn-boundary run is working: it is // HELD for the turn's end with the exact request it was sent with @@ -533,6 +549,11 @@ async fn a_full_request_held_across_the_switch_runs_on_the_new_harness() { Some("claude-model"), "m-held", ); + wait_for( + || queue_texts(&core) == vec!["queued as a full request"], + "the full request to be held for the turn's end", + ) + .await; let _ = limited.finish.send(()); settle_limited(&core).await; assert_eq!(queue_texts(&core), vec!["queued as a full request"]); @@ -607,7 +628,7 @@ async fn a_full_request_held_across_the_switch_runs_on_the_new_harness() { /// the new harness, ready to resume. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_switch_alone_keeps_the_chat_limited_until_a_turn_runs() { - let (core, limited, limited_prompts, pi, _pi_requests, _tmp) = setup(); + let (core, limited, limited_prompts, pi, pi_requests, _tmp) = setup(); queue_run(&core, "Remember PINEAPPLE", HarnessId::ClaudeCode, None, "m-open"); start_limited_turn(&limited_prompts).await; let _ = limited.finish.send(()); @@ -629,12 +650,15 @@ async fn the_switch_alone_keeps_the_chat_limited_until_a_turn_runs() { // The user's own next message (a normal send, not the resume prompt) // clears it just the same. queue_run(&core, "try a different angle", HarnessId::Pi, None, "m-next"); + // Wait for the harness to hold the run, not just for Working: the + // status publishes before run() subscribes to `finish`, so a send in + // that window would be lost and the turn would never settle. wait_for( - || { - core.sessions - .session_status(CHAT) - .is_some_and(|s| s.status == SessionStatus::Working) - }, + || pi_requests + .lock() + .unwrap() + .iter() + .any(|r| r.prompt.contains("try a different angle")), "the new turn to start", ) .await; diff --git a/crates/engine/tests/limited_chats.rs b/crates/engine/tests/limited_chats.rs index 9892b2362e..7814a492b3 100644 --- a/crates/engine/tests/limited_chats.rs +++ b/crates/engine/tests/limited_chats.rs @@ -95,12 +95,14 @@ impl Harness for LimitHarness { request: RunRequest, _controls: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + // Subscribe before recording the prompt: a test that sees the prompt + // may send `finish` at once. + let mut finish = self.finish.subscribe(); self.prompts.lock().unwrap().push(request.prompt.clone()); let run = self.runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let kind = self.kind.clone(); let second_kind = self.second_kind.clone(); let session = format!("sess-{run}"); - let mut finish = self.finish.subscribe(); let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { harness: self.id, model: "limit-1".into(), @@ -714,12 +716,11 @@ async fn a_plain_new_turn_clears_the_limit_record() { // The user's own next message (a normal send, not the resume prompt). queue_run(&core, "try a different angle", HarnessId::ClaudeCode, "m-next"); + // Wait for the harness to hold the run, not just for Working: the + // status publishes before run() subscribes to `finish`, so a send in + // that window would be lost and the turn would never settle. wait_for( - || { - core.sessions - .session_status(CHAT) - .is_some_and(|s| s.status == SessionStatus::Working) - }, + || prompts.lock().unwrap().iter().any(|p| p == "try a different angle"), "the new turn to start", ) .await; From e7a7ffdb8c9fac926471e12ba6374c03d864e6cf Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 18:07:22 +0700 Subject: [PATCH 91/96] fix(engine): the merged-notice test waits for the released row's turn --- crates/engine/tests/completion_notices.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index c7f82e7e5e..d0f86a738e 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -2301,7 +2301,13 @@ async fn capped_outcomes_merge_into_the_one_parked_notice_row() { text.starts_with("2 side chats reached outcomes:"), "the released notice is one merged notice: {text}" ); - assert_eq!(scripted.run_count(), 54); + // The entry lands before the released row's dispatch: wait for its turn. + wait_for( + || scripted.run_count() == 54, + "the released row's notice turn to start", + ) + .await; + wait_idle(&core, PARENT).await; assert!(queue_rows(&core, PARENT).is_empty()); core.shutdown().await; From 64b3aecd2a9424fd3d2438e6450aa49e05268d49 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 18:59:14 +0700 Subject: [PATCH 92/96] fix(doc): write the harness-switch seam's harness field push_part never wrote DocPartJson.harness, so every HarnessSwitch part pushed into a session doc read back as an empty text part: the seam was invisible to the switch tests and to the engine's history-seam gating (is_harness_switch_seam), so a post-switch send carried no history. --- crates/doc/src/schema.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/doc/src/schema.rs b/crates/doc/src/schema.rs index f3fb8a5051..ca14038f28 100644 --- a/crates/doc/src/schema.rs +++ b/crates/doc/src/schema.rs @@ -1035,6 +1035,7 @@ fn push_part(parts: &LoroList, part: &MessagePart) -> Result<(), DocError> { ("mimeType", &doc_part.mime_type), ("sourceChatId", &doc_part.source_chat_id), ("sourceTitle", &doc_part.source_title), + ("harness", &doc_part.harness), ] { if let Some(value) = value { map.insert(key, value.as_str())?; From e0611d3a57d350aedbd31714a69300d64b42ae98 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 19:23:10 +0700 Subject: [PATCH 93/96] fix(engine): the plain-error test waits for the flush, not the Errored blip --- crates/engine/tests/limited_chats.rs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/crates/engine/tests/limited_chats.rs b/crates/engine/tests/limited_chats.rs index 7814a492b3..595e5a5dfd 100644 --- a/crates/engine/tests/limited_chats.rs +++ b/crates/engine/tests/limited_chats.rs @@ -432,22 +432,15 @@ async fn a_plain_error_records_no_limit_and_flushes_the_queue_as_before() { .expect("queue message"); let _ = harness.finish.send(()); - wait_for( - || { - core.sessions - .session_status(CHAT) - .is_some_and(|s| s.status == SessionStatus::Errored) - }, - "the chat to settle errored", - ) - .await; - assert_eq!(limit_row(&core), None, "unclassified errors are not Limited"); - // Today's behavior, unchanged: the turn ended, the queued row goes out. + // Today's behavior, unchanged: the turn ended errored and the queued row + // goes out at once. The Errored status is only a blip before that next + // run starts, so the flush itself is the proof the errored turn ended. wait_for( || prompts.lock().unwrap().iter().any(|p| p == "follow up"), "the queued message to flush after a plain error", ) .await; + assert_eq!(limit_row(&core), None, "unclassified errors are not Limited"); let _ = harness.finish.send(()); core.shutdown().await; } From f9de5dc2b972057b4071a1846db9ee04e37d91d5 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 20:05:28 +0700 Subject: [PATCH 94/96] fix(doc,engine): the registry session row carries the scheduled resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RegistryDoc::upsert_session (and the legacy seed) never wrote scheduledResume — only the old WorkspaceDoc writer did — so the armed continuation never reached the workspace mirror: not on arm, not on the boot republish. resume_at_reset: the scripted harness subscribes to finish before logging the prompt, and the queued-row test releases the continuation's turn so the row can follow it. --- crates/doc/src/registry.rs | 10 ++++++++++ crates/doc/src/registry/tests.rs | 15 +++++++++++++++ crates/engine/tests/resume_at_reset.rs | 10 ++++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/registry.rs b/crates/doc/src/registry.rs index a811dc4b67..0da96b29d1 100644 --- a/crates/doc/src/registry.rs +++ b/crates/doc/src/registry.rs @@ -1505,6 +1505,10 @@ impl RegistryDoc { Some(limit) => serde_json::to_value(limit)?, None => Value::Null, }; + let scheduled_resume = match &session.scheduled_resume { + Some(scheduled) => serde_json::to_value(scheduled)?, + None => Value::Null, + }; let set = fields([ ("chatId", json!(session.chat_id)), ("deviceId", json!(session.device_id)), @@ -1513,6 +1517,7 @@ impl RegistryDoc { ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), ("limit", limit), + ("scheduledResume", scheduled_resume), ]); self.write(KIND_SESSIONS, &session.chat_id.clone(), OpKind::Upsert, set); Ok(()) @@ -1656,6 +1661,10 @@ impl RegistryDoc { Some(limit) => serde_json::to_value(limit)?, None => Value::Null, }; + let scheduled_resume = match &session.scheduled_resume { + Some(scheduled) => serde_json::to_value(scheduled)?, + None => Value::Null, + }; seed( KIND_SESSIONS, &session.chat_id, @@ -1668,6 +1677,7 @@ impl RegistryDoc { ("startedAt", opt_ms(session.started_at)), ("updatedAt", json!(session.updated_at.timestamp_millis())), ("limit", limit), + ("scheduledResume", scheduled_resume), ]), ); } diff --git a/crates/doc/src/registry/tests.rs b/crates/doc/src/registry/tests.rs index e0496d7c7d..a1b3b84338 100644 --- a/crates/doc/src/registry/tests.rs +++ b/crates/doc/src/registry/tests.rs @@ -378,6 +378,21 @@ fn rows_round_trip_and_upsert_refreshes() { assert_eq!(chats[0].last_message_preview.as_deref(), Some("hello")); } +/// The armed Resume-at-reset continuation rides the session row (remote +/// banners' countdown), and a cancel clears it. +#[test] +fn the_scheduled_resume_rides_the_session_row() { + let mut doc = RegistryDoc::new("dev-a"); + let mut row = session("chat-1", "dev-a", SessionStatus::Errored); + row.scheduled_resume = Some(roboco_proto::ScheduledResume { fire_at: 9_000 }); + doc.upsert_session(&row).unwrap(); + assert_eq!(doc.read_sessions().unwrap(), vec![row.clone()]); + + row.scheduled_resume = None; + doc.upsert_session(&row).unwrap(); + assert_eq!(doc.read_sessions().unwrap()[0].scheduled_resume, None); +} + #[test] fn own_push_ack_never_advances_the_cursor() { // Field incident: on the HTTPS transport, push ran before pull in one diff --git a/crates/engine/tests/resume_at_reset.rs b/crates/engine/tests/resume_at_reset.rs index bd6d6d244c..a9c66a6e2f 100644 --- a/crates/engine/tests/resume_at_reset.rs +++ b/crates/engine/tests/resume_at_reset.rs @@ -99,11 +99,13 @@ impl Harness for ResetHarness { request: RunRequest, _controls: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { + // Subscribe before recording the prompt: a test that sees the prompt + // may send `finish` at once. + let mut finish = self.finish.subscribe(); self.prompts.lock().unwrap().push(request.prompt.clone()); let run = self.runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let resets_at = self.script.lock().unwrap().get(run).copied().flatten(); let session = format!("sess-{run}"); - let mut finish = self.finish.subscribe(); let started = futures::stream::iter(vec![Ok(AgentEvent::SessionStarted { harness: HarnessId::ClaudeCode, model: "reset-1".into(), @@ -616,12 +618,16 @@ async fn a_queued_message_waits_behind_the_pending_continuation() { ); // The reset time arrives: the continuation fires, and the queued row - // follows it — the queue thaws with the continuation's dispatch. + // follows it — the queue thaws with the continuation's dispatch and + // drains when its turn ends. wait_for( || prompts_contain(&prompts, CONTINUE), "the continuation to fire at the reset time", ) .await; + // The continuation owns the turn: the queued row waits behind it. + assert!(!prompts_contain(&prompts, "queued while limited")); + let _ = harness.finish.send(()); wait_for( || prompts_contain(&prompts, "queued while limited"), "the queued row to follow the continuation", From ddddb940f24bfba307e4bad53697dbd46cd4086c Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 20:17:35 +0700 Subject: [PATCH 95/96] test(engine): completion-notice waits key on the run they wait for Scripted takes its turn before logging the prompt, so a seen prompt means the run already holds its scripted turn and the next run cannot take it. run_to_idle replaces queue_run + wait_idle: it waits for the run's own prompt (its Working row is published by then) before the idle wait, which otherwise passes on the chat's previous Idle row. The 25-round loop-cap loops wait for each round's notice turn to start before waiting the parent idle (the notice entry lands before that dispatch). --- crates/engine/tests/completion_notices.rs | 139 +++++++++++++++------- 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index d0f86a738e..8c1acdd0c0 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -102,6 +102,16 @@ impl Scripted { .iter() .any(|p| p.contains(needle)) } + + /// How many runs so far carried `needle` in their prompt. + fn prompt_count(&self, needle: &str) -> usize { + self.prompts + .lock() + .unwrap() + .iter() + .filter(|p| p.contains(needle)) + .count() + } } #[async_trait] @@ -129,13 +139,16 @@ impl Harness for Scripted { request: RunRequest, controls: RunControls, ) -> Result<BoxStream<'static, Result<AgentEvent, HarnessError>>, HarnessError> { - self.prompts.lock().unwrap().push(request.prompt.clone()); + // Take the turn before recording the prompt: a test that sees this + // prompt may start the next run at once, and that run must never + // take the turn scripted for this one. let turn = self .turns .lock() .unwrap() .pop_front() .unwrap_or(Turn::Hang); + self.prompts.lock().unwrap().push(request.prompt.clone()); let session = format!("sess-{}", self.run_count()); let (tx, rx) = tokio::sync::mpsc::channel::<Result<AgentEvent, HarnessError>>(32); let steered = self.steered.clone(); @@ -341,6 +354,30 @@ async fn wait_idle(core: &EngineCore, chat: &str) { .await; } +/// Queue a Run for `chat` and wait for THAT run to start and settle. A bare +/// `wait_idle` right after the queue can pass on the chat's previous Idle +/// row before this run even dispatched; the run's own prompt reaching the +/// harness means its Working status already published (dispatch sets it +/// before the run task starts), so the idle wait after it is this run's. +async fn run_to_idle( + core: &EngineCore, + client: &roboco_rpc::RpcClient, + scripted: &Scripted, + chat: &str, + prompt: &str, + message_id: &str, + origin_chat_id: Option<&str>, +) { + let before = scripted.prompt_count(prompt); + queue_run(client, chat, prompt, message_id, origin_chat_id).await; + wait_for( + || scripted.prompt_count(prompt) > before, + &format!("the run of {prompt:?} to start"), + ) + .await; + wait_idle(core, chat).await; +} + /// Create the orchestrating parent: a cwd'd, titled chat a notice turn can /// run on. async fn create_parent(core: &EngineCore, client: &roboco_rpc::RpcClient) { @@ -726,8 +763,7 @@ async fn a_user_started_turn_in_the_child_produces_no_notice() { create_parent_and_side(&core, &client, None).await; // A typed send: no origin chat on the command or the entry. - queue_run(&client, SIDE, "typed by the user", "m-run-1", None).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "typed by the user", "m-run-1", None).await; // Let any (wrongly recorded) notice delivery settle before asserting // absence. tokio::time::sleep(Duration::from_millis(300)).await; @@ -793,8 +829,7 @@ async fn a_user_created_side_chat_produces_no_notice() { // Even a parent-originated message does not make its turns notifiable: // there is no durable creator row to key on. - queue_run(&client, SIDE, "sent by a chat anyway", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "sent by a chat anyway", "m-run-1", Some(PARENT)).await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( @@ -828,8 +863,7 @@ async fn a_notify_false_side_chat_produces_no_notice() { }) ); - queue_run(&client, SIDE, "fire and forget", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "fire and forget", "m-run-1", Some(PARENT)).await; tokio::time::sleep(Duration::from_millis(300)).await; assert!( @@ -975,8 +1009,7 @@ async fn a_working_parent_receives_the_notice_after_its_turn_as_its_next_turn() // The parent starts the child's turn through the MCP surface; the // child completes while the parent still works. - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; // No notice while the parent is busy: nothing steered in, no parent // turn started, no entry written. The outcome is recorded durably @@ -1083,6 +1116,13 @@ async fn three_children_finishing_while_the_parent_works_merge_into_one_notice() ) .await; } + // Every child's run dispatched (its Working row published) before the + // idle waits read the rows. + wait_for( + || scripted.prompt_count("please do your part") == sides.len(), + "every child's turn to start", + ) + .await; for (chat_id, _) in sides { wait_idle(&core, chat_id).await; } @@ -1166,8 +1206,7 @@ async fn a_pending_notice_survives_an_engine_restart_and_is_delivered_after_boot "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &first, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; assert!(core.sessions.turn_in_flight(PARENT)); assert!(notice_entries(&core, PARENT).is_empty()); // The outcome is recorded durably before the engine dies — that is the @@ -1400,8 +1439,7 @@ async fn an_archived_parent_receives_no_notices() { create_parent_and_side(&core, &client, None).await; core.workspace.set_chat_archived(PARENT, true).unwrap(); - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; // Let a wrongly recorded outcome settle before asserting absence. tokio::time::sleep(Duration::from_millis(300)).await; @@ -1438,8 +1476,7 @@ async fn a_deleted_parent_is_owed_nothing() { "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "the child's outcome to be recorded", @@ -1539,8 +1576,7 @@ async fn notices_owed_to_a_parent_archived_before_delivery_are_dropped_at_boot() "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &first, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; wait_for( || ledger_rows(&dir) == 1, "the child's outcome to be recorded durably", @@ -1670,8 +1706,7 @@ async fn an_observed_outcome_owes_no_notice() { "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "please implement login", "m-run-1", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "the child's outcome to be recorded durably", @@ -1739,8 +1774,7 @@ async fn an_outcome_after_the_observation_still_notifies() { "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "first task", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "first task", "m-run-1", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "the first outcome to be recorded", @@ -1753,8 +1787,7 @@ async fn an_outcome_after_the_observation_still_notifies() { // A later turn of the same child settles while the parent still works: // its outcome is past the observation's watermark, so it is owed. - queue_run(&client, SIDE, "second task", "m-run-2", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "second task", "m-run-2", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "the second outcome to be recorded", @@ -1820,8 +1853,7 @@ async fn a_late_observation_report_never_drops_an_unseen_outcome() { "the parent's turn to start", ) .await; - queue_run(&client, SIDE, "first task", "m-run-1", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "first task", "m-run-1", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "the first outcome to be recorded", @@ -1835,8 +1867,7 @@ async fn a_late_observation_report_never_drops_an_unseen_outcome() { tokio::time::sleep(Duration::from_millis(5)).await; // The child's second turn settles after the read: unseen by it. - queue_run(&client, SIDE, "second task", "m-run-2", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "second task", "m-run-2", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 2, "the second outcome to be recorded", @@ -1907,8 +1938,7 @@ async fn stopping_the_parent_drops_pending_notices_and_suppresses_running_childr "the parent's turn to start", ) .await; - queue_run(&client, "chat-side-a", "please implement login", "m-run-a", Some(PARENT)).await; - wait_idle(&core, "chat-side-a").await; + run_to_idle(&core, &client, &scripted, "chat-side-a", "please implement login", "m-run-a", Some(PARENT)).await; wait_for( || ledger_rows(dir.path()) == 1, "child A's outcome to be recorded durably", @@ -2072,20 +2102,28 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { // 25 rounds: the parent orchestrates, the child completes, the notice // starts the parent's next turn. The parent never sees a user message. for round in 0..25 { - queue_run( + run_to_idle( + &core, &client, + &scripted, SIDE, &format!("round {round}"), &format!("m-run-{round}"), Some(PARENT), ) .await; - wait_idle(&core, SIDE).await; wait_for( || notice_entries(&core, PARENT).len() == round + 1, &format!("round {round}'s notice to be written"), ) .await; + // The notice turn itself dispatched (child + notice per round) + // before the idle wait reads the parent's row. + wait_for( + || scripted.run_count() == 2 * (round + 1), + &format!("round {round}'s notice turn to start"), + ) + .await; wait_idle(&core, PARENT).await; } assert_eq!(notice_entries(&core, PARENT).len(), 25); @@ -2093,8 +2131,7 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { // Round 26: the child completes, but the 26th notice-started turn does // not start — the notice parks as a paused queue row. - queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "round 26", "m-run-26", Some(PARENT)).await; wait_for( || queue_rows(&core, PARENT).len() == 1, "the capped notice to park in the parent's queue", @@ -2197,8 +2234,7 @@ async fn the_loop_cap_parks_the_26th_notice_until_the_user_acts() { ); // A new outcome after the user's message can start a notice turn again: // the count reset with the user's message (plus the row's own turn). - queue_run(&client, SIDE, "round 27", "m-run-27", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "round 27", "m-run-27", Some(PARENT)).await; wait_for( || notice_entries(&core, PARENT).len() == 27, "the count reset lets the next notice start a turn again", @@ -2232,24 +2268,31 @@ async fn capped_outcomes_merge_into_the_one_parked_notice_row() { // Fill the cap, then park the 26th notice. for round in 0..25 { - queue_run( + run_to_idle( + &core, &client, + &scripted, SIDE, &format!("round {round}"), &format!("m-run-{round}"), Some(PARENT), ) .await; - wait_idle(&core, SIDE).await; wait_for( || notice_entries(&core, PARENT).len() == round + 1, &format!("round {round}'s notice to be written"), ) .await; + // The notice turn itself dispatched (child + notice per round) + // before the idle wait reads the parent's row. + wait_for( + || scripted.run_count() == 2 * (round + 1), + &format!("round {round}'s notice turn to start"), + ) + .await; wait_idle(&core, PARENT).await; } - queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "round 26", "m-run-26", Some(PARENT)).await; wait_for( || queue_rows(&core, PARENT).len() == 1, "the capped notice to park in the parent's queue", @@ -2258,8 +2301,7 @@ async fn capped_outcomes_merge_into_the_one_parked_notice_row() { // A second capped outcome parks INTO the same row: one row, its text // and sides now covering both side chats. - queue_run(&client, SIDE2, "the second child fails", "m-run-2", Some(PARENT)).await; - wait_idle(&core, SIDE2).await; + run_to_idle(&core, &client, &scripted, SIDE2, "the second child fails", "m-run-2", Some(PARENT)).await; wait_for( || queue_rows_with_sides(&core, PARENT) .into_iter() @@ -2336,24 +2378,31 @@ async fn stopping_the_parent_drops_the_parked_notice_row() { // Fill the cap: 25 notice-started turns, then the 26th child completion // parks its notice in the parent's queue. for round in 0..25 { - queue_run( + run_to_idle( + &core, &client, + &scripted, SIDE, &format!("round {round}"), &format!("m-run-{round}"), Some(PARENT), ) .await; - wait_idle(&core, SIDE).await; wait_for( || notice_entries(&core, PARENT).len() == round + 1, &format!("round {round}'s notice to be written"), ) .await; + // The notice turn itself dispatched (child + notice per round) + // before the idle wait reads the parent's row. + wait_for( + || scripted.run_count() == 2 * (round + 1), + &format!("round {round}'s notice turn to start"), + ) + .await; wait_idle(&core, PARENT).await; } - queue_run(&client, SIDE, "round 26", "m-run-26", Some(PARENT)).await; - wait_idle(&core, SIDE).await; + run_to_idle(&core, &client, &scripted, SIDE, "round 26", "m-run-26", Some(PARENT)).await; wait_for( || queue_rows(&core, PARENT).len() == 1, "the capped notice to park in the parent's queue", From 46f37cb7b6ccaa2eec06567745e5574b5a5377a6 Mon Sep 17 00:00:00 2001 From: hoangvu12 <hggaming91@gmail.com> Date: Sat, 17 Oct 2026 20:39:54 +0700 Subject: [PATCH 96/96] test(engine): the notices suite waits up to 30 s on loaded CI runners --- crates/engine/tests/completion_notices.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/engine/tests/completion_notices.rs b/crates/engine/tests/completion_notices.rs index 8c1acdd0c0..7c6d698407 100644 --- a/crates/engine/tests/completion_notices.rs +++ b/crates/engine/tests/completion_notices.rs @@ -286,7 +286,8 @@ async fn wait_for<F>(mut predicate: F, what: &str) where F: FnMut() -> bool, { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + // 30 s: the suite boots ~25 engines in parallel on a small CI runner. + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); while !predicate() { assert!( tokio::time::Instant::now() < deadline,