diff --git a/src/app.rs b/src/app.rs index fcacd6e..e4d57a1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,10 +40,9 @@ use crate::{ /// Maximum attached paste size, leaving headroom below the frame limit. const MAX_PASTE: usize = 8 * 1024 * 1024; -// A maximum-size paste expands to this base64 bound in `encode_command`. -// Reserve 64 KiB for the command envelope and keep the result within one frame. +// A maximum-size paste leaves the client base64-encoded in one `Command::Paste` frame. const _: () = assert!( - MAX_PASTE.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize, + crate::frame::fits_base64(MAX_PASTE), "MAX_PASTE must base64-encode to under frame::MAX_FRAME" ); @@ -80,7 +79,8 @@ enum NoticeLevel { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Mode { Dashboard, - /// Typing a command to spawn in `spawn_cwd` (bottom command line focused). + /// The spawn prompt for `spawn_cwd`: a typed command or a picked agent, + /// selected by `spawn_page`. Spawn, /// Live directory picker (the `@` flow) that sets `spawn_cwd`. PickDir, @@ -150,9 +150,10 @@ pub enum SpawnPage { /// What Enter does with a picker row. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum DirKind { - /// The resolved path (row 0): Enter runs the command there. + /// The resolved path (row 0): Enter opens the spawn prompt there. Use, - /// A current task's directory: Enter runs there; Tab descends into it. + /// A current task's directory: Enter opens the spawn prompt there; Tab + /// descends into it. Jump, /// A subdirectory: Enter and Tab descend into it. Into, @@ -225,8 +226,9 @@ pub struct App { pub mode: Mode, pub group_mode: GroupMode, pub input: EditBuffer, - /// Directory a spawned command runs in. Set to `invocation_dir` for the `n` - /// flow, or to the picked directory for the `@` flow. + /// Directory a typed command or a picked agent launches in. Set to + /// `invocation_dir` for the `n` flow, or to the picked directory for the + /// `@` flow. pub spawn_cwd: PathBuf, /// Group assigned to the next spawn. Custom mode snapshots the selected /// task's group; State and Dir modes leave the spawn unassigned. @@ -266,7 +268,6 @@ pub struct App { /// Directory `fleetcom` was launched from: base for relative `@` paths and /// the "default" section that sorts first in "by dir" mode. pub invocation_dir: PathBuf, - pub invocation_label: String, // `@` directory-picker state (only meaningful in `Mode::PickDir`). pub dir_input: EditBuffer, pub dir_candidates: Vec, @@ -457,7 +458,6 @@ impl App { make: impl FnOnce(u16, u16, Sender<()>) -> Box, ) -> Self { let invocation_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let invocation_label = path::abbreviate(&invocation_dir); // The core runs every PTY at the *content* size: full height minus the // one row attached mode reserves for its status bar. let pane_rows = rows.saturating_sub(1).max(1); @@ -497,7 +497,6 @@ impl App { last_paint: None, force_paint: false, invocation_dir, - invocation_label, dir_input: EditBuffer::default(), dir_candidates: Vec::new(), dir_sel: 0, @@ -588,6 +587,7 @@ impl App { /// Task sections in render order. Navigation uses their flattened order. pub fn sections(&self) -> Vec<(String, Vec)> { + let home = path::abbreviate(&self.invocation_dir); let mut labeled: Vec<(u8, String, u8, String, u64, usize)> = self .views .iter() @@ -601,7 +601,7 @@ impl App { GroupMode::Dir => { let label = path::abbreviate(&v.cwd); // Keep the invocation directory first. - let rank = if label == self.invocation_label { 0 } else { 1 }; + let rank = if label == home { 0 } else { 1 }; (rank, label) } GroupMode::Custom => match &v.group { @@ -999,14 +999,6 @@ impl App { }); } - fn spawn_task(&mut self, command: &str) { - self.transport.send(Command::Spawn { - command: command.to_string(), - cwd: self.spawn_cwd.clone(), - group: self.spawn_group.clone(), - }); - } - // --- `@` directory picker ------------------------------------------------- /// Rebuild directory-picker rows with the resolved path first. When the @@ -1044,7 +1036,7 @@ impl App { for name in list_dirs(&base, partial) { let path = base.join(&name); // A current-task directory that is also a subdirectory already has - // a row: Enter runs there, and Tab descends. + // a row: Enter opens the spawn prompt there, and Tab descends. if cands .iter() .any(|c| c.kind == DirKind::Jump && c.path == path) @@ -1237,7 +1229,8 @@ impl App { } } - /// Clear the text-prompt state and return to the dashboard. + /// Clear the command buffer and return to the dashboard. The Agent page's + /// own state is reset when the prompt opens, not here. fn close_prompt(&mut self) { self.input.clear(); self.mode = Mode::Dashboard; @@ -1552,7 +1545,11 @@ impl App { _ => match self.spawn_page { SpawnPage::Command => self.on_key_textinput(k, |app, cmd| { if !cmd.is_empty() { - app.spawn_task(cmd); + app.transport.send(Command::Spawn { + command: cmd.to_string(), + cwd: app.spawn_cwd.clone(), + group: app.spawn_group.clone(), + }); } }), SpawnPage::Agent => self.on_key_agent(k), diff --git a/src/app_readme_tests.rs b/src/app_readme_tests.rs index b0fb96a..8a23b2c 100644 --- a/src/app_readme_tests.rs +++ b/src/app_readme_tests.rs @@ -107,7 +107,6 @@ impl Dirs { fn fixture_app(dirs: &Dirs, group_mode: GroupMode, views: Vec) -> App { let mut app = App::assemble(FIXTURE_ROWS, FIXTURE_COLS, |_, _, _| Box::new(NoTransport)); app.daemon_backed = true; - app.invocation_label = path::abbreviate(&dirs.fleetcom); app.invocation_dir = dirs.fleetcom.clone(); app.spawn_cwd = dirs.fleetcom.clone(); app.group_mode = group_mode; diff --git a/src/app_tests.rs b/src/app_tests.rs index 5c0a729..12b1112 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -283,7 +283,11 @@ fn dir_mode_groups_by_cwd() { app.group_mode = GroupMode::Dir; let s = app.sections(); assert_eq!(s.len(), 2, "one section per distinct cwd"); - assert_eq!(s[0].0, app.invocation_label, "invocation dir sorts first"); + assert_eq!( + s[0].0, + path::abbreviate(&app.invocation_dir), + "invocation dir sorts first" + ); assert_eq!(s[1].0, "/tmp"); } @@ -617,7 +621,7 @@ fn dir_mode_idle_task_holds_its_row() { app.spawn_in("sleep 5", inv); // id 2 app.pump(); app.group_mode = GroupMode::Dir; - let label = app.invocation_label.clone(); + let label = path::abbreviate(&app.invocation_dir); assert_eq!(app.section_ids(), vec![(label.clone(), vec![1, 2])]); let i = app.views.iter().position(|v| v.id == 1).unwrap(); @@ -687,7 +691,7 @@ fn dir_mode_finished_sinks_within_section() { app.group_mode = GroupMode::Dir; assert_eq!( app.section_ids(), - vec![(app.invocation_label.clone(), vec![2, 1])], + vec![(path::abbreviate(&app.invocation_dir), vec![2, 1])], "finished id 1 sinks below live id 2 within its directory" ); } @@ -1400,7 +1404,7 @@ fn rows_interleave_headers_and_tasks() { app.group_mode = GroupMode::Dir; let rows = app.list_rows(); assert_eq!(rows.len(), 4, "two sections, one task each"); - assert_eq!(rows[0], Row::Section(app.invocation_label.clone())); + assert_eq!(rows[0], Row::Section(path::abbreviate(&app.invocation_dir))); assert!(matches!(rows[1], Row::Task(i) if app.views[i].id == 1)); assert_eq!(rows[2], Row::Section("/tmp".to_string())); assert!(matches!(rows[3], Row::Task(i) if app.views[i].id == 2)); @@ -2513,17 +2517,12 @@ fn find_panel_rows_name_the_task_and_its_section() { app.pump(); app.on_key_dashboard(key(KeyCode::Char('/'))); - let mut out = Vec::new(); - crate::ui::render(&mut out, &mut app).unwrap(); - let frame = String::from_utf8_lossy(&out).into_owned(); + let frame = painted(&mut app); assert!(frame.contains("✻ api tests · Running"), "{frame:?}"); assert!(frame.contains("enter jump · ↑↓ pick · esc"), "{frame:?}"); find_type(&mut app, "zzz"); - app.last_frame.clear(); - let mut out = Vec::new(); - crate::ui::render(&mut out, &mut app).unwrap(); - let frame = String::from_utf8_lossy(&out).into_owned(); + let frame = painted(&mut app); assert!(frame.contains("(no matching tasks)"), "{frame:?}"); } @@ -2977,9 +2976,7 @@ fn pickgroup_caret_edits_refresh_the_filter() { fn paste_lands_at_the_caret() { let mut app = App::new_local(30, 100); app.mode = Mode::Spawn; - for c in "cargo t".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } + app.type_spawn("cargo t"); app.on_key_spawn(key(KeyCode::Left)); // caret before 't' app.on_paste("x\ny"); // control chars still stripped assert_eq!(app.input.as_str(), "cargo xyt"); @@ -3023,9 +3020,7 @@ fn custom_mode_spawn_inherits_the_selected_group() { assert_eq!(app.mode, Mode::Spawn); assert_eq!(app.spawn_group.as_deref(), Some("alpha")); - for c in "sleep 5".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } + app.type_spawn("sleep 5"); app.on_key_spawn(key(KeyCode::Enter)); app.pump(); let v = app.views.iter().find(|v| v.id == 2).unwrap(); @@ -3072,9 +3067,7 @@ fn state_and_dir_mode_spawns_stay_unassigned() { } app.on_key_dashboard(key(KeyCode::Char('n'))); - for c in "sleep 5".chars() { - app.on_key_spawn(key(KeyCode::Char(c))); - } + app.type_spawn("sleep 5"); app.on_key_spawn(key(KeyCode::Enter)); app.pump(); let v = app.views.iter().find(|v| v.id == 2).unwrap(); @@ -3127,34 +3120,6 @@ impl App { } } -/// Create an app with only a stub `claude` on the core's `PATH`. Discover and launch that -/// stub without running a real agent. Keep capture assets and Claude registry reads inside -/// the scratch tree. -fn stub_claude_fixture(tag: &str) -> (App, Scratch) { - let dir = temp(&format!("agent_{tag}")); - let bin = dir.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - write_executable(&bin.join("claude"), "exit 0"); - let ctx = crate::protocol::LaunchContext { - env: vec![ - ("PATH".into(), bin.as_os_str().to_os_string()), - ( - path::FLEETCOM_RUNTIME_DIR.into(), - dir.join("run").into_os_string(), - ), - ( - "CLAUDE_CONFIG_DIR".into(), - dir.join("claude-home").into_os_string(), - ), - ], - cwd: dir.to_path_buf(), - }; - let mut app = App::new_local_with_ctx(30, 100, ctx); - app.pump(); - assert_eq!(app.agents, ["claude"], "discovery must see only the stub"); - (app, dir) -} - /// Switch between Command and Agent with Tab only when agents are installed. With no /// agents, ignore Tab and BackTab and keep the current page. #[test] @@ -3258,14 +3223,7 @@ fn session_load_status_keeps_capture_notices_and_failures() { ], cwd: dir.to_path_buf(), }; - let managed = |agent: &str| crate::session::SessionEntry { - kind: crate::session::EntryKind::Managed { - agent: agent.into(), - resume: None, - }, - group: None, - name: None, - }; + let managed = |agent| crate::session::SessionEntry::managed(agent, None); let cfg = crate::session::SessionConfig::from([( dir.to_string_lossy().into_owned(), vec![managed("codex"), managed("grok"), managed("codex")], @@ -3295,12 +3253,10 @@ fn session_load_status_keeps_capture_notices_and_failures() { } /// Press `n`, Tab, then Enter to launch the first installed agent in the invocation -/// directory. Close the prompt, admit a managed task, and label its row with the -/// program word. +/// directory and close the prompt. #[test] fn agent_enter_launches_the_highlighted_agent_managed() { - let (mut app, _dir) = stub_claude_fixture("enter"); - let sent = app.record_sends(); + let (mut app, sent) = App::with_agents(&["claude"]); app.open_agent_page(); app.on_key_spawn(key(KeyCode::Enter)); assert_eq!(app.mode, Mode::Dashboard, "Enter closes the prompt"); @@ -3312,13 +3268,6 @@ fn agent_enter_launches_the_highlighted_agent_managed() { group: None, }] ); - app.pump(); - assert_eq!(app.views.len(), 1); - assert!(app.views[0].managed, "the core must admit a managed task"); - assert_eq!( - app.views[0].command, "claude", - "the row shows the program word" - ); } /// Launch the selected row on Enter, using the directory selected through `@` and the group @@ -4368,10 +4317,7 @@ fn peek_shows_short_output_instead_of_the_blank_grid_tail() { "the peek reads the selected task's screen" ); app.mode = Mode::Peek; - app.last_frame.clear(); - let mut out = Vec::new(); - crate::ui::render(&mut out, &mut app).unwrap(); - let frame = String::from_utf8_lossy(&out); + let frame = painted(&mut app); for word in ["alpha", "beta", "gamma"] { assert!(frame.contains(word), "peek frame must show {word:?}"); } diff --git a/src/daemon.rs b/src/daemon.rs index e1c4aa2..f0614b3 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8,8 +8,9 @@ //! //! Every task's PTY master is held by the daemon. On daemon termination, the PTYs //! are closed and SIGHUP is sent by the kernel to each terminal's foreground -//! process group: the whole task, with job control off under `$SHELL -c`. On -//! normal shutdown, send SIGTERM to each task group, then SIGKILL after a grace +//! process group: the whole task, because a literal task runs with job control off +//! under `$SHELL -c` and a managed task execs the agent binary with no shell at all. +//! On normal shutdown, send SIGTERM to each task group, then SIGKILL after a grace //! period, and remove the socket and lock. After a crash or SIGKILL, only the PTYs //! are closed; HUP-immune tasks may be left running without a supervisor. diff --git a/src/frame.rs b/src/frame.rs index 50566d5..c50df6c 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -11,7 +11,7 @@ use std::{ /// jzon-encoded control data (a `Command`, or any event but `Screen`). pub const KIND_CONTROL: u8 = 1; -/// A `Screen` event: a jzon header (id, cursor, lines) followed by the raw +/// A `Screen` event: a jzon metadata header followed by the raw /// `contents_formatted` bytes, spliced by [`crate::protocol`]. pub const KIND_SCREEN: u8 = 2; /// Connection-opening handshake containing the protocol version and launch @@ -23,6 +23,12 @@ pub const KIND_HELLO: u8 = 3; /// against this limit. pub const MAX_FRAME: u32 = 64 * 1024 * 1024; +/// Whether `n` raw bytes, base64-encoded, fit in one frame beside a 64 KiB reserve +/// for the jzon envelope that carries them (`Command::Paste`, `Event::ClipboardCopy`). +pub const fn fits_base64(n: usize) -> bool { + n.div_ceil(3) * 4 + 64 * 1024 <= MAX_FRAME as usize +} + /// Maximum time one frame write to a socket peer may block. A peer that /// stops draining its socket (crashed, SIGSTOPped, hostile) must not wedge /// the writer: the daemon's event writes and the client's command writes diff --git a/src/harness/assets.rs b/src/harness/assets.rs index fa91106..880ac80 100644 --- a/src/harness/assets.rs +++ b/src/harness/assets.rs @@ -326,7 +326,7 @@ mod tests { use super::*; use crate::{ harness::{ - BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, executable_file, + BINARY_ENV, CAPTURE_ENV, Claude, Harness, NOTIFY_CHAIN_ENV, Omp, fixtures::{ID, OTHER}, }, testutil::{dead_pid, install_fake_notifier, temp, write_executable}, @@ -453,10 +453,7 @@ console.log(Object.keys(handlers).join(" ")); "garbage", "install must never write into an earlier namespace" ); - assert_eq!(mode(&root), 0o700); - assert_eq!(mode(&second.claude_settings), 0o600); - assert_eq!(mode(&second.codex_notify), 0o700); - assert_eq!(mode(&second.omp_capture), 0o600); + assert_eq!(mode(&root), 0o700, "the root mode is reapplied"); } /// Installation retains live-owner namespaces and non-namespace entries. @@ -524,19 +521,6 @@ console.log(Object.keys(handlers).join(" ")); fs::read_to_string(stale.join("omp-capture.js")).unwrap(), "old module" ); - assert_eq!( - fs::read_to_string(&assets.claude_settings).unwrap(), - claude_settings_json() - ); - assert_eq!( - fs::read_to_string(&assets.codex_notify).unwrap(), - CODEX_NOTIFY_SCRIPT - ); - assert_eq!( - fs::read_to_string(&assets.omp_capture).unwrap(), - OMP_CAPTURE_MODULE - ); - assert_eq!(mode(&ns), 0o700); } /// Installation removes a dead owner's namespace and its contents. @@ -819,12 +803,10 @@ console.log(Object.keys(handlers).join(" ")); let written = fs::read_to_string(&cap).unwrap(); assert_eq!(written, format!("{parent}\n{payload}")); assert_eq!( - Claude - .parse_capture(&written, Some(parent), None) - .as_deref(), + Claude.parse_capture(&written, Some(parent)).as_deref(), Some(ID) ); - assert_eq!(Claude.parse_capture(&written, Some(parent + 1), None), None); + assert_eq!(Claude.parse_capture(&written, Some(parent + 1)), None); } /// Check the `omp` module without a JavaScript runtime: require both @@ -936,7 +918,7 @@ console.log(Object.keys(handlers).join(" ")); }; let captured = || { let written = fs::read_to_string(&cap).unwrap(); - Omp.parse_capture(&written, None, None) + Omp.parse_capture(&written, None) }; // Require a normal return for absent and empty capture paths, and @@ -1053,22 +1035,4 @@ console.log(Object.keys(handlers).join(" ")); "the daemon's own executable rides every plan" ); } - - /// Accept only an existing regular file with an execute bit: a missing - /// path, a plain file, and a directory are all unusable. - #[test] - fn fleetcom_binary_requires_an_executable_regular_file() { - let root = temp("assets_binary"); - let plain = root.join("plain"); - fs::write(&plain, "#!/bin/sh\n").unwrap(); - assert!(!executable_file(&plain), "no execute bit"); - fs::set_permissions(&plain, fs::Permissions::from_mode(0o700)).unwrap(); - assert!(executable_file(&plain)); - assert!( - !executable_file(&root.join("fleetcom (deleted)")), - "missing" - ); - assert!(!executable_file(&root), "a directory"); - assert_eq!(fleetcom_binary(), Some(std::env::current_exe().unwrap())); - } } diff --git a/src/harness/claude.rs b/src/harness/claude.rs index 93cf7c0..a2fdb80 100644 --- a/src/harness/claude.rs +++ b/src/harness/claude.rs @@ -16,9 +16,7 @@ use std::{ }; use super::summary::AWAITING_APPROVAL; -use super::{ - CAPTURE_ENV, CapturePaths, Harness, SpawnPlan, capture_id, home_root, is_uuid, resolve_home, -}; +use super::{CapturePaths, Harness, SpawnPlan, capture_id, home_root, is_uuid, resolve_home}; pub struct Claude; @@ -46,10 +44,7 @@ impl Harness for Claude { "--settings".into(), capture.claude_settings.clone().into_os_string(), ], - env: vec![( - CAPTURE_ENV.into(), - capture.capture_file.clone().into_os_string(), - )], + env: vec![capture.capture_env()], ..SpawnPlan::default() } } @@ -63,13 +58,7 @@ impl Harness for Claude { /// leader, as required by [`record_for_pid`]. Reject stamps from other PIDs (a /// background fork or a resident wrapper) and fall back to the registry, then the /// spawn-time ID. Without a task PID, ownership cannot be checked. - fn parse_capture( - &self, - payload: &str, - pid: Option, - // Ownership is decided by the stamp alone. - _home: Option<&Path>, - ) -> Option { + fn parse_capture(&self, payload: &str, pid: Option) -> Option { let json = payload.strip_prefix(&format!("{}\n", pid?))?; capture_id(&jzon::parse(json).ok()?, "session_id") } @@ -147,7 +136,7 @@ fn parse_record(text: &str) -> Option { /// process start to match the task. The unreaped task leader reserves its PID; /// the directory and start-time checks reject stale records already present at /// that path. The start time may differ by at most 30 seconds. A mismatch -/// returns `None` because the ID may enter a shell command. +/// returns `None`: the ID becomes a resume argv element and a session-file field. fn record_for_pid( pid: u32, cwd: &Path, @@ -170,7 +159,7 @@ fn record_for_pid( #[cfg(test)] mod tests { - use std::{fs, path::PathBuf}; + use std::fs; use super::*; use crate::{ @@ -230,13 +219,7 @@ mod tests { argv(&["--session-id", ID, "--settings", SETTINGS]) ); assert_eq!(fresh.resume_id.as_deref(), Some(ID)); - assert_eq!( - fresh.env, - vec![( - CAPTURE_ENV.into(), - PathBuf::from("/tmp/cap/session.json").into_os_string() - )] - ); + assert_eq!(fresh.env, vec![paths().capture_env()]); assert_eq!(fresh.notice, None); let resume = plan(&Claude, &Intent::Resume(OTHER.into()), None, &paths(), None); @@ -266,7 +249,7 @@ mod tests { /// Parse `payload` as the capture file of a task led by [`OWNER`]. fn parse_owned(payload: &str) -> Option { - Claude.parse_capture(payload, Some(OWNER), None) + Claude.parse_capture(payload, Some(OWNER)) } /// Accept the leader's stamp for every `source`, including `fork` after @@ -292,9 +275,9 @@ mod tests { ); } let owned = format!("{OWNER}\n{json}"); - assert_eq!(Claude.parse_capture(&owned, Some(424), None), None); - assert_eq!(Claude.parse_capture(&owned, Some(42420), None), None); - assert_eq!(Claude.parse_capture(&owned, None, None), None); + assert_eq!(Claude.parse_capture(&owned, Some(424)), None); + assert_eq!(Claude.parse_capture(&owned, Some(42420)), None); + assert_eq!(Claude.parse_capture(&owned, None), None); } /// Require the leader's exact decimal PID on the first line. Reject the @@ -331,7 +314,8 @@ mod tests { assert_eq!(parse_owned(&complete[..whole]).as_deref(), Some(ID)); } - /// Require a strict ID even with a valid stamp before shell insertion. + /// Require a strict ID even under a valid stamp: the ID becomes a resume argv element + /// and a session-file field. #[test] fn parse_capture_returns_only_strict_ids_under_a_valid_stamp() { for json in [ diff --git a/src/harness/codex.rs b/src/harness/codex.rs index 27271aa..4b24617 100644 --- a/src/harness/codex.rs +++ b/src/harness/codex.rs @@ -15,8 +15,8 @@ use std::{ fmt::Write as _, - fs, io, - io::{BufRead, BufReader, Read}, + fs, + io::{self, BufRead, BufReader, Read}, path::{Path, PathBuf}, }; @@ -91,17 +91,10 @@ impl Harness for Codex { "notify=[\"{}\"]", toml_escape(&capture.codex_notify.to_string_lossy()) ); - plan.args = vec![ - "-c".into(), - toml.into(), - "-c".into(), - EMBEDDED_OVERRIDE.into(), - ]; + // The notify override precedes the embedded one; a test pins that order. + plan.args.splice(..0, ["-c".into(), toml.into()]); plan.env = vec![ - ( - CAPTURE_ENV.into(), - capture.capture_file.clone().into_os_string(), - ), + capture.capture_env(), (NOTIFY_CHAIN_ENV.into(), chain.into()), (BINARY_ENV.into(), binary.clone().into_os_string()), ]; @@ -118,8 +111,6 @@ impl Harness for Codex { payload: &str, // Notifications for every thread originate in the task's own process. _pid: Option, - // Home resolution was completed at arrival. - _home: Option<&Path>, ) -> Option { is_uuid(payload).then(|| payload.to_string()) } @@ -268,7 +259,7 @@ fn config_notify_route(home: Option<&Path>) -> NotifyRoute { }; let text = match fs::read_to_string(root.join("config.toml")) { Ok(text) => text, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return NotifyRoute::Vacant, + Err(err) if err.kind() == io::ErrorKind::NotFound => return NotifyRoute::Vacant, Err(_) => return NotifyRoute::Opaque, }; let mut route = None; @@ -422,21 +413,16 @@ mod tests { use crate::{ harness::{ Intent, - fixtures::{OTHER, argv, paths}, + fixtures::{ + CODEX_CHILD as CHILD, CODEX_ROOT as ID, CODEX_TITLE as TITLE, OTHER, argv, paths, + }, plan, }, testutil::{Scratch, codex_session_meta, install_codex_rollout, install_codex_root, temp}, }; - /// Use v7 IDs in Codex launch and resume commands. Also accept the shared v4 fixture: - /// UUID version is not checked by `is_uuid`. - const ID: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; - /// A sub-agent thread and a second-level sub-agent thread in the session - /// rooted at [`ID`]. - const CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; + /// A second-level sub-agent thread under [`CHILD`], in the session rooted at [`ID`]. const GRANDCHILD: &str = "019f5454-3d70-7e02-b1c8-2a4b6c8d0e1f"; - /// Hidden title thread ID, reported through notify but never saved in a rollout. - const TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; /// Overlay argv of every fully instrumented launch that uses [`paths`]: /// the notifier, then explicit embedded mode. @@ -448,10 +434,7 @@ mod tests { /// `chain` as the encoded notifier argv. fn full_env(chain: &str) -> Vec<(OsString, OsString)> { vec![ - ( - CAPTURE_ENV.into(), - PathBuf::from("/tmp/cap/session.json").into_os_string(), - ), + paths().capture_env(), (NOTIFY_CHAIN_ENV.into(), chain.into()), ( BINARY_ENV.into(), @@ -469,7 +452,7 @@ mod tests { args: argv(&["-c", EMBEDDED_OVERRIDE]), env: Vec::new(), resume_id: None, - notice: Some(format!("codex capture unavailable: {why}")), + notice: Some(format!("{CAPTURE_OFF}: {why}")), } } @@ -798,35 +781,13 @@ mod tests { ) } - /// Validate the payload before looking up its rollout. Even with a root - /// rollout present, accept only a turn-complete payload with a strict ID. - #[test] - fn arrival_root_accepts_only_turn_complete_payloads() { - let home = temp("codex_capture_payload"); - install_codex_root(&home, ID); - let parse = |payload: &str| arrival_root(payload, Some(&home)); - assert_eq!(parse(&turn_complete(ID)).as_deref(), Some(ID)); - - let wrong_type = format!(r#"{{"type":"other","thread-id":"{ID}"}}"#); - assert_eq!(parse(&wrong_type), None); - for thread in ["my session", "../../../config", "*", ""] { - let payload = format!(r#"{{"type":"agent-turn-complete","thread-id":"{thread}"}}"#); - assert_eq!(parse(&payload), None, "{thread:?}"); - } - assert_eq!(parse(r#"{"type":"agent-turn-complete"}"#), None); - assert_eq!(parse("not json"), None); - assert_eq!(parse(""), None); - } - /// Accept exactly one bare UUID. Refuse pre-v1 JSON, trailing newlines from other - /// writers, and malformed or empty input regardless of PID or home. Do not reparse or - /// look up the ID at read time. + /// writers, and malformed or empty input regardless of PID. Do not reparse the ID at + /// read time. #[test] fn parse_capture_accepts_only_a_bare_uuid_slot() { - let home = temp("codex_capture_slot"); - install_codex_root(&home, ID); - for (pid, home) in [(None, None), (Some(4242), Some(&*home))] { - let parse = |slot: &str| Codex.parse_capture(slot, pid, home); + for pid in [None, Some(4242)] { + let parse = |slot: &str| Codex.parse_capture(slot, pid); assert_eq!(parse(ID).as_deref(), Some(ID)); assert_eq!(parse(&turn_complete(ID)), None, "old JSON format"); assert_eq!(parse(&format!("{ID}\n")), None, "trailing newline"); @@ -847,7 +808,7 @@ mod tests { ) -> impl Fn(&str) -> Option + 'a { move |key| { match key { - "FLEETCOM_CAPTURE_FILE" => capture, + CAPTURE_ENV => capture, "CODEX_HOME" => codex_home, "HOME" => home, _ => None, @@ -893,7 +854,7 @@ mod tests { assert_eq!(fs::read_to_string(&cap).unwrap(), ID); assert_eq!(siblings(&cap), ["task-1-0.json"]); assert_eq!( - Codex.parse_capture(&fs::read_to_string(&cap).unwrap(), None, Some(&home)), + Codex.parse_capture(&fs::read_to_string(&cap).unwrap(), None), Some(ID.to_string()) ); } @@ -920,8 +881,9 @@ mod tests { assert_eq!(siblings(&cap), ["task-2-0.json"]); } - /// Refuse before touching the slot: preserve existing contents for a non-turn-complete - /// type, a non-UUID thread, malformed JSON, or a thread without a rollout. + /// Refuse before touching the slot, even with the root's rollout present: preserve + /// existing contents for a non-turn-complete type, a non-UUID thread, malformed JSON, + /// or a thread without a rollout. #[test] fn record_arrival_refuses_without_writing() { let home = temp("codex_arrival_refuse"); @@ -935,7 +897,10 @@ mod tests { r#"{{"type":"agent-turn-complete","thread-id":"{}"}}"#, ID.to_uppercase() ), + r#"{"type":"agent-turn-complete","thread-id":"my session"}"#.to_string(), r#"{"type":"agent-turn-complete","thread-id":"../../x"}"#.to_string(), + r#"{"type":"agent-turn-complete","thread-id":"*"}"#.to_string(), + r#"{"type":"agent-turn-complete","thread-id":""}"#.to_string(), r#"{"type":"agent-turn-complete"}"#.to_string(), "not json".to_string(), String::new(), diff --git a/src/harness/grok.rs b/src/harness/grok.rs index 6dee2c6..417c6ca 100644 --- a/src/harness/grok.rs +++ b/src/harness/grok.rs @@ -50,7 +50,7 @@ mod tests { fn parse_capture_is_unconditionally_none() { // No injected channel exists, so no payload is ever trusted. let payload = format!(r#"{{"session_id":"{ID}"}}"#); - assert_eq!(Grok.parse_capture(&payload, None, None), None); - assert_eq!(Grok.parse_capture("", None, None), None); + assert_eq!(Grok.parse_capture(&payload, None), None); + assert_eq!(Grok.parse_capture("", None), None); } } diff --git a/src/harness/mod.rs b/src/harness/mod.rs index 1769250..2e934c5 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -51,9 +51,10 @@ pub const BINARY_ENV: &str = "FLEETCOM_BINARY"; /// Launch, capture, and resume behavior for one agent CLI. pub trait Harness: Sync { - /// Resolve configuration needed by instrumentation, capture parsing, or - /// the live registry from the launch environment. Return `None` when no - /// configuration is needed. + /// Resolve the tool's configuration root from the launch environment. It serves the + /// overlay (codex's `config.toml` notify route), arrival validation (codex rollouts), + /// and the live registry (claude's `sessions/`). Return `None` when the tool reads no + /// configuration. fn resolve_home(&self, _env: &dyn Fn(&str) -> Option) -> Option { None } @@ -75,17 +76,11 @@ pub trait Harness: Sync { /// Leave `resume_id` unset here and assign it from the intent in [`plan`]. fn overlay(&self, capture: &CapturePaths, home: Option<&Path>) -> SpawnPlan; - /// Extract a session ID from the capture file's contents. `pid` is the - /// task's session leader and `home` the launch-time harness home. When - /// other processes can write to the capture channel, use these arguments - /// to reject payloads written outside the task's own process. - /// Return `None` by default for tools without an injected capture channel. - fn parse_capture( - &self, - _payload: &str, - _pid: Option, - _home: Option<&Path>, - ) -> Option { + /// Extract a session ID from the capture file's contents. `pid` is the task's session + /// leader: when other processes can write to the capture channel, use it to reject + /// payloads written outside the task's own process. Return `None` by default for tools + /// without an injected capture channel. + fn parse_capture(&self, _payload: &str, _pid: Option) -> Option { None } @@ -200,9 +195,10 @@ fn executable_file(path: &Path) -> bool { } /// Select a display-only summary adapter by the basename of the command's first -/// whitespace-separated word. Accept arguments; do not select an adapter for environment -/// prefixes or compound shell commands. Apply this selection to literal and managed tasks -/// without enabling harness reads for literal tasks. +/// whitespace-separated word. Arguments and a space-separated compound (`claude && vim`) +/// still select; an environment prefix or shell syntax glued to the word (`claude;`) does +/// not. Apply this selection to literal and managed tasks without enabling harness reads +/// for literal tasks. pub fn select(command: &str) -> Option<&'static dyn crate::preview::SummaryAdapter> { let first = command.split_whitespace().next()?; let name = Path::new(first).file_name()?.to_str()?; @@ -287,6 +283,16 @@ pub struct CapturePaths { pub fleetcom_binary: Option, } +impl CapturePaths { + /// The capture-file pair every capturing overlay exports. + pub fn capture_env(&self) -> (OsString, OsString) { + ( + CAPTURE_ENV.into(), + self.capture_file.clone().into_os_string(), + ) + } +} + /// Spawn-time additions for one managed launch: argv elements passed to the /// binary directly, never shell text. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -297,8 +303,8 @@ pub struct SpawnPlan { pub env: Vec<(OsString, OsString)>, /// Session ID known at launch: the pinned fresh ID or the resumed ID. pub resume_id: Option, - /// One-line explanation of reduced instrumentation, for the status line after a - /// successful spawn. + /// One-line explanation of reduced instrumentation, shown on the status line after a + /// successful spawn interactively, or folded into a load's summary line. pub notice: Option, } @@ -350,16 +356,22 @@ pub(crate) mod fixtures { /// Strict v4 UUID used wherever a valid session ID is needed. pub(crate) const ID: &str = "c8c4a5cc-0b32-4ba0-a6b4-6ed08c218e0d"; - /// A second distinct ID for requote and precedence cases. + /// A second distinct ID for precedence cases. pub(crate) const OTHER: &str = "11111111-2222-4333-8444-555555555555"; + /// Thread IDs reported through one codex process's notifier: the conversation on + /// screen, a sub-agent it spawned, and the hidden title thread. Codex thread IDs are + /// v7; use these so fixtures match real rollouts (`is_uuid` checks no version field). + pub(crate) const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; + pub(crate) const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; + pub(crate) const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; + /// Argv elements from string literals, for snapshot assertions. pub(super) fn argv(words: &[&str]) -> Vec { words.iter().map(OsString::from).collect() } - /// Capture-path fixture. Include spaces in asset paths to test shell and TOML - /// quoting. + /// Capture-path fixture. Include spaces in asset paths to test TOML quoting. pub(super) fn paths() -> CapturePaths { CapturePaths { capture_file: PathBuf::from("/tmp/cap/session.json"), diff --git a/src/harness/omp.rs b/src/harness/omp.rs index 71f3981..2a6cd82 100644 --- a/src/harness/omp.rs +++ b/src/harness/omp.rs @@ -6,7 +6,7 @@ //! Report only IDs usable with `omp --resume`. Skip sub-agent sessions: //! the same handlers are registered for them, but their IDs are not resumable. //! Wait until the session file is created after the first assistant message. -//! For a bare `omp` task, no ID is captured until the end of the first turn. +//! For a fresh launch, no ID is captured until the end of the first turn. //! Capture is unavailable before omp 18.3.2: without the agent kind in the //! extension context, top-level sessions cannot be distinguished from sub-agents. //! @@ -15,7 +15,7 @@ use std::path::Path; -use super::{CAPTURE_ENV, CapturePaths, Harness, SpawnPlan, capture_id}; +use super::{CapturePaths, Harness, SpawnPlan, capture_id}; pub struct Omp; @@ -29,10 +29,7 @@ impl Harness for Omp { fn overlay(&self, capture: &CapturePaths, _home: Option<&Path>) -> SpawnPlan { SpawnPlan { args: vec!["-e".into(), capture.omp_capture.clone().into_os_string()], - env: vec![( - CAPTURE_ENV.into(), - capture.capture_file.clone().into_os_string(), - )], + env: vec![capture.capture_env()], ..SpawnPlan::default() } } @@ -40,20 +37,13 @@ impl Harness for Omp { /// Return `sessionId` from a valid extension payload. Check the agent /// kind in the extension: without that field in the payload, the check /// cannot be repeated here. - fn parse_capture( - &self, - payload: &str, - _pid: Option, - _home: Option<&Path>, - ) -> Option { + fn parse_capture(&self, payload: &str, _pid: Option) -> Option { capture_id(&jzon::parse(payload).ok()?, "sessionId") } } #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::*; use crate::harness::{ Intent, @@ -75,13 +65,7 @@ mod tests { let fresh = plan(&Omp, &Intent::Fresh, Some(ID), &paths(), None); assert_eq!(fresh.args, argv(&["-e", EXTENSION])); assert_eq!(fresh.resume_id, None, "the minted id is ignored"); - assert_eq!( - fresh.env, - vec![( - CAPTURE_ENV.into(), - PathBuf::from("/tmp/cap/session.json").into_os_string() - )] - ); + assert_eq!(fresh.env, vec![paths().capture_env()]); let resume = plan(&Omp, &Intent::Resume(CAPTURED.into()), None, &paths(), None); assert_eq!(resume.args, argv(&["--resume", CAPTURED, "-e", EXTENSION])); @@ -92,7 +76,7 @@ mod tests { /// Extract only a validated ID from the extension payload. #[test] fn parse_capture_returns_only_strict_ids() { - let parse = |payload: &str| Omp.parse_capture(payload, None, None); + let parse = |payload: &str| Omp.parse_capture(payload, None); for reason in [ "session_start", "session_switch", diff --git a/src/harness/summary_tests.rs b/src/harness/summary_tests.rs index efc8bad..e386f17 100644 --- a/src/harness/summary_tests.rs +++ b/src/harness/summary_tests.rs @@ -6,8 +6,12 @@ use crate::{ harness::select, preview::{MARKER, PreviewState, SummaryAdapter}, protocol::PreviewSource, + testutil::CORPUS_LINES, }; +/// The ten braille spinner frames codex and omp cycle through their titles. +const BRAILLE_FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + /// Build a synthetic live viewport for an adapter. fn rs(rows: &[&str]) -> Vec { rows.iter().map(|s| s.to_string()).collect() @@ -30,13 +34,14 @@ fn grok_screen>(above: &[S]) -> Vec { rows } -/// Resolve a corpus fixture at 40 rows and return its text, source, and rule. +/// Resolve a corpus fixture at the corpus row count and `cols` columns; +/// return its text, source, and rule. fn corpus( bytes: &[u8], adapter: &dyn SummaryAdapter, cols: u16, ) -> (String, PreviewSource, Option<&'static str>) { - let mut emu = Emulator::new(40, cols, 2000); + let mut emu = Emulator::new(CORPUS_LINES as u16, cols, 2000); emu.process(bytes); let mut st = PreviewState::new(); let p = st.resolve(Instant::now(), &emu, Some(adapter), None); @@ -81,15 +86,6 @@ fn select_matches_first_word_basenames_only() { } } -/// Every registered program word selects a dashboard adapter. -#[test] -fn select_covers_every_registered_shape() { - for a in crate::harness::AGENTS { - let name = a.harness.shape().0; - assert!(select(name).is_some(), "{name} must select an adapter"); - } -} - /// Each program word routes to its own CLI's matchers: the selected /// adapter fires that CLI's rule on that CLI's screen shape. #[test] @@ -101,8 +97,7 @@ fn select_routes_to_the_matching_adapter() { 4, "route the new adapter's screen here" ); - let sep = "─".repeat(80); - let claude = rs(&["✻ Hashing… (6s · ↓ 87 tokens)", &sep, "❯", &sep]); + let claude = claude_screen(&["✻ Hashing… (6s · ↓ 87 tokens)"]); assert_eq!( select("claude").unwrap().live_preview(&claude).unwrap().1, "claude:spinner" @@ -342,54 +337,6 @@ fn claude_title_frames_canonicalize_to_constant_text() { ); } -/// A registry status outranks a screen-derived status while retaining the -/// model label and its own matcher ID. -#[test] -fn registry_anchor_outranks_the_claude_spinner() { - let rule = "─".repeat(60); - let screen = [ - "╭─── Claude Code v2.1.233 ────────────╮", - "│ Fable 5 with high effort · Claude Max · │ notes │", - "╰──────────────────────────────────────╯", - "", - "✻ Hashing… (6s · ↓ 87 tokens)", - &rule, - "❯", - &rule, - ] - .join("\r\n"); - let mut emu = Emulator::new(24, 80, 100); - emu.process(screen.as_bytes()); - - let mut st = PreviewState::new(); - let p = st - .resolve(Instant::now(), &emu, Some(&ClaudeSummary), None) - .clone(); - assert_eq!( - (p.text.as_str(), p.rule), - ("Fable 5 (high) · Hashing…", Some("claude:spinner")), - "premise: this screen anchors on the spinner" - ); - - let mut st = PreviewState::new(); - let p = st - .resolve( - Instant::now(), - &emu, - Some(&ClaudeSummary), - Some(("awaiting approval", "claude:registry-approval")), - ) - .clone(); - assert_eq!( - (p.text.as_str(), p.source, p.rule), - ( - "Fable 5 (high) · awaiting approval", - PreviewSource::Anchor, - Some("claude:registry-approval") - ) - ); -} - /// Cascade-level: with the claude adapter installed and no anchor on /// the screen, a frame-led title renders stripped to its text under the /// Title tier; without an adapter it renders verbatim. @@ -437,13 +384,12 @@ fn title_tier_renders_the_normalized_title() { /// a wrapped status tail and body text touching the chrome both refuse. #[test] fn claude_aborts_on_foreign_column_zero_rows() { - let sep = "─".repeat(120); - let wrapped = rs(&["✻ Hashing… (6s · ↓ 87 to", "kens)", &sep, "❯", &sep]); + let wrapped = claude_screen(&["✻ Hashing… (6s · ↓ 87 to", "kens)"]); assert_eq!(ClaudeSummary.live_preview(&wrapped), None); // A menu quoted in the body, reaching the window with the input box // intact, refuses rather than synthesizing approval. - let menu = rs(&["❯ 1. Yes", " 2. No", &sep, "❯", &sep]); + let menu = claude_screen(&["❯ 1. Yes", " 2. No"]); assert_eq!(ClaudeSummary.live_preview(&menu), None); } @@ -789,7 +735,7 @@ fn codex_label_rejects_warning_prose_and_incomplete_footers() { /// single string; idle and foreign titles pass through. #[test] fn codex_title_animations_canonicalize_to_constant_text() { - for frame in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] { + for frame in BRAILLE_FRAMES { assert_eq!( CodexSummary.normalize_title(&format!("{frame} fleetcom")), Some("⠋ fleetcom".to_string()), @@ -1429,7 +1375,7 @@ fn omp_title_separators_decode_state_and_label() { ); // Every working frame must normalize to the same title. - for frame in ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] { + for frame in BRAILLE_FRAMES { assert_eq!( OmpSummary.normalize_title(&format!("π {frame} Fix the flaky test")), Some("⠋ Fix the flaky test".to_string()), diff --git a/src/main.rs b/src/main.rs index 4f2b9ff..1715edc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ #![forbid(unsafe_code)] -//! `fleetcom`: a fleet-view supervisor for arbitrary shell commands. Each task is -//! a command in its own PTY; the dashboard groups them by status, and you can +//! `fleetcom`: a fleet-view supervisor for shell commands and managed agents. Each +//! task runs in its own PTY; the dashboard groups them by status, and you can //! peek at, attach to, and background any of them. // fleetcom requires Unix PTYs, domain sockets, and process-group signaling. diff --git a/src/preview.rs b/src/preview.rs index a658efb..58afd35 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -23,8 +23,11 @@ pub const MARKER: &str = "full-screen"; /// Read-only emulator state consumed by one preview-resolution step. pub trait ScreenFacts { + /// Change counter for every other fact this trait exposes: it advances + /// whenever any of them can change (each `process` call, each + /// sync-frame landing, `resize`). The resolution key relies on this: an + /// unchanged revision means an unchanged screen. fn revision(&self) -> u64; - fn alt_epoch(&self) -> u64; fn alternate_screen(&self) -> bool; fn title(&self) -> Option<&str>; /// Last sanitized primary-screen title. Retain across printable output and @@ -44,10 +47,6 @@ impl ScreenFacts for Emulator { Self::revision(self) } - fn alt_epoch(&self) -> u64 { - Self::alt_epoch(self) - } - fn alternate_screen(&self) -> bool { Self::alternate_screen(self) } @@ -170,15 +169,10 @@ fn cascade( Preview::floor(screen.live_floor().trim_start().to_string()) } -/// Screen and registry state that invalidates the cached preview candidate. -type ResolveKey = ( - u64, - u64, - bool, - Option, - Option, - Option<(String, &'static str)>, -); +/// Screen revision and registry state that invalidate the cached preview +/// candidate. The revision alone covers the screen: see +/// [`ScreenFacts::revision`]. +type ResolveKey = (u64, Option<(String, &'static str)>); /// Per-task preview resolution state. Reset this state when replacing the `Task` on /// rerun. @@ -236,10 +230,6 @@ impl PreviewState { } let key = ( screen.revision(), - screen.alt_epoch(), - screen.alternate_screen(), - screen.title().map(str::to_owned), - screen.primary_title().map(str::to_owned), blocked.map(|(text, rule)| (text.to_string(), rule)), ); if self.last_key.as_ref() != Some(&key) { @@ -319,7 +309,6 @@ impl PreviewState { if self.rendered.frozen { return; } - self.downgrade_pending_since = None; let alt_torn_down_at_exit = self.rendered_under_alt && !screen.alternate_screen() && screen @@ -349,10 +338,10 @@ mod tests { use crate::harness::summary::{CodexSummary, OmpSummary}; /// Synthetic screen facts with a floor-read counter for the - /// revision-gate test. + /// revision-gate test. Every mutator bumps `revision`, matching the + /// emulator's contract that no fact changes without a grid advance. struct FakeScreen { revision: u64, - alt_epoch: u64, alt: bool, title: Option, /// Last primary-screen title, mirroring `Emulator::primary_title`. @@ -368,7 +357,6 @@ mod tests { fn primary(floor: &str) -> Self { Self { revision: 1, - alt_epoch: 0, alt: false, title: None, primary_title: None, @@ -385,7 +373,6 @@ mod tests { fn enter_alt(&mut self) { self.alt = true; - self.alt_epoch += 1; self.advance(); } @@ -422,10 +409,6 @@ mod tests { self.revision } - fn alt_epoch(&self) -> u64 { - self.alt_epoch - } - fn alternate_screen(&self) -> bool { self.alt } @@ -711,18 +694,18 @@ mod tests { ); } - /// Invalidate the resolve key on a primary-title change alone. + /// A primary-title change arrives with the grid advance that parsed it; + /// the revision bump recomputes the candidate and the min-hold governs + /// the re-render. #[test] - fn a_primary_title_change_invalidates_the_key() { + fn a_primary_title_change_recomputes_the_candidate() { let t0 = Instant::now(); let mut st = PreviewState::new(); let mut s = FakeScreen::primary("shell"); s.set_primary_title("π > one"); assert_eq!(st.resolve(t0, &s, Some(&OmpSummary), None).text, "one"); - // The slot changes with no revision bump: only the key's - // primary-title entry can trigger the recompute. - s.primary_title = Some("π > two".into()); + s.set_primary_title("π > two"); let p = st .resolve(t0 + TITLE_MIN_HOLD, &s, Some(&OmpSummary), None) .clone(); diff --git a/src/protocol.rs b/src/protocol.rs index 3237740..c20203b 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -196,7 +196,7 @@ pub enum Event { Screen(ScreenView), /// A one-line notice for the status line (save/load result, spawn error). Status(String), - /// The id assigned to a direct `Spawn`. This event precedes the next task + /// The id assigned to a direct `Spawn` or `SpawnAgent`. This event precedes the next task /// snapshot containing that id. Session and recovery loads do not emit it. Spawned { id: u64 }, /// The reply to `ListSessions`: saved session-recipe names (sorted) and @@ -274,8 +274,8 @@ pub enum Lifecycle { /// Floor`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum PreviewSource { - /// The last non-blank row of the live screen: the cascade's fallback when - /// no higher-ranked preview qualifies. + /// The last non-blank row of the live screen, leading whitespace trimmed: the + /// cascade's fallback when no higher-ranked preview qualifies. Floor, /// The alternate screen is active with no usable title. Marker, @@ -458,7 +458,8 @@ fn insert_opt_ms(o: &mut jzon::JsonValue, key: &str, val: Option) { } /// Decode a JSON array of strings one-to-one. Reject the whole array on any non-string -/// member to preserve encoded and decoded positions. +/// member to preserve encoded and decoded positions. A missing or non-array value reads +/// as empty. fn str_vec(v: &jzon::JsonValue) -> Option> { let mut out = Vec::with_capacity(v.len()); for m in v.members() { @@ -941,9 +942,7 @@ pub fn decode_event(kind: u8, payload: &[u8]) -> Option { let v = jzon::parse(std::str::from_utf8(payload).ok()?).ok()?; match v["t"].as_str()? { "hello_ok" => Some(Event::HelloOk), - // Require an explicit array, including `[]` for an empty menu. Without this - // check, missing and non-array values would be accepted as empty by - // `str_vec`. + // Require an explicit array: an empty menu is an explicit `[]`. "agents" if v["agents"].is_array() => Some(Event::Agents(str_vec(&v["agents"])?)), "tasks" => { let mut views = Vec::new(); diff --git a/src/protocol_tests.rs b/src/protocol_tests.rs index 546c043..5923ed1 100644 --- a/src/protocol_tests.rs +++ b/src/protocol_tests.rs @@ -424,8 +424,9 @@ fn screen_header_without_alt_scroll_is_rejected() { assert_eq!(decode_event(KIND_SCREEN, &screen_payload(header)), None); } +/// Round-trip a two-view task snapshot, including a non-UTF-8 task path. #[test] -fn tasks_and_status_round_trip() { +fn tasks_round_trip() { let tasks = Event::Tasks(vec![ TaskView { id: 1, @@ -459,10 +460,6 @@ fn tasks_and_status_round_trip() { let (k, p) = encode_event(&tasks); assert_eq!(k, KIND_CONTROL); assert_eq!(decode_event(k, &p), Some(tasks)); - - let status = Event::Status("saved 'x'".into()); - let (k, p) = encode_event(&status); - assert_eq!(decode_event(k, &p), Some(status)); } /// `SetGroup` emits `"g"` only for an assignment. A missing or null `"g"` diff --git a/src/selection.rs b/src/selection.rs index 328d3a9..769fc96 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -233,9 +233,6 @@ mod tests { fn rows_below_the_screen_land_on_the_bottom_row() { let rows = screen(&["ab", "cd"]); assert_eq!(drag((0, 0), (9, 0)).extract(&rows), "ab\nc"); - // Both endpoints below the screen: the drag collapses onto the - // bottom row, and clamping inverts the endpoint order. - assert_eq!(drag((5, 1), (9, 0)).extract(&rows), "cd"); } #[test] diff --git a/src/session.rs b/src/session.rs index 14a692c..7d980cb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,5 +1,5 @@ //! JSON session recipes stored one file per name in the user's config directory. On -//! recipe load, start new commands without restoring live processes. +//! recipe load, start new tasks without restoring live processes. use std::{ collections::BTreeMap, @@ -40,6 +40,30 @@ pub struct SessionEntry { pub name: Option, } +#[cfg(test)] +impl SessionEntry { + /// Unlabelled literal entry. + pub(crate) fn literal(cmd: &str) -> Self { + Self { + kind: EntryKind::Literal(cmd.into()), + group: None, + name: None, + } + } + + /// Unlabelled managed entry. + pub(crate) fn managed(agent: &str, resume: Option<&str>) -> Self { + Self { + kind: EntryKind::Managed { + agent: agent.into(), + resume: resume.map(String::from), + }, + group: None, + name: None, + } + } +} + /// Session recipe mapping directories to ordered entries. pub type SessionConfig = BTreeMap>; @@ -81,8 +105,8 @@ pub fn sessions_dir(root: Option) -> Option { } /// Session format version written by `to_json` and accepted by `from_json`. Managed entries -/// were introduced in version 2. Treat a missing version as 1 and convert its literals -/// through [`migrate_v1`]. Reject newer versions. +/// were introduced in version 2. A missing version means 1; `from_json` loads a v1 file as +/// literals, then rewrites the recipe through [`migrate_v1`]. Reject newer versions. const FORMAT_VERSION: u64 = 2; /// Build the recipe's `dirs` object. @@ -113,42 +137,49 @@ fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue { dirs } -/// Convert v1 literal entries to the corresponding managed form. In v1, bare program words -/// and canonical resume commands were instrumented; preserving them as literals would -/// silently disable conversation tracking. Convert exactly those two forms, accepting v1's -/// whitespace between tokens and single-quoted IDs. Require an exact registered word as the -/// first token. With a path (`/usr/local/bin/claude`, `~/bin/claude`), preserve the user's -/// chosen binary as a literal rather than substitute the first binary found on `PATH`; -/// managed resume tracking is then unavailable. Leave all other commands literal. +/// Rewrite a v1 recipe's literal entries in place to the corresponding managed form; labels +/// are untouched. In v1, bare program words and canonical resume commands were +/// instrumented; preserving them as literals would silently disable conversation tracking. +/// Convert exactly those two forms, accepting v1's whitespace between tokens and +/// single-quoted IDs. Require an exact registered word as the first token. With a path +/// (`/usr/local/bin/claude`, `~/bin/claude`), preserve the user's chosen binary as a literal +/// rather than substitute the first binary found on `PATH`; managed resume tracking is then +/// unavailable. Leave all other commands literal. /// -/// Remove this conversion one release after format v2 ships. After removal, load -/// unconverted v1 entries as literals: continue to accept the file, but without managed -/// resume tracking. -fn migrate_v1(cmd: &str) -> EntryKind { - let literal = || EntryKind::Literal(cmd.to_string()); - let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); - let Some(h) = words.next().and_then(harness::registered) else { - return literal(); - }; - let (agent, selector) = h.shape(); - let managed = |resume| EntryKind::Managed { - agent: agent.to_string(), - resume, - }; - match (words.next(), words.next(), words.next()) { - (None, _, _) => managed(None), - (Some(sel), Some(id), None) if sel == selector => { - let id = id - .strip_prefix('\'') - .and_then(|t| t.strip_suffix('\'')) - .unwrap_or(id); - if harness::is_uuid(id) { - managed(Some(id.to_string())) - } else { - literal() +/// Phase 5, one release after format v2 ships, deletes this function, its one call in +/// `from_json`, and its tests (`v1_literals_migrate_exactly_the_two_instrumented_shapes`, +/// `v1_recovery_snapshots_migrate_on_load`). A v1 file still loads afterward, with every +/// entry literal: the file is accepted, managed resume tracking is not. +fn migrate_v1(cfg: &mut SessionConfig) { + /// The managed form of one v1 literal, or `None` to leave it literal. + fn managed_form(cmd: &str) -> Option { + let mut words = cmd.split([' ', '\t']).filter(|w| !w.is_empty()); + let (agent, selector) = words.next().and_then(harness::registered)?.shape(); + let resume = match (words.next(), words.next(), words.next()) { + (None, _, _) => None, + (Some(sel), Some(id), None) if sel == selector => { + let id = id + .strip_prefix('\'') + .and_then(|t| t.strip_suffix('\'')) + .unwrap_or(id); + if !harness::is_uuid(id) { + return None; + } + Some(id.to_string()) } + _ => return None, + }; + Some(EntryKind::Managed { + agent: agent.to_string(), + resume, + }) + } + for entry in cfg.values_mut().flatten() { + if let EntryKind::Literal(cmd) = &entry.kind + && let Some(kind) = managed_form(cmd) + { + entry.kind = kind; } - _ => literal(), } } @@ -171,10 +202,10 @@ pub fn fingerprint_json(cfg: &SessionConfig) -> String { /// Parse wrapped and flat schemas, returning the stored name when present. Identify a /// wrapped file by object-valued `dirs`; accept top-level entry arrays as a flat map, /// including an array under a directory named `dirs`. Require an integer `version` from 1 -/// through [`FORMAT_VERSION`]. Treat a missing version as 1 and convert its literal entries -/// through [`migrate_v1`]. Accept entry forms regardless of version: strings and `{"cmd"}` -/// objects for literals, `{"agent"}` objects for managed agents. Reject the whole file for -/// objects with both keys, unregistered agents, or resume IDs that are not strict UUIDs. +/// through [`FORMAT_VERSION`]. Treat a missing version as 1. Accept entry forms regardless +/// of version: strings and `{"cmd"}` objects for literals, `{"agent"}` objects for managed +/// agents. Reject the whole file for objects with both keys, unregistered agents, or resume +/// IDs that are not strict UUIDs. Pass a v1 recipe through [`migrate_v1`] once it is built. fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { let invalid = |message| io::Error::new(io::ErrorKind::InvalidData, message); let parsed = jzon::parse(text).map_err(|e| invalid(e.to_string()))?; @@ -182,11 +213,9 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid("session root: expected an object".into())); } // Validate version metadata before detecting the schema shape. - let version = &parsed["version"]; - let mut v1 = true; - if parsed.has_key("version") { - match version.as_u64() { - Some(n) if (1..=FORMAT_VERSION).contains(&n) => v1 = n == 1, + let version = if parsed.has_key("version") { + match parsed["version"].as_u64() { + Some(n) if (1..=FORMAT_VERSION).contains(&n) => n, Some(n) if n > FORMAT_VERSION => { return Err(invalid(format!( "session format version {n} is newer than this fleetcom \ @@ -198,11 +227,13 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid(format!( "session format version {} is not one this fleetcom reads \ (supports {FORMAT_VERSION}); load it with a newer build", - version.dump() + parsed["version"].dump() ))); } } - } + } else { + 1 + }; let (name, dirs, flat) = if parsed["dirs"].is_object() { let name = opt_str(&parsed["name"]) .ok_or_else(|| invalid("session field \"name\": expected a string or null".into()))?; @@ -220,18 +251,10 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { return Err(invalid(format!("directory {dir:?}: expected an array"))); } let mut entries = Vec::new(); - // In v1, managed launches could only be stored as command text. - let literal = |cmd: &str| { - if v1 { - migrate_v1(cmd) - } else { - EntryKind::Literal(cmd.to_string()) - } - }; for (index, member) in val.members().enumerate() { if let Some(cmd) = member.as_str() { entries.push(SessionEntry { - kind: literal(cmd), + kind: EntryKind::Literal(cmd.to_string()), group: None, name: None, }); @@ -281,7 +304,7 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { let cmd = member["cmd"].as_str().ok_or_else(|| { invalid(format!("{location}, field \"cmd\": expected a string")) })?; - literal(cmd) + EntryKind::Literal(cmd.to_string()) }; entries.push(SessionEntry { kind, @@ -291,6 +314,10 @@ fn from_json(text: &str) -> io::Result<(Option, SessionConfig)> { } cfg.insert(dir.to_string(), entries); } + // In v1, managed launches could only be stored as command text. + if version == 1 { + migrate_v1(&mut cfg); + } Ok((name, cfg)) } @@ -567,11 +594,12 @@ mod tests { /// Unadorned literal: the plain-string member form. fn e(cmd: &str) -> SessionEntry { - SessionEntry { - kind: EntryKind::Literal(cmd.into()), - group: None, - name: None, - } + SessionEntry::literal(cmd) + } + + /// Unadorned managed entry: the `{"agent"[, "resume"]}` member form. + fn m(agent: &str, resume: Option<&str>) -> SessionEntry { + SessionEntry::managed(agent, resume) } /// Grouped literal: the `{"cmd", "group"}` member form. @@ -599,18 +627,6 @@ mod tests { } } - /// Unadorned managed entry: the `{"agent"[, "resume"]}` member form. - fn m(agent: &str, resume: Option<&str>) -> SessionEntry { - SessionEntry { - kind: EntryKind::Managed { - agent: agent.into(), - resume: resume.map(String::from), - }, - group: None, - name: None, - } - } - #[test] fn round_trips_dirs_and_commands() { let dir = temp("session_roundtrip"); @@ -699,22 +715,6 @@ mod tests { assert_eq!(to_json("work", &cfg), expected); } - /// Saved files include the accepted format version. - #[test] - fn save_writes_version_2_and_load_accepts_it() { - let dir = temp("session_version_roundtrip"); - let mut cfg = SessionConfig::new(); - cfg.insert("~/p".into(), vec![e("vim")]); - - let file = save_in(&dir, "versioned", &cfg).unwrap(); - assert!( - fs::read_to_string(&file) - .unwrap() - .contains("\"version\": 2") - ); - assert_eq!(load_in(&dir, "versioned").unwrap(), cfg); - } - /// Round-trip managed entries in every label combination, with and without resume IDs, /// alongside literals. #[test] @@ -864,8 +864,13 @@ mod tests { ("vim".into(), e("vim")), (String::new(), e("")), ]; - for (cmd, expected) in &cases { - assert_eq!(migrate_v1(cmd), expected.kind, "{cmd:?}"); + let mut cfg = SessionConfig::from([( + "d".to_string(), + cases.iter().map(|(cmd, _)| e(cmd)).collect(), + )]); + migrate_v1(&mut cfg); + for ((cmd, expected), got) in cases.iter().zip(&cfg["d"]) { + assert_eq!(got, expected, "{cmd:?}"); } // Through the loader: every v1 spelling, wrapped and flat, string and // object form, with labels carried over. @@ -978,14 +983,6 @@ mod tests { assert_eq!(cfg["~/proj"], vec![e("vim")]); } - /// An explicit `"version": 1` passes the gate. - #[test] - fn explicit_version_1_loads() { - let (_, cfg) = - from_json(r#"{"version": 1, "name": "v", "dirs": {"~/proj": ["vim"]}}"#).unwrap(); - assert_eq!(cfg["~/proj"], vec![e("vim")]); - } - /// Reject newer formats and name both versions in the error. Apply the same version /// check as v1 builds when reading v2 files. #[test] diff --git a/src/supervisor.rs b/src/supervisor.rs index 242674a..7cc3de9 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -7,6 +7,7 @@ use std::{ collections::BTreeMap, ffi::OsString, + fmt::Write, io, path::{Path, PathBuf}, sync::{Arc, Mutex, OnceLock, mpsc::Sender}, @@ -96,7 +97,7 @@ impl LoadOutcome { summary.push_str("; "); summary.push_str(note); if count > 1 { - summary.push_str(&format!(" ({count} tasks)")); + let _ = write!(summary, " ({count} tasks)"); } } summary @@ -104,26 +105,24 @@ impl LoadOutcome { } /// Resolve the registered harness and current binary on `launch`'s `PATH`, then build a -/// managed launch with `intent`. Return a diagnostic for the caller if either lookup fails. +/// managed launch that resumes `resume`, or starts fresh without one. Return a diagnostic +/// for the caller if either lookup fails. fn resolve_agent( agent: &str, launch: &LaunchContext, - intent: Intent, + resume: Option, ) -> Result { let h = harness::registered(agent).ok_or_else(|| format!("no agent named {agent:?}"))?; + let path = env_get(&launch.env, "PATH").unwrap_or_default(); + let binary = + harness::find_on_path(agent, path).ok_or_else(|| format!("{agent} not found on PATH"))?; Ok(ManagedLaunch { agent: h, - binary: agent_binary(agent, launch)?, - intent, + binary, + intent: resume.map_or(Intent::Fresh, Intent::Resume), }) } -/// Resolve `agent` on `launch`'s `PATH`, or return a diagnostic for the caller. -fn agent_binary(agent: &str, launch: &LaunchContext) -> Result { - let path = env_get(&launch.env, "PATH").unwrap_or_default(); - harness::find_on_path(agent, path).ok_or_else(|| format!("{agent} not found on PATH")) -} - /// Environment variable overriding per-task terminal history depth. pub const FLEETCOM_SCROLLBACK: &str = "FLEETCOM_SCROLLBACK"; @@ -206,13 +205,13 @@ fn fnv1a_hex(bytes: &[u8]) -> String { /// Resolve a managed task's session ID in precedence order: capture file, live registry, /// then spawn-time ID. Prefer the first two sources because a session may have been -/// selected after launch. Validate capture through the harness using the task's leader PID -/// and launch-time home. On rejection, try the next source. For literal tasks, skip all -/// sources and return `None`. +/// selected after launch. Validate capture through the harness using the task's leader PID. +/// On rejection, try the next source. For literal tasks, skip all sources and return +/// `None`. fn current_resume_id(task: &Task) -> Option { if let (Some(h), Some(path)) = (task.harness, &task.capture_file) && let Ok(payload) = std::fs::read_to_string(path) - && let Some(id) = h.parse_capture(&payload, task.pid(), task.harness_home.as_deref()) + && let Some(id) = h.parse_capture(&payload, task.pid()) { return Some(id); } @@ -230,7 +229,7 @@ fn current_resume_id(task: &Task) -> Option { } /// Resolve harness configuration from the task's launch environment. -fn harness_home(env: &[(OsString, OsString)], h: &dyn harness::Harness) -> Option { +fn harness_home(env: &[(OsString, OsString)], h: &dyn Harness) -> Option { h.resolve_home(&|key| env_get(env, key).map(PathBuf::from)) } @@ -599,7 +598,7 @@ impl Supervisor { cwd: t.cwd.clone(), tagged: t.tagged, flagship: self.flagship == Some(t.id), - managed: t.managed, + managed: t.harness.is_some(), group: t.group.clone(), name: t.name.clone(), lifecycle: t.lifecycle(now, IDLE_AFTER), @@ -837,7 +836,7 @@ impl Supervisor { let mut meta = None; let mut notice = None; let (command, exec) = match launch { - Launch::Literal(text) => (*text, Exec::Literal((*text).to_string())), + Launch::Literal(text) => (*text, Exec::Literal), Launch::Managed(m) => { // Require the overlay to retain process ownership. Without the embedded // override, codex would use its shared server outside fleetcom supervision. @@ -974,7 +973,7 @@ impl Supervisor { let Some(launch) = self.launch_or_refuse() else { return; }; - let managed = match resolve_agent(agent, &launch, Intent::Fresh) { + let managed = match resolve_agent(agent, &launch, None) { Ok(m) => m, Err(why) => return self.status(format!("{why}, not spawning")), }; @@ -1010,17 +1009,11 @@ impl Supervisor { let command; let relaunch = match self.tasks[i].harness { Some(agent) => { - let binary = match agent_binary(agent.shape().0, &launch) { - Ok(b) => b, + let resume = current_resume_id(&self.tasks[i]); + match resolve_agent(agent.shape().0, &launch, resume) { + Ok(m) => Launch::Managed(m), Err(why) => return self.status(format!("{why}, not spawning")), - }; - let intent = - current_resume_id(&self.tasks[i]).map_or(Intent::Fresh, Intent::Resume); - Launch::Managed(ManagedLaunch { - agent, - binary, - intent, - }) + } } None => { command = self.tasks[i].command.clone(); @@ -1149,8 +1142,7 @@ impl Supervisor { Launch::Literal(text) } EntryKind::Managed { agent, resume } => { - let intent = resume.clone().map_or(Intent::Fresh, Intent::Resume); - match resolve_agent(agent, &launch, intent) { + match resolve_agent(agent, &launch, resume.clone()) { Ok(m) => Launch::Managed(m), Err(why) => { out.failed += 1; @@ -1246,10 +1238,10 @@ impl Supervisor { // Append optional clauses to the fixed message prefix. let mut msg = String::from("loaded recovery snapshot; save to name it"); if out.skipped > 0 { - msg.push_str(&format!(", {} skipped ({SKIP_REASONS})", out.skipped)); + let _ = write!(msg, ", {} skipped ({SKIP_REASONS})", out.skipped); } if out.failed > 0 { - msg.push_str(&format!(", {} failed to spawn", out.failed)); + let _ = write!(msg, ", {} failed to spawn", out.failed); } self.status(out.annotate(msg)); } diff --git a/src/supervisor_capture_tests.rs b/src/supervisor_capture_tests.rs index 87e5a42..a73616a 100644 --- a/src/supervisor_capture_tests.rs +++ b/src/supervisor_capture_tests.rs @@ -1,8 +1,11 @@ use super::*; use crate::{ - harness::fixtures::{ID as CAP_ID, OTHER as CAP_OTHER}, + harness::fixtures::{CODEX_CHILD, CODEX_ROOT, CODEX_TITLE, ID as CAP_ID, OTHER as CAP_OTHER}, protocol::{Lifecycle, Preview, PreviewSource}, - testutil::{codex_session_meta, dead_pid, install_codex_rollout, install_codex_root}, + testutil::{ + codex_session_meta, dead_pid, install_codex_rollout, install_codex_root, + install_resident_shell, + }, }; // --- session-capture wiring ------------------------------------------- @@ -102,6 +105,11 @@ fn stamped(task: &Task, json: &str) -> String { ) } +/// The JSON Claude's `SessionStart` hook writes for session `id` started from `source`. +fn hook_json(id: &str, source: &str) -> String { + format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) +} + /// Save a recipe and return its persisted JSON. fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { s.apply(Command::SaveSession { name: name.into() }); @@ -109,9 +117,9 @@ fn save_and_read(s: &mut Supervisor, config: &Path, name: &str) -> String { std::fs::read_to_string(config.join("sessions").join(format!("{name}.json"))).unwrap() } -/// Drain the queued events and return the status lines among them. -fn notices(s: &mut Supervisor) -> Vec { - s.drain() +/// The status lines among `events`. +fn notices(events: Vec) -> Vec { + events .into_iter() .filter_map(|e| match e { Event::Status(m) => Some(m), @@ -120,10 +128,10 @@ fn notices(s: &mut Supervisor) -> Vec { .collect() } -/// Require exactly one queued status containing `needle`. Match the distinctive token so -/// message wording can change without affecting the behavior check. -fn assert_sole_notice(s: &mut Supervisor, needle: &str) { - let got = notices(s); +/// Require exactly one status among `events`, containing `needle`. Match the distinctive +/// token so message wording can change without affecting the behavior check. +fn assert_sole_notice(events: Vec, needle: &str) { + let got = notices(events); assert!( got.len() == 1 && got[0].contains(needle), "expected one notice naming {needle:?}; got {got:?}" @@ -135,27 +143,6 @@ fn acknowledged(events: &[Event]) -> bool { events.iter().any(|e| matches!(e, Event::Spawned { .. })) } -/// An unlabelled managed entry. -fn managed(agent: &str, resume: Option<&str>) -> SessionEntry { - SessionEntry { - kind: EntryKind::Managed { - agent: agent.into(), - resume: resume.map(String::from), - }, - group: None, - name: None, - } -} - -/// An unlabelled literal entry. -fn literal(cmd: &str) -> SessionEntry { - SessionEntry { - kind: EntryKind::Literal(cmd.into()), - group: None, - name: None, - } -} - /// Save `entries` for `dir` through the real serializer, then load the named recipe. Use /// this path to start a managed task with a specified resume target. fn load_recipe( @@ -193,6 +180,22 @@ fn saved_resume(s: &mut Supervisor, config: &Path, name: &str) -> Option } } +/// Tick until the recovery snapshot lands under `config`'s session root, then load it. One +/// incarnation owns one snapshot file, so the sole file is the one to read. +fn recovered(s: &mut Supervisor, config: &Path) -> SessionConfig { + assert!( + wait_until(Duration::from_secs(5), || { + s.tick(); + !recovery_files(config).is_empty() + }), + "the recovery snapshot never landed" + ); + let files = recovery_files(config); + assert_eq!(files.len(), 1); + let stem = files[0].strip_suffix(".json").unwrap(); + session::load_recovery_in(&config.join("sessions/recovery"), stem).unwrap() +} + /// The FNV-1a discriminator is stable and separates distinct config roots. #[test] fn fnv_discriminator_is_stable_and_distinguishes_roots() { @@ -329,7 +332,6 @@ fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { "a literal launch adds no argument and names no capture file" ); let t = &s.tasks[0]; - assert!(!t.managed); assert!(t.harness.is_none(), "a literal task has no harness channel"); assert!(t.capture_file.is_none()); assert!(t.resume_id.is_none()); @@ -367,7 +369,7 @@ fn typed_agent_word_is_literal_runs_verbatim_and_saves_as_text() { ); assert_eq!(current_resume_id(&s.tasks[0]), None); let entries = saved_entries(&mut s, &config, "typed"); - assert_eq!(entries[0], literal("claude")); + assert_eq!(entries[0], SessionEntry::literal("claude")); assert!(matches!( &entries[1].kind, EntryKind::Managed { agent, resume: Some(_) } if agent == "claude" @@ -416,12 +418,7 @@ fn rerun_cannot_read_the_old_runs_stale_capture() { // A late hook write can recreate the old path, but the new run cannot read it. // Stamp with the new leader's PID to test isolation by path alone. - let stale = stamped( - &s.tasks[0], - &format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"startup"}}"# - ), - ); + let stale = stamped(&s.tasks[0], &hook_json(CAP_OTHER, "startup")); std::fs::write(&old_cap, &stale).unwrap(); assert_eq!( saved_resume(&mut s, &config, "stalecap").as_deref(), @@ -636,7 +633,7 @@ fn spawn_grok_pins_an_id_and_injects_nothing_else() { assert_eq!( saved_entries(&mut s, &config, "grokpin"), - [managed("grok", Some(&id))], + [SessionEntry::managed("grok", Some(&id))], "the recipe must resume the pinned session" ); } @@ -763,7 +760,7 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { vec![SessionEntry { group: Some("agents".into()), name: Some(tool.into()), - ..managed(tool, expected_id.as_deref()) + ..SessionEntry::managed(tool, expected_id.as_deref()) }], )]); save_and_read(&mut s, &config, "hints"); @@ -772,21 +769,7 @@ fn printed_resume_hints_do_not_change_saved_recovery_or_rerun_targets() { expected, "{tool}: named save" ); - assert!( - wait_until(Duration::from_secs(5), || { - s.tick(); - !recovery_files(&config).is_empty() - }), - "{tool}: recovery snapshot never landed" - ); - let files = recovery_files(&config); - assert_eq!(files.len(), 1); - let stem = files[0].strip_suffix(".json").unwrap(); - assert_eq!( - session::load_recovery_in(&config.join("sessions/recovery"), stem).unwrap(), - expected, - "{tool}: recovery" - ); + assert_eq!(recovered(&mut s, &config), expected, "{tool}: recovery"); let id = s.tasks[0].id; s.apply(Command::Restart { id }); assert_eq!(s.tasks[0].run, 1, "{tool}: rerun must replace the task"); @@ -823,10 +806,10 @@ fn rerun_latches_exit_without_reap_and_preserves_the_launch_id() { &config, "target", &dir, - vec![managed("grok", Some(CAP_OTHER))], + vec![SessionEntry::managed("grok", Some(CAP_OTHER))], ); - assert_eq!(s.tasks.len(), 1, "{:?}", notices(&mut s)); - assert!(s.tasks[0].managed); + assert_eq!(s.tasks.len(), 1, "{:?}", notices(s.drain())); + assert!(s.tasks[0].harness.is_some()); assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_OTHER)); let id = s.tasks[0].id; let pid = Pid::from_raw(s.tasks[0].pid().unwrap() as i32).unwrap(); @@ -897,16 +880,7 @@ fn resume_id_precedence_registry_over_spawn_under_capture() { // A capture-file ID outranks the registry ID. let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write( - &cap, - stamped( - &s.tasks[0], - &format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ), - ) - .unwrap(); + std::fs::write(&cap, stamped(&s.tasks[0], &hook_json(CAP_OTHER, "clear"))).unwrap(); assert_eq!( saved_resume(&mut s, &config, "capture").as_deref(), Some(CAP_OTHER), @@ -926,9 +900,6 @@ fn capture_stamped_by_a_foreign_process_falls_back_to_the_next_source() { let dir = scratch("cap_foreign"); let (bin, runtime, config) = (dir.join("bin"), dir.join("run"), dir.join("config")); let claude_home = dir.join("claude_home"); - let json = |id: &str, source: &str| { - format!(r#"{{"session_id":"{id}","hook_event_name":"SessionStart","source":"{source}"}}"#) - }; // Write a capture stamped with the task leader's PID. Then run the installed hook from // the stub through an intermediate shell, with the same inherited environment as in // Claude's daemon. Append `:` to prevent exec of the pipeline's last command: the @@ -942,7 +913,7 @@ sh -c 'printf "%s\n" "$1" | sh "$0"; :' '{d}/hook' '{foreign}' : > '{d}/foreign-done' until [ -e '{d}/done' ]; do sleep 0.05; done"#, d = dir.display(), - foreign = json(FOREIGN_ID, "startup"), + foreign = hook_json(FOREIGN_ID, "startup"), ), ); let mut s = sup_ctx(agent_ctx_plus( @@ -970,7 +941,7 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, .to_string(); std::fs::write(dir.join("hook"), hook).unwrap(); - std::fs::write(&cap, stamped(&s.tasks[0], &json(CAP_OTHER, "clear"))).unwrap(); + std::fs::write(&cap, stamped(&s.tasks[0], &hook_json(CAP_OTHER, "clear"))).unwrap(); assert_eq!( current_resume_id(&s.tasks[0]).as_deref(), Some(CAP_OTHER), @@ -988,7 +959,7 @@ until [ -e '{d}/done' ]; do sleep 0.05; done"#, .expect("the hook must write a stamp line"); assert_eq!( rest, - format!("{}\n", json(FOREIGN_ID, "startup")), + format!("{}\n", hook_json(FOREIGN_ID, "startup")), "the foreign session must have replaced the task's capture" ); assert!( @@ -1080,7 +1051,10 @@ fn silent_codex_tasks_save_and_recover_without_a_resume_id() { } let expected = SessionConfig::from([( path::abbreviate(&dir), - vec![managed("codex", None), managed("codex", None)], + vec![ + SessionEntry::managed("codex", None), + SessionEntry::managed("codex", None), + ], )]); save_and_read(&mut s, &config, "silent"); assert_eq!( @@ -1088,29 +1062,9 @@ fn silent_codex_tasks_save_and_recover_without_a_resume_id() { expected ); - assert!( - wait_until(Duration::from_secs(5), || { - s.tick(); - !recovery_files(&config).is_empty() - }), - "the recovery snapshot never landed" - ); - let files = recovery_files(&config); - assert_eq!(files.len(), 1); - let stem = files[0].strip_suffix(".json").unwrap(); - let recovery = config.join("sessions").join("recovery"); - assert_eq!( - session::load_recovery_in(&recovery, stem).unwrap(), - expected - ); + assert_eq!(recovered(&mut s, &config), expected); } -/// Thread IDs reported through one codex process's notifier: the -/// conversation, a spawned sub-agent, and the hidden title thread. -const CODEX_ROOT: &str = "019f5453-de22-7240-b2e5-0d32692aa6d9"; -const CODEX_CHILD: &str = "019f5454-0c11-7b33-9a4e-5f0e6d7c8b9a"; -const CODEX_TITLE: &str = "019f5453-de9f-7e61-8c0d-1a2b3c4d5e6f"; - /// Notification JSON passed to the injected notifier after a completed turn of `thread`. /// `last` is the final assistant message, already escaped for a JSON string. fn turn_complete(thread: &str, last: &str) -> String { @@ -1240,9 +1194,9 @@ fn refused_codex_capture_keeps_the_launch_target() { &config, "target", &dir, - vec![managed("codex", Some(CAP_ID))], + vec![SessionEntry::managed("codex", Some(CAP_ID))], ); - assert_eq!(s.tasks.len(), 1, "{:?}", notices(&mut s)); + assert_eq!(s.tasks.len(), 1, "{:?}", notices(s.drain())); assert_eq!(s.tasks[0].resume_id.as_deref(), Some(CAP_ID)); assert_eq!(arrive(&s, &codex_home, &title_turn()), None); @@ -1338,7 +1292,7 @@ fn home_only_launch_env_targets_the_clients_dot_codex() { assert_eq!( saved_entries(&mut s, &config, "homeonly"), - [managed("codex", None)] + [SessionEntry::managed("codex", None)] ); } @@ -1532,7 +1486,7 @@ fn unrepresentable_config_notify_suppresses_injection() { &[("CODEX_HOME", &codex_home)], )); s.spawn_agent("codex", dir.to_path_buf(), None); - assert_sole_notice(&mut s, "capture unavailable"); + assert_sole_notice(s.drain(), "capture unavailable"); let argv = wait_argv(&mut s, &dir.join("argv")); assert_eq!( argv, @@ -1548,7 +1502,7 @@ fn unrepresentable_config_notify_suppresses_injection() { .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); s.spawn_agent("codex", dir.to_path_buf(), None); - assert_eq!(notices(&mut s), Vec::::new()); + assert_eq!(notices(s.drain()), Vec::::new()); let argv = wait_argv(&mut s, &dir.join("argv")); assert!( argv.iter().any(|a| a.starts_with("notify=[")), @@ -1577,7 +1531,10 @@ fn non_agent_entries_survive_save_as_plain_strings() { "no object form for an unadorned entry; got {text}" ); let cfg = session::load_in(&config.join("sessions"), "plain").unwrap(); - assert_eq!(cfg[&path::abbreviate(&dir)], vec![literal("sleep 30")]); + assert_eq!( + cfg[&path::abbreviate(&dir)], + vec![SessionEntry::literal("sleep 30")] + ); } /// Cadence passes persist changed capture IDs without rewriting stable recipes. @@ -1614,16 +1571,7 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { // Change the capture ID without a recipe mutation. let cap = s.tasks[0].capture_file.clone().expect("capture file set"); - std::fs::write( - &cap, - stamped( - &s.tasks[0], - &format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ), - ) - .unwrap(); + std::fs::write(&cap, stamped(&s.tasks[0], &hook_json(CAP_OTHER, "clear"))).unwrap(); assert!( wait_until(Duration::from_secs(5), || { s.tick(); @@ -1649,25 +1597,6 @@ fn recovery_cadence_rewrites_on_capture_drift_and_skips_when_static() { // --- managed launches -------------------------------------------------- -/// Install a resident shell at `/resident-sh`: run its `-c` text as a child and retain -/// the shell as leader, as with tcsh and csh. Set it as `SHELL` to verify that managed -/// launches bypass the shell. -fn install_resident_shell(dir: &Path) -> PathBuf { - let shell = dir.join("resident-sh"); - // Append `:` to prevent sh from execing the child as the last command. - write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); - shell -} - -/// `agent_ctx` with `SHELL` pointing at the resident shell. -fn resident_shell_ctx(bin: &Path, runtime: &Path, dir: &Path) -> LaunchContext { - let shell = install_resident_shell(dir); - let mut ctx = agent_ctx(bin, runtime, dir.to_path_buf()); - ctx.env.retain(|(k, _)| k != "SHELL"); - ctx.env.push(("SHELL".into(), shell.into_os_string())); - ctx -} - /// The settings overlay beside the sole task's capture file. fn settings_beside(s: &Supervisor) -> String { s.tasks[0] @@ -1698,7 +1627,13 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { out = dir.display() ), ); - let mut s = sup_ctx(resident_shell_ctx(&bin, &runtime, &dir)); + let mut ctx = agent_ctx(&bin, &runtime, dir.to_path_buf()); + ctx.env.retain(|(k, _)| k != "SHELL"); + ctx.env.push(( + "SHELL".into(), + install_resident_shell(&dir).into_os_string(), + )); + let mut s = sup_ctx(ctx); s.spawn_agent("claude", dir.to_path_buf(), None); assert!(acknowledged(&s.drain()), "a managed spawn is acknowledged"); let argv = wait_argv(&mut s, &dir.join("argv")); @@ -1706,7 +1641,6 @@ fn managed_claude_accepts_its_own_capture_and_refuses_a_foreign_stamp() { wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); let t = &s.tasks[0]; - assert!(t.managed); assert_eq!(t.command, "claude", "the program word is the display text"); assert!(t.harness.is_some()); let pinned = t @@ -1746,11 +1680,11 @@ fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { s.spawn_agent("vim", dir.to_path_buf(), None); let events = s.drain(); assert!(!acknowledged(&events)); - assert_sole_notice(&mut sup_with(events), "no agent named"); + assert_sole_notice(events, "no agent named"); s.spawn_agent("claude", dir.to_path_buf(), None); let events = s.drain(); assert!(!acknowledged(&events)); - assert_sole_notice(&mut sup_with(events), "not found on PATH"); + assert_sole_notice(events, "not found on PATH"); assert!(s.tasks.is_empty(), "a refused launch creates nothing"); assert!(!runtime.exists(), "a refused launch installs nothing"); @@ -1760,19 +1694,12 @@ fn managed_spawn_refuses_an_unknown_word_a_missing_binary_and_a_full_fleet() { assert!(acknowledged(&s.drain())); assert_eq!(s.tasks.len(), 1); s.spawn_agent("claude", dir.to_path_buf(), None); - assert_sole_notice(&mut s, "task limit"); + assert_sole_notice(s.drain(), "task limit"); spawn(&mut s, "sleep 1", dir.to_path_buf()); - assert_sole_notice(&mut s, "task limit"); + assert_sole_notice(s.drain(), "task limit"); assert_eq!(s.tasks.len(), 1, "the ceiling holds for both launch kinds"); } -/// Build a supervisor with only the supplied `events` for filtering through `notices`. -fn sup_with(events: Vec) -> Supervisor { - let mut s = Supervisor::new(24, 80, 0); - s.events = events; - s -} - /// Rerun a managed task through its harness: place the captured ID before the overlay; /// preserve id, tag, group, name, and program word; increment the run number. Resolve the /// binary on `PATH` again. If removed since launch, refuse the rerun and preserve the @@ -1791,12 +1718,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); std::fs::write( s.tasks[0].capture_file.as_ref().unwrap(), - stamped( - &s.tasks[0], - &format!( - r#"{{"session_id":"{CAP_OTHER}","hook_event_name":"SessionStart","source":"clear"}}"# - ), - ), + stamped(&s.tasks[0], &hook_json(CAP_OTHER, "clear")), ) .unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); @@ -1810,7 +1732,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { "the intent part leads, the overlay follows, no second pin" ); let t = &s.tasks[0]; - assert!(t.managed, "a rerun keeps the task managed"); + assert!(t.harness.is_some(), "a rerun keeps the task managed"); assert_eq!((t.id, t.run, t.command.as_str()), (id, 1, "claude")); assert_eq!(t.resume_id.as_deref(), Some(CAP_OTHER)); assert!(t.tagged); @@ -1829,7 +1751,7 @@ fn managed_rerun_resumes_the_captured_id_from_the_binary_path_finds_now() { wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); std::fs::remove_file(bin.join("claude")).unwrap(); s.apply(Command::Restart { id }); - assert_sole_notice(&mut s, "not found on PATH"); + assert_sole_notice(s.drain(), "not found on PATH"); assert_eq!(s.tasks[0].run, 1, "the finished task is preserved"); } @@ -1853,13 +1775,13 @@ fn managed_rerun_without_an_id_starts_fresh() { s.apply(Command::Restart { id }); let again = wait_argv(&mut s, &dir.join("argv")); assert_eq!(again, first, "no id known: the rerun is a fresh launch"); - assert!(s.tasks[0].managed); + assert!(s.tasks[0].harness.is_some()); assert_eq!(s.tasks[0].run, 1); } -/// Save a managed entry with its current session ID, then reload it as managed. Place the -/// selector and ID first in argv, execute the binary directly even with a resident `SHELL`, -/// and preserve the group. +/// Save a managed entry with its current session ID, then reload it as managed: the +/// selector and ID lead argv, the reloaded run's own capture is accepted, and the group +/// survives. #[test] fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { let dir = scratch("managed_save"); @@ -1882,15 +1804,15 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { ), ); install_stub(&bin, "omp", &omp_out); - let mut ctx = resident_shell_ctx(&bin, &runtime, &dir); - ctx.env.push(( - "FLEETCOM_CONFIG_DIR".into(), - config.as_os_str().to_os_string(), + let mut s = sup_ctx(agent_ctx_plus( + &bin, + &runtime, + dir.to_path_buf(), + &[("FLEETCOM_CONFIG_DIR", &config)], )); - let mut s = sup_ctx(ctx); s.spawn_agent("claude", dir.to_path_buf(), Some("agents".into())); s.spawn_agent("omp", dir.to_path_buf(), None); - assert!(s.tasks.iter().all(|t| t.managed)); + assert!(s.tasks.iter().all(|t| t.harness.is_some())); // Wait for both first-run records before touching captures or clearing // them, so a late write from the original launch cannot pass for the // reloaded one. @@ -1912,9 +1834,9 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { vec![ SessionEntry { group: Some("agents".into()), - ..managed("claude", Some(CAP_ID)) + ..SessionEntry::managed("claude", Some(CAP_ID)) }, - managed("omp", None), + SessionEntry::managed("omp", None), ] )]) ); @@ -1924,14 +1846,15 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { s.apply(Command::LoadSession { name: "managed".into(), }); - assert_eq!(s.tasks.len(), 4, "{:?}", notices(&mut s)); + assert_eq!(s.tasks.len(), 4, "{:?}", notices(s.drain())); let reloaded = &s.tasks[2]; - assert!(reloaded.managed, "a managed entry reloads managed"); + assert!( + reloaded.harness.is_some(), + "a managed entry reloads managed" + ); assert_eq!(reloaded.command, "claude"); - assert!(reloaded.harness.is_some()); assert_eq!(reloaded.resume_id.as_deref(), Some(CAP_ID)); assert_eq!(reloaded.group.as_deref(), Some("agents")); - assert!(s.tasks[3].managed); assert_eq!(s.tasks[3].command, "omp"); assert!(s.tasks[3].harness.is_some()); assert!(s.tasks[3].resume_id.is_none()); @@ -1943,7 +1866,7 @@ fn managed_task_saves_as_a_managed_entry_and_reloads_managed() { assert_eq!( current_resume_id(&s.tasks[2]).as_deref(), Some(CAP_OTHER), - "the reloaded stub's `$$` stamp passes the gate: it ran as a direct child" + "the reloaded run's own capture outranks the recipe's resume ID" ); let omp_argv = wait_argv(&mut s, &omp_out.join("argv")); assert_eq!( @@ -1973,15 +1896,15 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { "fleet", &dir, vec![ - managed("grok", None), - literal("true"), + SessionEntry::managed("grok", None), + SessionEntry::literal("true"), SessionEntry { name: Some("pilot".into()), - ..managed("claude", Some(CAP_ID)) + ..SessionEntry::managed("claude", Some(CAP_ID)) }, ], ); - let got = notices(&mut s); + let got = notices(s.drain()); assert!( got.len() == 1 && got[0].contains("1 failed to spawn") @@ -1989,9 +1912,9 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { "one summary naming the missing agent; got {got:?}" ); assert_eq!(s.tasks.len(), 2); - assert!(!s.tasks[0].managed); + assert!(s.tasks[0].harness.is_none()); assert_eq!(s.tasks[0].command, "true"); - assert!(s.tasks[1].managed); + assert!(s.tasks[1].harness.is_some()); assert_eq!(s.tasks[1].name.as_deref(), Some("pilot")); assert_eq!( wait_argv(&mut s, &dir.join("argv"))[..2], @@ -2001,7 +1924,7 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { let stem = "20990101-000000-1"; let cfg = SessionConfig::from([( dir.to_string_lossy().into_owned(), - vec![managed("grok", None)], + vec![SessionEntry::managed("grok", None)], )]); session::save_recovery_in( &session::recovery_dir(&config.join("sessions")), @@ -2011,11 +1934,12 @@ fn load_reports_a_missing_agent_and_loads_the_rest() { ) .unwrap(); s.apply(Command::LoadRecovery { stem: stem.into() }); - assert_sole_notice(&mut s, "grok not found on PATH"); + assert_sole_notice(s.drain(), "grok not found on PATH"); } -/// Include the embedded override on managed codex launches even with capture disabled, and -/// report the reason. On rerun, place `resume ` before the overrides. +/// With capture disabled, a rerun places `resume ` before the embedded override and +/// repeats the capture notice. The launch's own notice and embedded-only argv are pinned by +/// `unrepresentable_config_notify_suppresses_injection`. #[test] fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { let dir = scratch("managed_codex"); @@ -2030,11 +1954,10 @@ fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { &[("CODEX_HOME", &codex_home)], )); s.spawn_agent("codex", dir.to_path_buf(), None); - assert_sole_notice(&mut s, "capture unavailable"); - let argv = wait_argv(&mut s, &dir.join("argv")); - assert_eq!(argv, ["-c", "features.daemon_auto_start=false"]); + // Drain the launch notice so the rerun's is the sole one. + let _ = s.drain(); + let _ = wait_argv(&mut s, &dir.join("argv")); let id = s.tasks[0].id; - assert!(s.tasks[0].managed); assert!(s.tasks[0].resume_id.is_none(), "codex cannot pin an id"); wait_for_lifecycle(&mut s, id, |l| l == Lifecycle::Ok); @@ -2042,7 +1965,7 @@ fn managed_codex_reports_the_capture_notice_and_leads_a_rerun_with_resume() { std::fs::write(s.tasks[0].capture_file.as_ref().unwrap(), CAP_ID).unwrap(); std::fs::remove_file(dir.join("argv")).unwrap(); s.apply(Command::Restart { id }); - assert_sole_notice(&mut s, "capture unavailable"); + assert_sole_notice(s.drain(), "capture unavailable"); assert_eq!( wait_argv(&mut s, &dir.join("argv")), ["resume", CAP_ID, "-c", "features.daemon_auto_start=false"] diff --git a/src/supervisor_tests.rs b/src/supervisor_tests.rs index b8e56b1..a0172ac 100644 --- a/src/supervisor_tests.rs +++ b/src/supervisor_tests.rs @@ -65,16 +65,11 @@ fn session_config_groups_by_dir_in_spawn_order() { spawn(&mut s, "c", here()); let cfg = s.session_config(); - let literal = |cmd: &str| SessionEntry { - kind: EntryKind::Literal(cmd.into()), - group: None, - name: None, - }; assert_eq!( cfg[&path::abbreviate(&here())], - vec![literal("a"), literal("c")] + vec![SessionEntry::literal("a"), SessionEntry::literal("c")] ); - assert_eq!(cfg["/tmp"], vec![literal("b")]); + assert_eq!(cfg["/tmp"], vec![SessionEntry::literal("b")]); } /// `tick` emits exactly a `Tasks` snapshot while nothing is watched, and @@ -1166,17 +1161,13 @@ fn resize_clamps_hostile_dimensions() { fn worst_case_screen_frame_fits_max_frame() { use std::fmt::Write as _; - use alacritty_terminal::{ - event::VoidListener, - index::{Column, Line}, - term::{Config, test::TermSize}, - vte::ansi::Processor, - }; + use alacritty_terminal::index::{Column, Line}; use crate::{ ansi, frame::{KIND_SCREEN, MAX_FRAME}, protocol::{ScreenView, encode_event}, + testutil::parse_term, }; // Alternate complete SGR states so every cell emits all style and @@ -1204,10 +1195,7 @@ fn worst_case_screen_frame_fits_max_frame() { } } - let mut term = - alacritty_terminal::Term::new(Config::default(), &TermSize::new(cols, rows), VoidListener); - let mut parser: Processor = Processor::new(); - parser.advance(&mut term, input.as_bytes()); + let term = parse_term(input.as_bytes(), rows, cols); // Confirm the grid contains the features used by the density bound. let probe = &term.grid()[Line(0)][Column(0)]; @@ -2357,15 +2345,13 @@ fn list_sessions_includes_recovery_snapshots_newest_first() { let mut s = sup_ctx(config_ctx(&config, dir.to_path_buf(), &[])); let rec = config.join("sessions").join("recovery"); - let entry = |cmd: &str| SessionEntry { - kind: EntryKind::Literal(cmd.into()), - group: None, - name: None, - }; let mut one = SessionConfig::new(); - one.insert("~/a".into(), vec![entry("vim")]); + one.insert("~/a".into(), vec![SessionEntry::literal("vim")]); let mut two = SessionConfig::new(); - two.insert("~/a".into(), vec![entry("vim"), entry("top")]); + two.insert( + "~/a".into(), + vec![SessionEntry::literal("vim"), SessionEntry::literal("top")], + ); session::save_recovery_in( &rec, "20260714-093015-11", @@ -2417,14 +2403,12 @@ fn load_recovery_materializes_the_fleet_and_notices() { dir.to_string_lossy().into_owned(), vec![ SessionEntry { - kind: EntryKind::Literal("sleep 30".into()), group: Some("api".into()), - name: None, + ..SessionEntry::literal("sleep 30") }, SessionEntry { - kind: EntryKind::Literal("sleep 31".into()), - group: None, name: Some("web".into()), + ..SessionEntry::literal("sleep 31") }, ], ); diff --git a/src/task.rs b/src/task.rs index 7a66003..80ec2f4 100644 --- a/src/task.rs +++ b/src/task.rs @@ -96,8 +96,10 @@ pub struct Task { pub group: Option, /// Custom display name; `None` means unnamed. pub name: Option, - /// Harness for a managed task's session ID and blocked-status reads. `None` for literal - /// tasks: never read a harness channel for those tasks. + /// Harness for a managed task's session ID and blocked-status reads: the harness built + /// the argv, no shell sits between, and a rerun goes through it again. `None` for + /// literal tasks: never read a harness channel for those tasks, and never edit their + /// command text. pub harness: Option<&'static dyn crate::harness::Harness>, /// Harness home resolved from this run's launch environment. pub harness_home: Option, @@ -111,9 +113,6 @@ pub struct Task { pub resume_id: Option, /// Capture path allocated for this task run. pub capture_file: Option, - /// Whether this task was launched directly with harness-built argv. If true, use the - /// harness again on rerun. Never edit literal command text. - pub managed: bool, /// Dashboard-preview resolution state; resets with the task on rerun /// because a rerun replaces the whole `Task`. preview: PreviewState, @@ -209,10 +208,9 @@ fn wait_code(status: &rustix::process::WaitIdStatus) -> i32 { } /// Child execution mode: literal shell text or managed argv. -#[derive(Debug, Clone, PartialEq, Eq)] pub enum Exec { - /// The text as typed, run as `$SHELL -c `. - Literal(String), + /// Run the spawn's `command`, the text as typed, as `$SHELL -c `. + Literal, /// Run `binary` with `args` directly. Keep the agent as task leader regardless of the /// login shell: validate Claude capture and registry records against that PID. With a /// resident shell (tcsh, csh, or a wrapper script), the shell would be the leader @@ -226,8 +224,9 @@ pub enum Exec { impl Task { /// Spawn `exec` in a fresh `rows`×`cols` PTY with `scrollback` history /// rows. Retain `command` for the UI and recipes: a literal task's typed - /// text, a managed task's program word. Pass exactly `env` to the child - /// and notify the core through `waker` on terminal output. + /// text, which is also what its shell runs, or a managed task's program + /// word. Pass exactly `env` to the child and notify the core through + /// `waker` on terminal output. #[allow(clippy::too_many_arguments)] // All arguments define task launch state. pub fn spawn( id: u64, @@ -249,9 +248,8 @@ impl Task { }) .map_err(io_err)?; - let managed = matches!(exec, Exec::Managed { .. }); let mut cmd = match exec { - Exec::Literal(text) => { + Exec::Literal => { // Read SHELL from the launch context, not the daemon's environment: a zsh // client may connect to a daemon started from bash and still require zsh // word-splitting. Without SHELL, use the portable default. @@ -262,7 +260,7 @@ impl Task { // Use a non-interactive shell. Interactive startup files, // aliases, and shell functions are not loaded. cmd.arg("-c"); - cmd.arg(text); + cmd.arg(command); cmd } Exec::Managed { binary, args } => { @@ -368,7 +366,6 @@ impl Task { run: 0, resume_id: None, capture_file: None, - managed, preview: PreviewState::new(), blocked: None, blocked_probed: None, @@ -601,15 +598,7 @@ impl Task { /// Move the scrollback viewport, clamped to retained history. pub fn scroll_view(&mut self, action: ScrollAction) { - let mut p = grid(&self.parser); - let cur = p.scrollback(); - let target = match action { - ScrollAction::Up(n) => cur.saturating_add(n as usize), - ScrollAction::Down(n) => cur.saturating_sub(n as usize), - ScrollAction::Top => usize::MAX, - ScrollAction::Live => 0, - }; - p.set_scrollback(target); + grid(&self.parser).scroll(action); } /// Rows the viewport is scrolled back from live output. diff --git a/src/task_tests.rs b/src/task_tests.rs index ccbf72d..1320ccf 100644 --- a/src/task_tests.rs +++ b/src/task_tests.rs @@ -1,21 +1,18 @@ use super::*; -use crate::testutil::{env_here, here, read_pid, sh_env, temp, wait_until, write_executable}; +use crate::testutil::{ + env_here, here, install_resident_shell, read_pid, sh_env, temp, wait_until, write_executable, +}; /// Tests drive the reader directly, so there is no core loop to wake. fn no_waker() -> Waker { Arc::new(Mutex::new(None)) } -/// The exec form of a typed command. -fn literal(command: &str) -> Exec { - Exec::Literal(command.to_string()) -} - fn spawn(id: u64, command: &str) -> Task { Task::spawn( id, command, - literal(command), + Exec::Literal, &here(), 24, 80, @@ -149,19 +146,16 @@ fn exited_leader_stays_a_zombie_until_drop() { #[test] fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { let dir = temp("task_identity"); - let shell = dir.join("resident-sh"); - // Add `:` after the child command to prevent sh from execing that command. Retain the - // shell as task leader. - write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); + let shell = install_resident_shell(&dir); let agent = dir.join("agent"); let pid_file = dir.join("pid"); write_executable( &agent, &format!("printf '%s' \"$$\" > '{}'", pid_file.display()), ); - let mut env = sh_env(); + let mut env = env_here(); env.retain(|(k, _)| k != "SHELL"); - env.push(("SHELL".into(), shell.as_os_str().to_os_string())); + env.push(("SHELL".into(), shell.into_os_string())); let managed = Exec::Managed { binary: agent.clone(), @@ -169,7 +163,6 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { }; let mut t = Task::spawn(1, "agent", managed, &here(), 24, 80, 2000, &env, no_waker()).unwrap(); wait_finished(&mut t); - assert!(t.managed); assert_eq!( read_pid(&pid_file).as_raw() as u32, t.pid().unwrap(), @@ -181,7 +174,7 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { let mut t = Task::spawn( 2, &typed, - literal(&typed), + Exec::Literal, &here(), 24, 80, @@ -191,7 +184,6 @@ fn managed_launch_makes_the_agent_the_task_leader_under_any_shell() { ) .unwrap(); wait_finished(&mut t); - assert!(!t.managed); assert_ne!( read_pid(&pid_file).as_raw() as u32, t.pid().unwrap(), @@ -212,7 +204,7 @@ fn terminate_reaches_stragglers_after_leader_exit() { let mut t = Task::spawn( 5, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, @@ -405,7 +397,7 @@ fn finalize_preview_waits_for_reader_eof() { let mut t = Task::spawn( 20, cmd, - literal(cmd), + Exec::Literal, &here(), 24, 80, @@ -454,7 +446,7 @@ fn finalize_preview_lands_an_open_sync_frame() { let mut t = Task::spawn( 21, cmd, - literal(cmd), + Exec::Literal, &here(), 24, 80, @@ -495,7 +487,7 @@ fn finalize_preview_freezes_the_final_primary_line() { let mut t = Task::spawn( 40, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, @@ -542,7 +534,7 @@ fn finalize_preview_keeps_the_last_render_across_alt_teardown() { let mut t = Task::spawn( 41, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, @@ -610,7 +602,7 @@ fn finalize_preview_freezes_primary_output_after_alt_teardown() { let mut t = Task::spawn( 43, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, @@ -659,7 +651,7 @@ fn summary_adapter_anchors_live_and_freezes_completion_at_exit() { let mut t = Task::spawn( 42, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, @@ -726,7 +718,7 @@ fn probe_replies_reach_the_child_through_the_allowlist() { let mut t = Task::spawn( 11, &cmd, - literal(&cmd), + Exec::Literal, &here(), 24, 80, diff --git a/src/terminal/ansi.rs b/src/terminal/ansi.rs index 831d269..896d220 100644 --- a/src/terminal/ansi.rs +++ b/src/terminal/ansi.rs @@ -164,6 +164,7 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { let cursor = &grid.cursor; let point = cursor.point; + let row = point.line.0.max(0) as usize; if offset == 0 && cursor.input_needs_wrap { // Pending wrap (phantom column): only a write into the last column // sets it and any CUP clears it, so it must be recreated by rewriting @@ -178,7 +179,7 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { } else { (last, &line[Column(last)]) }; - cup(&mut buf, point.line.0.max(0) as usize, base_col); + cup(&mut buf, row, base_col); sync_sgr(&mut buf, &mut state, base); // A '\t' goes through `put_tab` (which never sets pending wrap) and a // lone wide half would wrap; both are unrepresentable under a pending @@ -194,7 +195,7 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { // cell. push_zerowidth(&mut buf, base); } else { - cup(&mut buf, point.line.0.max(0) as usize, point.column.0); + cup(&mut buf, row, point.column.0); } // Leave the replay target in a known attribute state; the source's @@ -208,7 +209,7 @@ pub fn formatted(term: &Term) -> (Vec, (u16, u16), bool) { ( buf.into_bytes(), - (point.line.0.max(0) as u16, point.column.0 as u16), + (row as u16, point.column.0 as u16), !term.mode().contains(TermMode::SHOW_CURSOR), ) } @@ -345,15 +346,18 @@ fn sync_sgr(buf: &mut String, state: &mut Sgr, cell: &Cell) { push_color(buf, want.fg, 30); push_color(buf, want.bg, 40); if let Some(color) = want.underline { - push_underline_color(buf, color); + push_color(buf, color, 50); } buf.push('m'); *state = want; } -/// Foreground and background SGR parameters share one shape at different -/// bases (30/40): named colors at `base+i`, bright at `base+60`, and the -/// indexed (`;5;`) and RGB (`;2;`) forms introduced by `base+8` (38/48). +/// Foreground, background, and underline SGR parameters share one shape at +/// different bases (30/40/50): named colors at `base+i`, bright at `base+60`, +/// and the indexed (`;5;`) and RGB (`;2;`) forms introduced by `base+8` +/// (38/48/58). Base 50 is SGR 58 (underline color), for which the parser +/// stores only the indexed and RGB forms: a named underline color cannot +/// occur in a parsed grid, so the named arms are unreachable at that base. fn push_color(buf: &mut String, color: Color, base: u16) { let base = base as usize; match color { @@ -384,34 +388,15 @@ fn push_color(buf: &mut String, color: Color, base: u16) { } } -fn push_underline_color(buf: &mut String, color: Color) { - match color { - Color::Indexed(i) => { - let _ = write!(buf, ";58;5;{i}"); - } - Color::Spec(rgb) => { - let _ = write!(buf, ";58;2;{};{};{}", rgb.r, rgb.g, rgb.b); - } - // SGR 58 only parses indexed and RGB forms, so a named underline - // color cannot occur in a parsed grid. - Color::Named(_) => {} - } -} - /// Round-trip tests parse bytes, serialize the resulting grid, replay the /// serialization, and compare every displayed cell and the cursor. Comparison /// exclusions are documented by [`tests::assert_same_screen`]. #[cfg(test)] mod tests { - use alacritty_terminal::{ - event::VoidListener, - grid::Scroll, - term::{Config, test::TermSize}, - vte::ansi::Processor, - }; + use alacritty_terminal::{event::VoidListener, grid::Scroll}; use super::*; - use crate::testutil::{CORPUS_COLS, CORPUS_LINES}; + use crate::testutil::{CORPUS_COLS, CORPUS_LINES, parse_term}; /// Flags replay can never set: CUP-per-row emission performs no soft /// wraps, so wrap bookkeeping (soft-wrap marker and the spacer left when a @@ -419,17 +404,6 @@ mod tests { /// module docs for the copy/paste consequence. const WRAP_ARTIFACTS: Flags = Flags::WRAPLINE.union(Flags::LEADING_WIDE_CHAR_SPACER); - fn new_term(lines: usize, cols: usize) -> Term { - Term::new(Config::default(), &TermSize::new(cols, lines), VoidListener) - } - - fn parse(bytes: &[u8], lines: usize, cols: usize) -> Term { - let mut term = new_term(lines, cols); - let mut parser: Processor = Processor::new(); - parser.advance(&mut term, bytes); - term - } - /// Compare every displayed cell of `source` (offset-adjusted) against the /// replayed term's screen: character, zero-width extras, fg, bg, /// underline color, and flags. @@ -518,9 +492,11 @@ mod tests { /// it (see [`formatted`]). fn round_trip(source: &Term, case: &str) -> Term { let (bytes, pos, hidden) = formatted(source); - let mut replay = new_term(source.grid().screen_lines(), source.grid().columns()); - let mut parser: Processor = Processor::new(); - parser.advance(&mut replay, &bytes); + let replay = parse_term( + &bytes, + source.grid().screen_lines(), + source.grid().columns(), + ); assert_same_screen(source, &replay, case); @@ -566,7 +542,7 @@ mod tests { ($name:ident, $file:literal) => { #[test] fn $name() { - let source = parse( + let source = parse_term( include_bytes!(concat!("../../tests/corpus/", $file)), CORPUS_LINES, CORPUS_COLS, @@ -593,7 +569,7 @@ mod tests { /// displayed (offset) content, not the live screen. #[test] fn corpus_scrolled_viewport() { - let mut source = parse( + let mut source = parse_term( include_bytes!("../../tests/corpus/codex_resume.bin"), CORPUS_LINES, CORPUS_COLS, @@ -615,7 +591,7 @@ mod tests { fn sgr_reset_boundaries() { // Attributes active at a row's right edge must not leak into the next // row's default cells, and SGR 0 mid-row must restore defaults. - let source = parse( + let source = parse_term( b"\x1b[1;31;44mred on blue\x1b[0m plain\r\n\x1b[7mreverse to eol", 4, 20, @@ -631,7 +607,7 @@ mod tests { #[test] fn default_color_restoration() { // SGR 39/49 restore one default while the other stays set. - let source = parse(b"\x1b[31;44mA\x1b[39mB\x1b[49mC", 2, 10); + let source = parse_term(b"\x1b[31;44mA\x1b[39mB\x1b[49mC", 2, 10); let cell = &source.grid()[Line(0)][Column(1)]; assert_eq!( cell.fg, @@ -648,7 +624,7 @@ mod tests { #[test] fn wide_char_spacers_not_double_emitted() { - let source = parse("ab漢字c🙂d".as_bytes(), 2, 20); + let source = parse_term("ab漢字c🙂d".as_bytes(), 2, 20); assert!( source.grid()[Line(0)][Column(3)] .flags @@ -664,7 +640,7 @@ mod tests { // column and the write leaves the cursor in pending-wrap state. let mut bytes = b"\x1b[1;9H".to_vec(); bytes.extend("漢".as_bytes()); - let source = parse(&bytes, 3, 10); + let source = parse_term(&bytes, 3, 10); assert!( source.grid()[Line(0)][Column(9)] .flags @@ -686,7 +662,7 @@ mod tests { // land on the next row. let mut bytes = b"\x1b[1;10H".to_vec(); bytes.extend("漢".as_bytes()); - let source = parse(&bytes, 3, 10); + let source = parse_term(&bytes, 3, 10); assert!( source.grid()[Line(0)][Column(9)] .flags @@ -705,7 +681,7 @@ mod tests { fn zerowidth_combining_marks() { // Combining marks attach to narrow cells, wide cells (through the // spacer), and stack when repeated. - let source = parse( + let source = parse_term( "e\u{301}x 漢\u{20d7} a\u{300}\u{301}\u{308}".as_bytes(), 2, 20, @@ -727,7 +703,7 @@ mod tests { fn erased_with_attrs_styled_blanks() { // BCE: ED/EL fill cells with the template background. Those blanks // carry attributes and must be re-emitted, never skipped as empty. - let source = parse(b"\x1b[44m\x1b[2J\x1b[3;3Hx\x1b[45m\x1b[K", 6, 12); + let source = parse_term(b"\x1b[44m\x1b[2J\x1b[3;3Hx\x1b[45m\x1b[K", 6, 12); assert_eq!( source.grid()[Line(5)][Column(11)].bg, Color::Named(NamedColor::Blue), @@ -745,7 +721,7 @@ mod tests { fn cursor_phantom_column() { // Writing through the last column leaves pending wrap; replay must // recreate it without actually wrapping (write-then-reposition). - let source = parse(b"0123456789", 3, 10); + let source = parse_term(b"0123456789", 3, 10); assert!( source.grid().cursor.input_needs_wrap, "premise: pending wrap set" @@ -761,13 +737,13 @@ mod tests { #[test] fn cursor_position_and_visibility() { - let hidden = parse(b"hello\x1b[?25l\x1b[2;4H", 4, 10); + let hidden = parse_term(b"hello\x1b[?25l\x1b[2;4H", 4, 10); let (_, pos, hide) = formatted(&hidden); assert_eq!(pos, (1, 3)); assert!(hide); round_trip(&hidden, "cursor_hidden"); - let visible = parse(b"hello\x1b[3;2H", 4, 10); + let visible = parse_term(b"hello\x1b[3;2H", 4, 10); let (_, _, hide) = formatted(&visible); assert!(!hide); round_trip(&visible, "cursor_visible"); @@ -789,7 +765,7 @@ mod tests { for i in 100..=107 { bytes.extend(format!("\x1b[{i}mD").into_bytes()); } - let source = parse(&bytes, 3, 20); + let source = parse_term(&bytes, 3, 20); round_trip(&source, "colors_named_and_bright"); } @@ -799,13 +775,13 @@ mod tests { for i in [0u8, 7, 15, 16, 123, 231, 232, 255] { bytes.extend(format!("\x1b[38;5;{i}mx\x1b[48;5;{i}my").into_bytes()); } - let source = parse(&bytes, 2, 20); + let source = parse_term(&bytes, 2, 20); round_trip(&source, "colors_indexed_256"); } #[test] fn colors_rgb() { - let source = parse( + let source = parse_term( b"\x1b[38;2;1;2;3mA\x1b[48;2;250;128;0mB\x1b[38;2;255;255;255;48;2;0;0;0mC", 2, 10, @@ -833,7 +809,7 @@ mod tests { for (sgr, _) in cases { bytes.extend(format!("\x1b[{sgr}mA\x1b[0m").into_bytes()); } - let source = parse(&bytes, 2, 20); + let source = parse_term(&bytes, 2, 20); for (col, (sgr, flag)) in cases.iter().enumerate() { assert!( source.grid()[Line(0)][Column(col)].flags.contains(*flag), @@ -847,7 +823,7 @@ mod tests { fn attr_blink_not_modeled() { // alacritty stores no blink flag: SGR 5/6 must not perturb the round // trip, and the drop is symmetric (documented in the module docs). - let source = parse(b"\x1b[5mslow\x1b[6mfast\x1b[25m off", 2, 20); + let source = parse_term(b"\x1b[5mslow\x1b[6mfast\x1b[25m off", 2, 20); assert_eq!( source.grid()[Line(0)][Column(0)].flags, Flags::empty(), @@ -858,7 +834,7 @@ mod tests { #[test] fn underline_color_extras() { - let source = parse(b"\x1b[4;58;5;99mA\x1b[4;58;2;10;20;30mB\x1b[59mC", 2, 10); + let source = parse_term(b"\x1b[4;58;5;99mA\x1b[4;58;2;10;20;30mB\x1b[59mC", 2, 10); assert_eq!( source.grid()[Line(0)][Column(0)].underline_color(), Some(Color::Indexed(99)), @@ -871,7 +847,7 @@ mod tests { fn tab_cells_keep_erased_attrs() { // put_tab stores a literal '\t' in the cell it lands on, preserving // whatever attributes the cell already had (here: a BCE fill). - let source = parse(b"\x1b[44m\x1b[2J\x1b[Ha\tb", 3, 20); + let source = parse_term(b"\x1b[44m\x1b[2J\x1b[Ha\tb", 3, 20); assert_eq!( source.grid()[Line(0)][Column(1)].c, '\t', @@ -890,7 +866,7 @@ mod tests { // The backend translates the DEC special graphics charset at write // time, so serialized cells are already Unicode; replay needs no // charset shifts. - let source = parse(b"\x1b(0lqk\x1b(B done", 2, 12); + let source = parse_term(b"\x1b(0lqk\x1b(B done", 2, 12); assert_eq!( source.grid()[Line(0)][Column(0)].c, '┌', @@ -903,7 +879,7 @@ mod tests { fn alt_screen_visible_grid() { // The serializer reads the active grid; an alt-screen source must // reproduce the alt content on the replay's (primary) screen. - let source = parse(b"primary\x1b[?1049h\x1b[2;2Halt content\x1b[31mred", 4, 20); + let source = parse_term(b"primary\x1b[?1049h\x1b[2;2Halt content\x1b[31mred", 4, 20); assert!( source.mode().contains(TermMode::ALT_SCREEN), "premise: on alt screen" @@ -918,7 +894,7 @@ mod tests { // excluded from comparison (unrepresentable). let mut bytes = "漢".as_bytes().to_vec(); bytes.extend(b"\x1b[1;1H\x1b[1X"); - let source = parse(&bytes, 2, 10); + let source = parse_term(&bytes, 2, 10); let line = &source.grid()[Line(0)]; assert!( line[Column(1)].flags.contains(Flags::WIDE_CHAR_SPACER) @@ -934,7 +910,7 @@ mod tests { // styled blank (see module docs). let mut bytes = "漢".as_bytes().to_vec(); bytes.extend(b"\x1b[1;2H\x1b[1X"); - let source = parse(&bytes, 2, 10); + let source = parse_term(&bytes, 2, 10); let line = &source.grid()[Line(0)]; assert!( line[Column(0)].flags.contains(Flags::WIDE_CHAR) @@ -948,7 +924,7 @@ mod tests { fn contents_plain_text() { // The '\t' cell reads as a blank; 'x' lands at the tab stop (col 8); // trailing spaces trim per row; the empty last row stays a line. - let source = parse("one\r\ntwo 漢\u{301}字\r\n\tx".as_bytes(), 4, 12); + let source = parse_term("one\r\ntwo 漢\u{301}字\r\n\tx".as_bytes(), 4, 12); assert_eq!(contents(&source), "one\ntwo 漢\u{301}字\n x\n"); } @@ -958,7 +934,7 @@ mod tests { // spaces (`formatted` re-emits SGR 8 and both views must agree). The // combining mark on the hidden 'S' must not leak, and the trailing // hidden run trims away like padding. - let source = parse( + let source = parse_term( "ab\x1b[8mS\u{301}ECRET\x1b[28mcd \x1b[8mtail".as_bytes(), 2, 20, @@ -970,7 +946,7 @@ mod tests { fn hidden_wide_glyphs_keep_both_columns() { // A concealed wide glyph occupies two cells; both read as spaces so // later glyphs keep their columns. - let source = parse("\x1b[8m日\x1b[28mx".as_bytes(), 1, 10); + let source = parse_term("\x1b[8m日\x1b[28mx".as_bytes(), 1, 10); assert_eq!(contents(&source), " x"); } @@ -1133,7 +1109,7 @@ mod tests { 0x0123456789abcdef, ] { let bytes = escape_soup(seed, 4000, 24, 80); - let source = parse(&bytes, 24, 80); + let source = parse_term(&bytes, 24, 80); for row in 0..24 { let line = &source.grid()[Line(row)]; for col in 0..80 { diff --git a/src/terminal/emulator.rs b/src/terminal/emulator.rs index ab12e9f..b36431e 100644 --- a/src/terminal/emulator.rs +++ b/src/terminal/emulator.rs @@ -19,7 +19,10 @@ use alacritty_terminal::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; -use crate::{format::prefix_bytes, protocol::ClipboardKind}; +use crate::{ + format::prefix_bytes, + protocol::{ClipboardKind, ScrollAction}, +}; /// Mouse event classes requested by the child through DECSET 1000/1002/1003. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -41,11 +44,9 @@ pub enum MouseProtocolEncoding { /// Maximum decoded size of one buffered OSC 52 clipboard payload. pub(crate) const CLIPBOARD_STORE_MAX_BYTES: usize = 1024 * 1024; -// A maximum-size store expands to this base64 bound when re-encoded for -// forwarding. Reserve 64 KiB for the command envelope and keep the result -// within one frame. +// A maximum-size store leaves the daemon base64-encoded in one `Event::ClipboardCopy` frame. const _: () = assert!( - CLIPBOARD_STORE_MAX_BYTES.div_ceil(3) * 4 + 64 * 1024 <= crate::frame::MAX_FRAME as usize, + crate::frame::fits_base64(CLIPBOARD_STORE_MAX_BYTES), "CLIPBOARD_STORE_MAX_BYTES must base64-encode to under frame::MAX_FRAME" ); @@ -188,8 +189,10 @@ const MAX_ZEROWIDTH: usize = 16; /// Counting bytes lets repeated marks trigger a scan without a separate timer. const SWEEP_INTERVAL_BYTES: usize = 256 * 1024; -/// One task's parser, terminal grid, and buffered probe responses. The parser -/// and grid advance together under the same caller-held lock. +/// One task's parser, terminal grid, and buffered probe responses, plus the +/// facts observed while parsing: OSC 52 stores, alternate-screen and title +/// transitions, a grid revision counter, and the zero-width sweep budget. +/// The parser and grid advance together under the same caller-held lock. pub struct Emulator { term: Term, parser: Processor, @@ -423,16 +426,16 @@ impl Emulator { self.term.grid().display_offset() } - /// Move the viewport `rows` back from live output; the backend clamps to - /// retained history, so `usize::MAX` means the oldest stored row. - pub fn set_scrollback(&mut self, rows: usize) { - // Clamp contract: absolute target, capped at retained history. The - // grid API is relative; both offsets are bounded by the history cap, - // so the delta fits i32. - let grid = self.term.grid(); - let target = rows.min(grid.history_size()); - let delta = target as i32 - grid.display_offset() as i32; - self.term.scroll_display(Scroll::Delta(delta)); + /// Move the viewport by `action`. The backend clamps every variant to + /// retained history: a delta lands in `0..=history_size`, `Top` is the + /// oldest stored row, `Live` is the live screen (offset 0). + pub fn scroll(&mut self, action: ScrollAction) { + self.term.scroll_display(match action { + ScrollAction::Up(n) => Scroll::Delta(n as i32), + ScrollAction::Down(n) => Scroll::Delta(-(n as i32)), + ScrollAction::Top => Scroll::Top, + ScrollAction::Live => Scroll::Bottom, + }); } /// Resize the grid to `rows`×`cols`. @@ -476,7 +479,10 @@ impl Emulator { self.revision } - /// Count of alt-screen entries observed so far. + /// Count of alt-screen entries observed so far. Production consults the + /// epoch only inside this module (title stamping and `title`'s currency + /// filter); the accessor exists for tests. + #[cfg(test)] pub fn alt_epoch(&self) -> u64 { self.alt.epoch } @@ -550,7 +556,9 @@ fn live_floor_of(term: &Term) -> String { /// offset; apply the offset only when iterating for display. /// /// Unlike [`crate::ansi::contents`], preserve glyphs in concealed (SGR 8) cells -/// and orphaned wide halves: match harness output against stored grid text, +/// and orphaned wide halves, and omit `LEADING_WIDE_CHAR_SPACER` cells (the +/// end-of-row spacer left when a wide glyph spills to the next row) instead +/// of emitting their blank: match harness output against stored grid text, /// not replay-equivalent display text. fn live_row_text_of(term: &Term, row: i32) -> String { let mut text = String::new(); @@ -578,10 +586,6 @@ struct AltScreen { /// Count of alt-screen entries. Compared against /// `CapturedTitle::alt_epoch` to expire titles at app boundaries. epoch: u64, - /// Alt bit after the last observed mode event: transition detection - /// needs the previous value, and the mode register only holds the - /// current one. - last_alt: bool, /// The live floor captured at each alt-screen exit, at the mode event /// itself: successor bytes in the same read have not parsed yet, so /// this is exactly what the restore left visible. Preview finalization @@ -631,11 +635,13 @@ struct ObservedTerm<'a> { } impl ObservedTerm<'_> { - /// Update alternate-screen state after a delegated mode change by - /// comparing the backend's current and previously observed mode bits. - fn observe_alt(&mut self) { + /// Update alternate-screen state after a delegated mode change. `was` is + /// the backend's alt bit read before the delegation: transition detection + /// needs the previous value, and the mode register only holds the + /// current one. + fn observe_alt(&mut self, was: bool) { let alt = self.term.mode().contains(TermMode::ALT_SCREEN); - if alt && !self.alt.last_alt { + if alt && !was { self.alt.epoch += 1; // Promote a staged primary-screen announce into the new epoch: consume the // staged copy so it cannot be reused for another entry. @@ -646,10 +652,9 @@ impl ObservedTerm<'_> { }); } } - if !alt && self.alt.last_alt { + if !alt && was { self.alt.leave_floor = Some(live_floor_of(self.term)); } - self.alt.last_alt = alt; } /// Assign a sanitized title to the current alternate-screen epoch, or stage and @@ -747,8 +752,9 @@ impl Handler for ObservedTerm<'_> { set_tabs(a0: u16); } fn reset_state(&mut self) { + let was = self.term.mode().contains(TermMode::ALT_SCREEN); self.term.reset_state(); - self.observe_alt(); + self.observe_alt(was); // RIS clears the backend title and title stack without separate // handler events. The captured title remains epoch-gated. self.alt.raw_title = None; @@ -764,12 +770,14 @@ impl Handler for ObservedTerm<'_> { report_mode(a0: vt::Mode); } fn set_private_mode(&mut self, a0: vt::PrivateMode) { + let was = self.term.mode().contains(TermMode::ALT_SCREEN); self.term.set_private_mode(a0); - self.observe_alt(); + self.observe_alt(was); } fn unset_private_mode(&mut self, a0: vt::PrivateMode) { + let was = self.term.mode().contains(TermMode::ALT_SCREEN); self.term.unset_private_mode(a0); - self.observe_alt(); + self.observe_alt(was); } delegate! { report_private_mode(a0: vt::PrivateMode); @@ -1168,8 +1176,9 @@ mod tests { assert!(!emu.alternate_scroll(), "leaving the alt screen closes it"); } - /// The clamp contract `scroll_view` relies on: absolute target capped at - /// retained history, `usize::MAX` → oldest stored row, 0 → live. + /// The clamp contract `scroll_view` relies on: deltas are capped at + /// retained history in both directions, `Top` is the oldest stored row, + /// `Live` is offset 0. #[test] fn scrollback_clamps_to_retained_history() { let mut emu = Emulator::new(4, 10, 100); @@ -1178,17 +1187,21 @@ mod tests { } // 12 newlines on a 4-row screen, cursor starting at the top: the // first 3 move the cursor, the remaining 9 scroll rows into history. - emu.set_scrollback(usize::MAX); + emu.scroll(ScrollAction::Top); assert_eq!(emu.scrollback(), 9); assert!( emu.contents().starts_with("l0"), "the oldest stored row must be displayed" ); - emu.set_scrollback(3); + emu.scroll(ScrollAction::Down(6)); assert_eq!(emu.scrollback(), 3); - emu.set_scrollback(10_000); + emu.scroll(ScrollAction::Up(10_000)); assert_eq!(emu.scrollback(), 9, "over-scroll clamps at history"); - emu.set_scrollback(0); + emu.scroll(ScrollAction::Down(10_000)); + assert_eq!(emu.scrollback(), 0, "under-scroll clamps at live"); + emu.scroll(ScrollAction::Up(2)); + assert_eq!(emu.scrollback(), 2); + emu.scroll(ScrollAction::Live); assert_eq!(emu.scrollback(), 0); } @@ -1206,13 +1219,13 @@ mod tests { emu.process(format!("\r\nhist {i:02}").as_bytes()); } emu.process(b"\x1b[r"); - emu.set_scrollback(usize::MAX); + emu.scroll(ScrollAction::Top); assert_eq!(emu.scrollback(), 30); assert!( emu.contents().starts_with("seed 01"), "oldest region-scrolled row heads the history" ); - emu.set_scrollback(0); + emu.scroll(ScrollAction::Live); // Shrink, then keep inserting at the new geometry. Row counts shift // with reflow (shrinking parks the excess viewport rows in history), @@ -1223,7 +1236,7 @@ mod tests { emu.process(format!("\r\nmore {i:02}").as_bytes()); } emu.process(b"\x1b[r"); - emu.set_scrollback(usize::MAX); + emu.scroll(ScrollAction::Top); let after_shrink = emu.scrollback(); assert!( after_shrink >= 50, @@ -1235,15 +1248,15 @@ mod tests { ); // Growing back must not orphan anything either. - emu.set_scrollback(0); + emu.scroll(ScrollAction::Live); emu.resize(40, 120); - emu.set_scrollback(usize::MAX); + emu.scroll(ScrollAction::Top); assert!( emu.contents().contains("seed 01"), "history survives the round trip" ); let live = { - emu.set_scrollback(0); + emu.scroll(ScrollAction::Live); emu.contents() }; assert!( @@ -1749,7 +1762,7 @@ mod tests { } emu.process(b"latest"); assert_eq!(emu.live_floor(), "latest"); - emu.set_scrollback(usize::MAX); + emu.scroll(ScrollAction::Top); assert!( emu.contents().starts_with("l0"), "premise: the view shows history" diff --git a/src/terminal/golden.rs b/src/terminal/golden.rs index 85118d6..7ae9b5f 100644 --- a/src/terminal/golden.rs +++ b/src/terminal/golden.rs @@ -4,7 +4,7 @@ //! //! **Displayed state.** Verify every final plain-text row via [`ansi::contents`], //! cursor position and visibility via [`ansi::formatted`], and selected styled -//! cells for each fixture (see `tests/corpus/README.md`). +//! cells for the displayed-state fixtures (see `tests/corpus/README.md`). //! //! **Parser semantics.** Verify exact scrollback retention, bold-plus-dim intensity //! stacking, DEC charset translation, and VS16 width with targeted fixtures. @@ -13,21 +13,18 @@ use alacritty_terminal::{ Term, event::VoidListener, grid::Dimensions, - index::{Column, Line, Point}, - term::{Config, TermMode, cell::Flags, test::TermSize}, - vte::ansi::{Color, NamedColor, Processor, Rgb}, + index::{Column, Line}, + term::{TermMode, cell::Flags}, + vte::ansi::{Color, NamedColor, Rgb}, }; use crate::{ ansi, - testutil::{CORPUS_COLS as COLS, CORPUS_LINES as LINES}, + testutil::{CORPUS_COLS as COLS, CORPUS_LINES as LINES, parse_term}, }; fn alacritty(bytes: &[u8]) -> Term { - let mut term = Term::new(Config::default(), &TermSize::new(COLS, LINES), VoidListener); - let mut parser: Processor = Processor::new(); - parser.advance(&mut term, bytes); - term + parse_term(bytes, LINES, COLS) } /// Pin every visible text row, cursor position and visibility, and the active @@ -556,24 +553,20 @@ fn semantic_dec_scrollregion_charset_translation() { // Visible screen: box body translated, ASCII rows verbatim, the rest // blank. - let al_text = ansi::contents(&al); - let al_rows: Vec<&str> = al_text.split('\n').collect(); - assert_eq!(al_rows[0], "│ │"); - assert_eq!(al_rows[1], "└─────┘"); - let shared = [ - (2, "ascii after charset"), - (3, "inside region 1"), - (4, "inside region 2"), - (5, "inside region 3"), - (38, "bottom line after region reset"), - ]; - for (row, text) in shared { - assert_eq!(al_rows[row], text, "row {row}"); - } - for row in (6..38).chain([39]) { - assert_eq!(al_rows[row], "", "row {row} blank"); - } - assert_eq!(al.grid().cursor.point, Point::new(Line(39), Column(0))); + assert_screen( + "dec_scrollregion.bin", + &al, + &[ + (0, "│ │"), + (1, "└─────┘"), + (2, "ascii after charset"), + (3, "inside region 1"), + (4, "inside region 2"), + (5, "inside region 3"), + (38, "bottom line after region reset"), + ], + (39, 0), + ); } /// Assert identical styled cells, screen text, cursor state, and alternate-screen mode diff --git a/src/terminal/input_tests.rs b/src/terminal/input_tests.rs index 5f45eeb..2c17646 100644 --- a/src/terminal/input_tests.rs +++ b/src/terminal/input_tests.rs @@ -252,19 +252,6 @@ fn modifier_param_formula() { } } -/// Application-cursor mode uses SS3 only for unmodified cursor keys. -#[test] -fn app_cursor_drives_unmodified_only() { - assert_eq!( - key_bytes(true, Key::Left, mods(false, true, false)), - Some(b"\x1b[1;3D".to_vec()), - ); - assert_eq!( - key_bytes(true, Key::Up, Mods::default()), - Some(b"\x1bOA".to_vec()), - ); -} - /// The full F1–F12 table, including the terminfo gaps (no 16 between /// F5=15 and F6=17; no 22 before F11=23) and the modified forms. #[test] diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 843e8b8..e7a6658 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -6,7 +6,8 @@ pub(crate) mod ansi; #[cfg(test)] mod codex_tests; pub(crate) mod emulator; -// Differential emulator tests over recorded PTY output. +// Absolute pins over the recorded PTY corpus, plus the wrapper-vs-backend +// oracle. #[cfg(test)] mod golden; pub(crate) mod input; diff --git a/src/testutil.rs b/src/testutil.rs index a313b3a..94a9ebf 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -15,6 +15,13 @@ use std::{ time::{Duration, Instant, SystemTime}, }; +use alacritty_terminal::{ + Term, + event::VoidListener, + term::{Config, test::TermSize}, + vte::ansi::Processor, +}; + use crate::{ emulator::Emulator, task::{pid_is_dead, positive_pid}, @@ -186,6 +193,17 @@ pub(crate) fn write_executable(path: &Path, body: &str) { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); } +/// Install a resident shell at `/resident-sh` and return its path: it runs its `-c` +/// text in a child `sh` and stays the task leader, as tcsh and csh do. The trailing `:` +/// keeps it resident: `sh` execs a script's last simple command in its own place, which +/// would hand the child the leader PID. Set it as `SHELL` to verify that a managed launch +/// bypasses the shell. +pub(crate) fn install_resident_shell(dir: &Path) -> PathBuf { + let shell = dir.join("resident-sh"); + write_executable(&shell, "[ \"$1\" = -c ] || exit 2\n/bin/sh -c \"$2\"\n:"); + shell +} + /// Install a fake notifier at `path` that records its argv, one token /// per line, into `record`. pub(crate) fn install_fake_notifier(path: &Path, record: &Path) { @@ -236,6 +254,16 @@ pub(crate) fn install_codex_root(home: &Path, thread: &str) -> PathBuf { pub(crate) const CORPUS_LINES: usize = 40; pub(crate) const CORPUS_COLS: usize = 120; +/// A raw backend `Term` of `lines`×`cols` with `bytes` parsed into it: the +/// reference grid for tests that compare the wrapper or the serializer +/// against the backend without going through `Emulator`. +pub(crate) fn parse_term(bytes: &[u8], lines: usize, cols: usize) -> Term { + let mut term = Term::new(Config::default(), &TermSize::new(cols, lines), VoidListener); + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, bytes); + term +} + /// An emulator sized for corpus replay: corpus geometry plus enough /// scrollback (2000 rows) to retain every fixture's history. pub(crate) fn corpus_emulator() -> Emulator { diff --git a/src/ui.rs b/src/ui.rs index 5bb47c2..eb67079 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3,6 +3,7 @@ //! differ from the previous frame. use std::{ + borrow::Cow, io::{self, Write}, time::Duration, }; @@ -279,17 +280,16 @@ fn caret_line(prefix: &str, buf: &EditBuffer, cols: usize) -> (String, u16) { /// The `❯` prompt prefix with optional directory and group destinations. fn spawn_prefix(app: &App) -> String { let dir = (app.spawn_cwd != app.invocation_dir).then(|| path::abbreviate(&app.spawn_cwd)); - prompt_line(dir.as_deref(), app.spawn_group.as_deref(), "") + prompt_line(dir.as_deref(), app.spawn_group.as_deref()) } /// Assemble the spawn prompt from its optional `▸` destination segments. -fn prompt_line(dir: Option<&str>, group: Option<&str>, input: &str) -> String { +fn prompt_line(dir: Option<&str>, group: Option<&str>) -> String { let mut line = String::from(" ❯ "); for seg in [dir, group].into_iter().flatten() { line.push_str(seg); line.push_str(" ▸ "); } - line.push_str(input); line } @@ -680,8 +680,9 @@ fn render_controls(out: &mut impl Write, app: &App) -> io::Result<()> { /// The varying content of a bottom-panel picker; rendered within the shared skeleton in /// `render_panel`. struct Panel<'a> { - /// Header line, painted by `highlight` as the focused field. - header: String, + /// Header line, painted by `highlight` as the focused field. With `input`, + /// the prompt prefix the buffer is appended to. + header: &'a str, /// Preformatted row labels; indented and marked with `▸` in the skeleton. labels: &'a [String], /// Index of the highlighted row. @@ -692,12 +693,14 @@ struct Panel<'a> { hint: String, /// Dim placeholder shown instead of rows when `labels` is empty. empty: Option<&'a str>, - /// Cursor column on the header line; hidden for `None`. - cursor: Option, + /// Edit buffer rendered after `header` with the caret shown at its + /// position; `None` paints the header verbatim with the cursor hidden. + input: Option<&'a EditBuffer>, } -/// Bottom-panel skeleton shared by the dir, group, and session pickers, -/// anchored above the footer and sized so the selected row stays visible. +/// Bottom-panel skeleton shared by the `@`, `g`, `/`, session, and Agent +/// panels, anchored to the bottom of the screen with the hint on the footer +/// row, and sized so the selected row stays visible. fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> { let cols = app.cols as usize; let rows = app.rows; @@ -710,7 +713,14 @@ fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> { let panel_h = (body + 2) as u16; let top = rows.saturating_sub(panel_h).max(2); - highlight(out, top, &p.header, cols, app.terminal_focused)?; + let (header, cursor) = match p.input { + Some(buf) => { + let (line, cx) = caret_line(p.header, buf, cols); + (Cow::Owned(line), Some(cx)) + } + None => (Cow::Borrowed(p.header), None), + }; + highlight(out, top, &header, cols, app.terminal_focused)?; if total == 0 { if let Some(msg) = p.empty { @@ -742,7 +752,7 @@ fn render_panel(out: &mut impl Write, app: &App, p: &Panel) -> io::Result<()> { cols, )?; - match p.cursor { + match cursor { Some(cx) => queue!(out, MoveTo(cx, top), Show), None => queue!(out, Hide), } @@ -767,19 +777,17 @@ fn render_pickdir(out: &mut impl Write, app: &App) -> io::Result<()> { Some(DirKind::Into) => "enter/tab open", None => "", }; - // Add three styled columns after the text without moving the caret. - let (line, cx) = caret_line(" @ ", &app.dir_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!("{line} "), + header: " @ ", labels: &labels, sel: app.dir_sel, max_rows: 8, hint: format!("{action} · ↑↓ pick · esc"), empty: None, - cursor: Some(cx), + input: Some(&app.dir_input), }, ) } @@ -802,18 +810,17 @@ fn render_pickgroup(out: &mut impl Write, app: &App) -> io::Result<()> { None => "", } }; - let (line, cx) = caret_line(" g ", &app.group_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!("{line} "), + header: " g ", labels: &labels, sel: app.group_sel, max_rows: 8, hint: format!("{action} · ↑↓ pick · esc"), empty: None, - cursor: Some(cx), + input: Some(&app.group_input), }, ) } @@ -841,18 +848,17 @@ fn render_find(out: &mut impl Write, app: &App) -> io::Result<()> { None => String::new(), }) .collect(); - let (line, cx) = caret_line(" / ", &app.find_input, app.cols as usize); render_panel( out, app, &Panel { - header: format!("{line} "), + header: " / ", labels: &labels, sel: app.find_sel, max_rows: 8, hint: "enter jump · ↑↓ pick · esc".to_string(), empty: Some(" (no matching tasks)"), - cursor: Some(cx), + input: Some(&app.find_input), }, ) } @@ -875,19 +881,18 @@ fn agent_empty_text(missing: &[&str]) -> String { /// `agent_sel`. fn render_agent_page(out: &mut impl Write, app: &App) -> io::Result<()> { let prefix = format!("{}agent: ", spawn_prefix(app)); - let (line, cx) = caret_line(&prefix, &app.agent_input, app.cols as usize); let empty = agent_empty_text(&app.missing_agents()); render_panel( out, app, &Panel { - header: format!("{line} "), + header: &prefix, labels: &app.agent_candidates, sel: app.agent_sel, max_rows: 8, hint: "↑↓ pick · enter launch · tab command · esc".to_string(), empty: Some(&empty), - cursor: Some(cx), + input: Some(&app.agent_input), }, ) } @@ -936,13 +941,13 @@ fn render_session_picker(out: &mut impl Write, app: &App) -> io::Result<()> { out, app, &Panel { - header: header.to_string(), + header, labels, sel, max_rows: 10, hint, empty, - cursor: None, + input: None, }, ) } @@ -1254,28 +1259,15 @@ mod tests { ); } - /// Include the Agent-page hint in the spawn footer only when agents are installed. - #[test] - fn spawn_page_hint_shows_the_count_only_when_nonzero() { - assert_eq!(spawn_page_hint(0), " enter run · esc"); - assert_eq!(spawn_page_hint(3), " enter run · tab agents (3) · esc"); - } - /// Directory and group destinations appear only when present. #[test] fn spawn_prompt_decoration_shapes() { - assert_eq!(prompt_line(None, None, "cargo test"), " ❯ cargo test"); - assert_eq!( - prompt_line(Some("~/x"), None, "cargo test"), - " ❯ ~/x ▸ cargo test" - ); - assert_eq!( - prompt_line(None, Some("alpha"), "cargo test"), - " ❯ alpha ▸ cargo test" - ); + assert_eq!(prompt_line(None, None), " ❯ "); + assert_eq!(prompt_line(Some("~/x"), None), " ❯ ~/x ▸ "); + assert_eq!(prompt_line(None, Some("alpha")), " ❯ alpha ▸ "); assert_eq!( - prompt_line(Some("~/x"), Some("alpha"), "cargo test"), - " ❯ ~/x ▸ alpha ▸ cargo test" + prompt_line(Some("~/x"), Some("alpha")), + " ❯ ~/x ▸ alpha ▸ " ); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d76b17d..3e174cf 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -9,7 +9,7 @@ use std::{ io::{Read, Write}, - os::unix::{ffi::OsStrExt, net::UnixStream}, + os::unix::{ffi::OsStrExt, fs::PermissionsExt, net::UnixStream}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, time::{Duration, Instant}, @@ -90,17 +90,22 @@ pub fn hello_frame(version: u32, env: &[(&[u8], &[u8])], cwd: &str) -> Vec { /// under a predictable shell) and require the `hello_ok` ack. pub fn shake_hands(stream: &mut UnixStream, cwd: &str) { let path = std::env::var("PATH").unwrap_or_default(); - let env: Vec<(&[u8], &[u8])> = vec![ - (b"PATH".as_slice(), path.as_bytes()), - (b"SHELL".as_slice(), b"/bin/sh".as_slice()), - ]; - shake_hands_env(stream, cwd, &env); + shake_hands_env( + stream, + cwd, + &[("PATH", path.as_str()), ("SHELL", "/bin/sh")], + ); } /// Complete [`shake_hands`] with a caller-supplied hello environment. -pub fn shake_hands_env(stream: &mut UnixStream, cwd: &str, env: &[(&[u8], &[u8])]) { +pub fn shake_hands_env( + stream: &mut UnixStream, + cwd: &str, + env: &[(impl AsRef<[u8]>, impl AsRef<[u8]>)], +) { + let env: Vec<(&[u8], &[u8])> = env.iter().map(|(k, v)| (k.as_ref(), v.as_ref())).collect(); stream - .write_all(&hello_frame(PROTOCOL_VERSION, env, cwd)) + .write_all(&hello_frame(PROTOCOL_VERSION, &env, cwd)) .unwrap(); let (kind, payload) = read_frame(stream).expect("no reply to hello"); let text = String::from_utf8_lossy(&payload); @@ -110,6 +115,31 @@ pub fn shake_hands_env(stream: &mut UnixStream, cwd: &str, env: &[(&[u8], &[u8]) ); } +/// Read control frames for up to 10 s until one tagged `tag` satisfies `pred`; return +/// its text. Periodic `tasks` snapshots share the stream, so unrelated frames are +/// skipped. Sets the stream's read timeout so a stalled daemon fails the test instead +/// of hanging it. +pub fn next_frame(stream: &mut UnixStream, tag: &str, pred: impl Fn(&str) -> bool) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!(Instant::now() < deadline, "no {tag} frame arrived"); + let (kind, payload) = read_frame(stream).expect("stream closed"); + let text = String::from_utf8_lossy(&payload).into_owned(); + if kind == 1 && text.contains(&format!(r#""t":"{tag}""#)) && pred(&text) { + return text; + } + } +} + +/// Write an executable `#!/bin/sh` script at `path`. The parent must exist. +pub fn write_executable(path: &Path, body: &str) { + std::fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + /// Poll `ok` until it holds or `budget` elapses; returns the final answer. pub fn wait_until(budget: Duration, mut ok: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + budget; @@ -189,8 +219,9 @@ impl Drop for KillOnDrop { } } -/// A test daemon's runtime directory. Remove the tree on drop, except during -/// unwinding: preserve the socket, config, and snapshots to inspect after failure. +/// A test-owned temp directory: a daemon's runtime dir or a test's scratch tree. Remove +/// the tree on drop, except during unwinding: preserve the socket, config, snapshots, +/// stubs, and argv records to inspect after failure. pub struct RuntimeDir(PathBuf); impl std::ops::Deref for RuntimeDir { type Target = Path; @@ -206,6 +237,16 @@ impl Drop for RuntimeDir { } } +/// A fresh `fleetcom_it_{tag}_{pid}` directory under the system temp dir, cleared of any +/// leftover from an earlier run. Tags must be distinct within one test: a daemon's +/// runtime dir and a scratch tree sharing a tag would clear each other. +pub fn scratch(tag: &str) -> RuntimeDir { + let dir = std::env::temp_dir().join(format!("fleetcom_it_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + RuntimeDir(dir) +} + /// Start a `fleetcom --daemon` against an isolated runtime dir and connect a /// raw socket to it with no handshake, for tests that exercise the handshake /// itself. `configure` tweaks the daemon's `Command` (extra env vars) before @@ -219,13 +260,11 @@ pub fn start_daemon_raw( tag: &str, configure: impl FnOnce(&mut Command), ) -> (RuntimeDir, KillOnDrop, UnixStream) { - let dir = std::env::temp_dir().join(format!("fleetcom_it_{tag}_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let dir = scratch(tag); let mut cmd = Command::new(env!("CARGO_BIN_EXE_fleetcom")); cmd.arg("--daemon") - .env("FLEETCOM_RUNTIME_DIR", &dir) + .env("FLEETCOM_RUNTIME_DIR", &*dir) // Isolate recovery snapshots with the daemon's runtime files. .env("FLEETCOM_CONFIG_DIR", dir.join("config")) .stdin(Stdio::null()) @@ -241,7 +280,7 @@ pub fn start_daemon_raw( stream.is_some() }); let stream = stream.expect("daemon never bound its socket"); - (RuntimeDir(dir), daemon, stream) + (dir, daemon, stream) } /// `start_daemon_raw` plus the standard handshake: the connection is ready for diff --git a/tests/corpus/README.md b/tests/corpus/README.md index edfcf05..49a58f5 100644 --- a/tests/corpus/README.md +++ b/tests/corpus/README.md @@ -132,7 +132,7 @@ Verify display and parser behavior against these fixtures: - `codex_resume`, `wide_emoji`, `dec_scrollregion`, and `topregion_scroll` for parser semantics: scrollback retention, intensity stacking, charset translation, and VS16 width. -- Every raw capture listed above for comparison of the emulator's final styled +- Every raw capture in the Fixtures table for comparison of the emulator's final styled cells, text, cursor, and alternate-screen state with the terminal backend, and for a serialize-and-replay round trip. diff --git a/tests/daemon_agents.rs b/tests/daemon_agents.rs index 7c77a81..2649da3 100644 --- a/tests/daemon_agents.rs +++ b/tests/daemon_agents.rs @@ -4,30 +4,22 @@ mod common; -use std::{ - io::Write, - os::unix::{fs::PermissionsExt, net::UnixStream}, - path::PathBuf, - time::{Duration, Instant}, -}; +use std::{io::Write, os::unix::net::UnixStream, path::PathBuf, time::Duration}; use common::{ - read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, wait_until, + RuntimeDir, next_frame, scratch, shake_hands_env, spawn_agent_frame, start_daemon_raw, + stop_daemon, wait_until, write_executable, }; /// Scratch tree: the stub in `bin`, an empty `nobin`, and isolated runtime, config, and /// registry paths. struct Scratch { - root: PathBuf, + root: RuntimeDir, } impl Scratch { fn new(tag: &str) -> Self { - let root = std::env::temp_dir().join(format!( - "fleetcom_it_agents_scratch_{tag}_{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); + let root = scratch(&format!("{tag}_scratch")); for sub in ["bin", "nobin", "run", "config", "claude-home", "work"] { std::fs::create_dir_all(root.join(sub)).unwrap(); } @@ -65,54 +57,17 @@ impl Scratch { } } -impl Drop for Scratch { - fn drop(&mut self) { - if !std::thread::panicking() { - let _ = std::fs::remove_dir_all(&self.root); - } - } -} - /// Send a handshake whose `PATH` is the scratch tree's `bin` subdirectory. fn hello(stream: &mut UnixStream, s: &Scratch, bin: &str) { - let owned = s.hello_env(bin); - let env: Vec<(&[u8], &[u8])> = owned - .iter() - .map(|(k, v)| (k.as_bytes(), v.as_bytes())) - .collect(); - shake_hands_env(stream, &s.work().display().to_string(), &env); - // Time out if the daemon stalls instead of hanging the test. - stream - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); + shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env(bin)); } /// Install a `claude` stub to record argv and exit. fn install_claude_stub(s: &Scratch) { - let path = s.root.join("bin").join("claude"); - std::fs::write( - &path, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", - s.record().display() - ), - ) - .unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - -/// Read control frames for up to 10 s until a frame tagged `tag` satisfies `pred`. Skip -/// periodic `tasks` snapshots on the same stream. -fn next_frame(stream: &mut UnixStream, tag: &str, pred: impl Fn(&str) -> bool) -> String { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - assert!(Instant::now() < deadline, "no {tag} frame arrived"); - let (kind, payload) = read_frame(stream).expect("stream closed"); - let text = String::from_utf8_lossy(&payload).into_owned(); - if kind == 1 && text.contains(&format!(r#""t":"{tag}""#)) && pred(&text) { - return text; - } - } + write_executable( + &s.root.join("bin").join("claude"), + &format!("printf '%s\\n' \"$@\" > '{}'", s.record().display()), + ); } #[test] @@ -167,13 +122,7 @@ fn hello_discovers_agents_and_spawn_agent_launches_a_managed_task() { // Reconnect with a PATH without the stub. Resend an empty list to clear the previous // client's menu. drop(stream); - let sock = dir.join("default.sock"); - let mut again = None; - wait_until(Duration::from_secs(5), || { - again = UnixStream::connect(&sock).ok(); - again.is_some() - }); - let mut again = again.expect("reconnect failed"); + let mut again = UnixStream::connect(dir.join("default.sock")).expect("reconnect failed"); hello(&mut again, &s, "nobin"); assert_eq!( next_frame(&mut again, "agents", |_| true), diff --git a/tests/daemon_recovery.rs b/tests/daemon_recovery.rs index 909e8da..afae098 100644 --- a/tests/daemon_recovery.rs +++ b/tests/daemon_recovery.rs @@ -6,10 +6,10 @@ mod common; use std::{ os::unix::net::UnixStream, path::{Path, PathBuf}, - time::{Duration, Instant}, + time::Duration, }; -use common::{read_frame, shake_hands, spawn_task, start_daemon, stop_daemon, wait_until}; +use common::{next_frame, shake_hands, spawn_task, start_daemon, stop_daemon, wait_until}; /// Recovery-snapshot paths under the test daemon's config root. fn snapshots(dir: &Path) -> Vec { @@ -50,18 +50,7 @@ fn detached_daemon_writes_the_pending_snapshot() { // The daemon remains available and retains the task after the write. let mut stream = UnixStream::connect(dir.join("default.sock")).expect("reconnect failed"); shake_hands(&mut stream, &dir.display().to_string()); - stream - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - loop { - assert!(Instant::now() < deadline, "no tasks event after reconnect"); - let (kind, payload) = read_frame(&mut stream).expect("stream closed after reconnect"); - let text = String::from_utf8_lossy(&payload); - if kind == 1 && text.contains(r#""t":"tasks""#) && text.contains("sleep 30") { - break; - } - } + next_frame(&mut stream, "tasks", |t| t.contains("sleep 30")); stop_daemon(&mut daemon); } diff --git a/tests/daemon_resume.rs b/tests/daemon_resume.rs index 831b3b0..372ef1e 100644 --- a/tests/daemon_resume.rs +++ b/tests/daemon_resume.rs @@ -6,15 +6,15 @@ mod common; use std::{ io::Write, - os::unix::{fs::PermissionsExt, net::UnixStream}, + os::unix::net::UnixStream, path::{Path, PathBuf}, process::{Command, Stdio}, time::Duration, }; use common::{ - control_frame, read_frame, shake_hands_env, spawn_agent_frame, start_daemon_raw, stop_daemon, - wait_until, + RuntimeDir, control_frame, read_frame, scratch, shake_hands_env, spawn_agent_frame, + start_daemon_raw, stop_daemon, wait_until, write_executable, }; /// Delimiter separating argv records in a stub's append-only output. @@ -43,18 +43,14 @@ const CODEX_EMBEDDED: &str = "features.daemon_auto_start=false"; /// Scratch tree containing every executable, store, working directory, and argv /// record used by one test. struct Scratch { - root: PathBuf, + root: RuntimeDir, } impl Scratch { fn new(tag: &str) -> Self { // Keep the scratch tree separate from start_daemon_raw's directory, // which is cleared during daemon setup. - let root = std::env::temp_dir().join(format!( - "fleetcom_it_resume_scratch_{tag}_{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); + let root = scratch(&format!("{tag}_scratch")); for sub in ["bin", "run", "config", "claude-home", "codex-home", "work"] { std::fs::create_dir_all(root.join(sub)).unwrap(); } @@ -119,23 +115,9 @@ impl Scratch { } } -impl Drop for Scratch { - fn drop(&mut self) { - // Preserve stubs, stores, and argv records for inspection after failure. - if !std::thread::panicking() { - let _ = std::fs::remove_dir_all(&self.root); - } - } -} - /// Send a handshake scoped to the scratch tree. fn hello(stream: &mut UnixStream, s: &Scratch) { - let owned = s.hello_env(); - let env: Vec<(&[u8], &[u8])> = owned - .iter() - .map(|(k, v)| (k.as_bytes(), v.as_bytes())) - .collect(); - shake_hands_env(stream, &s.work().display().to_string(), &env); + shake_hands_env(stream, &s.work().display().to_string(), &s.hello_env()); } /// Drain daemon events while a test polls files. Without this reader, snapshot @@ -146,13 +128,6 @@ fn drain_events(stream: &UnixStream) { std::thread::spawn(move || while read_frame(&mut rx).is_ok() {}); } -/// Install an executable stub named `name` under the scratch bin dir. -fn install_stub(s: &Scratch, name: &str, body: &str) { - let path = s.bin().join(name); - std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); -} - /// Install a `claude` stub to record argv, write a `SessionStart` payload, /// and print a resumable exit hint. Prefix the payload with the same PID /// stamp as the real hook: after an exec from the shell, the stub's `$$` @@ -173,7 +148,7 @@ printf 'Resume this session with:\nclaude --resume %s\n' "$id""#, marker = RUN_MARKER, rec = s.record("claude").display(), ); - install_stub(s, "claude", &body); + write_executable(&s.bin().join("claude"), &body); } /// Install a `codex` stub to record argv and report only through the injected notifier, as @@ -207,7 +182,7 @@ fi"#, title = turn_complete(CODEX_TITLE), notified = s.notified().display(), ); - install_stub(s, "codex", &body); + write_executable(&s.bin().join("codex"), &body); } /// Parse a stub record into one argv vector per run. diff --git a/tests/daemon_session.rs b/tests/daemon_session.rs index dd0ce35..ae175f6 100644 --- a/tests/daemon_session.rs +++ b/tests/daemon_session.rs @@ -4,26 +4,22 @@ mod common; -use std::{ - io::Write, - time::{Duration, Instant}, -}; +use std::{io::Write, time::Duration}; use common::{ - control_frame, read_frame, shake_hands_env, start_daemon, start_daemon_raw, stop_daemon, - wait_until, + control_frame, next_frame, scratch, shake_hands_env, start_daemon, start_daemon_raw, + stop_daemon, wait_until, }; #[test] fn load_session_resolves_relative_dirs_against_the_client_cwd() { - let config = std::env::temp_dir().join(format!("fleetcom_it_sess_cfg_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&config); + let config = scratch("sess_cfg"); std::fs::create_dir_all(config.join("sessions")).unwrap(); // start_daemon's hello carries `dir` as the client cwd; the daemon process // itself runs in this test's cwd (the repo root), which has no `sub`. let (dir, mut daemon, mut stream) = start_daemon("sess", |cmd| { - cmd.env("FLEETCOM_CONFIG_DIR", &config); + cmd.env("FLEETCOM_CONFIG_DIR", &*config); }); std::fs::create_dir_all(dir.join("sub")).unwrap(); let out = dir.join("sub").join("out"); @@ -43,7 +39,6 @@ fn load_session_resolves_relative_dirs_against_the_client_cwd() { ); stop_daemon(&mut daemon); - let _ = std::fs::remove_dir_all(&config); } /// The session root follows the *hello's* env, not the daemon's: with the @@ -52,17 +47,15 @@ fn load_session_resolves_relative_dirs_against_the_client_cwd() { /// recipe only the daemon's env can see must never surface. #[test] fn session_commands_follow_the_hello_config_dir() { - let pid = std::process::id(); - let daemon_cfg = std::env::temp_dir().join(format!("fleetcom_it_sess_dcfg_{pid}")); - let client_cfg = std::env::temp_dir().join(format!("fleetcom_it_sess_ccfg_{pid}")); + let daemon_cfg = scratch("sess_dcfg"); + let client_cfg = scratch("sess_ccfg"); for d in [&daemon_cfg, &client_cfg] { - let _ = std::fs::remove_dir_all(d); std::fs::create_dir_all(d.join("sessions")).unwrap(); } std::fs::write(daemon_cfg.join("sessions").join("daemononly.json"), "{}").unwrap(); let (dir, mut daemon, mut stream) = start_daemon_raw("sesscfg", |cmd| { - cmd.env("FLEETCOM_CONFIG_DIR", &daemon_cfg); + cmd.env("FLEETCOM_CONFIG_DIR", &*daemon_cfg); }); // Hand-rolled hello whose env carries the client-side config override. let cwd = dir.display().to_string(); @@ -87,20 +80,7 @@ fn session_commands_follow_the_hello_config_dir() { ); stream.write_all(&control_frame(r#"{"t":"list"}"#)).unwrap(); - // The reply shares the stream with periodic Tasks snapshots; skip to it. - stream - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(10); - let sessions = loop { - assert!(Instant::now() < deadline, "no sessions event arrived"); - let (kind, payload) = - read_frame(&mut stream).expect("stream closed before the sessions event"); - let text = String::from_utf8_lossy(&payload).into_owned(); - if kind == 1 && text.contains(r#""t":"sessions""#) { - break text; - } - }; + let sessions = next_frame(&mut stream, "sessions", |_| true); assert!( sessions.contains(r#""where""#), "list must see the hello dir's recipe: {sessions}" @@ -111,6 +91,4 @@ fn session_commands_follow_the_hello_config_dir() { ); stop_daemon(&mut daemon); - let _ = std::fs::remove_dir_all(&daemon_cfg); - let _ = std::fs::remove_dir_all(&client_cfg); }