Skip to main content

starweaver_cli/
lib.rs

1#![allow(clippy::missing_errors_doc)]
2//! CLI-first local product surface for Starweaver.
3
4mod args;
5mod client_state;
6mod clipboard;
7mod config;
8mod environment;
9mod error;
10pub mod launcher;
11mod local_store;
12mod oauth;
13mod profiles;
14mod prompt_input;
15mod rpc;
16mod runner;
17pub(crate) mod runtime_coordinator;
18pub(crate) mod service;
19mod slash_commands;
20mod tui;
21mod update_check;
22
23use std::env;
24
25pub use args::{Cli, CliCommand, OutputMode, RpcCommand, RpcTransport, SessionCommand};
26pub use config::{CliConfig, ConfigResolver};
27pub use error::{CliError, CliResult};
28pub use local_store::{
29    DisplayReplayWindow, LocalSessionStore, LocalStore, LocalStreamArchive, TrimReport,
30};
31pub use service::CliService;
32pub use slash_commands::SlashCommandDefinition;
33
34/// Run the CLI from process arguments.
35pub fn run_from_env() -> CliResult<()> {
36    run(env::args())
37}
38
39/// Run the CLI from an argument iterator.
40pub fn run(args: impl IntoIterator<Item = String>) -> CliResult<()> {
41    let cli = match args::parse(args) {
42        Ok(cli) => cli,
43        Err(CliError::Display(output)) => {
44            print!("{output}");
45            return Ok(());
46        }
47        Err(error) => return Err(error),
48    };
49    let config = ConfigResolver::default().resolve(&cli)?;
50    if let Some(CliCommand::Rpc(command)) = &cli.command {
51        return rpc::run(&config, command);
52    }
53    let output = command_output_from_parts(cli, config)?;
54    print!("{output}");
55    Ok(())
56}
57
58/// Run the JSON-RPC host service from a standalone RPC process.
59pub fn run_rpc_server(command: &RpcCommand, store: Option<String>) -> CliResult<()> {
60    let cli = rpc_cli(store, command.clone());
61    let config = ConfigResolver::default().resolve(&cli)?;
62    rpc::run(&config, command)
63}
64
65/// Return command output for tests and host integrations.
66pub fn command_output(args: impl IntoIterator<Item = String>) -> CliResult<String> {
67    let cli = match args::parse(args) {
68        Ok(cli) => cli,
69        Err(CliError::Display(output)) => return Ok(output),
70        Err(error) => return Err(error),
71    };
72    let config = ConfigResolver::default().resolve(&cli)?;
73    command_output_from_parts(cli, config)
74}
75
76const fn rpc_cli(store: Option<String>, command: RpcCommand) -> Cli {
77    Cli {
78        prompt: None,
79        session: None,
80        continue_session: false,
81        new_session: false,
82        run: None,
83        branch_from: None,
84        profile: None,
85        worker: None,
86        worker_label: None,
87        worktree: None,
88        worktree_name: None,
89        branch: None,
90        output: None,
91        hitl: None,
92        store,
93        command: Some(CliCommand::Rpc(command)),
94    }
95}
96
97fn command_output_from_parts(cli: Cli, config: CliConfig) -> CliResult<String> {
98    let show_update_hint = should_show_update_hint(&cli, &config);
99    if show_update_hint {
100        update_check::spawn_update_check_if_due(&config);
101    }
102    let hint = show_update_hint.then(|| update_check::update_hint(&config));
103    let service = CliService::open(config)?;
104    let mut output = service.execute(cli)?;
105    if let Some(Some(hint)) = hint {
106        output.push_str(&hint);
107    }
108    Ok(output)
109}
110
111const fn should_show_update_hint(cli: &Cli, config: &CliConfig) -> bool {
112    matches!(config.default_output, OutputMode::Text | OutputMode::Silent)
113        && matches!(
114            &cli.command,
115            None | Some(
116                CliCommand::Version
117                    | CliCommand::Diagnostics
118                    | CliCommand::ReplayCheck
119                    | CliCommand::Run(_),
120            )
121        )
122}
123
124#[cfg(test)]
125mod tests {
126    #![allow(clippy::unwrap_used)]
127
128    use std::{ffi::OsString, io, path::Path};
129
130    use super::*;
131
132    fn output(root: &Path, raw_args: &[&str]) -> CliResult<String> {
133        let mut command_args = vec!["starweaver-cli".to_string()];
134        command_args.extend(raw_args.iter().map(|arg| (*arg).to_string()));
135        let cli = args::parse(command_args)?;
136        let config = ConfigResolver::for_tests(root).resolve(&cli)?;
137        CliService::open(config)?.execute(cli)
138    }
139
140    fn write_trim_test_config(root: &Path, auto_after_run: bool) {
141        let global = root.join("global");
142        std::fs::create_dir_all(&global).unwrap();
143        std::fs::write(
144            global.join("config.toml"),
145            format!(
146                r#"
147[general]
148model = "local_echo"
149
150[trim]
151auto_after_run = {auto_after_run}
152current_session_keep_recent_runs = 1
153all_sessions_keep_recent_runs = 1
154all_sessions_keep_days = 1
155all_sessions_interval_hours = 1
156"#
157            ),
158        )
159        .unwrap();
160    }
161
162    fn first_session_from_list_json(output: &str) -> serde_json::Value {
163        serde_json::from_str::<serde_json::Value>(output).unwrap()["sessions"][0].clone()
164    }
165
166    fn session_run_count(root: &Path, session_id: &str) -> usize {
167        let show = output(root, &["session", "show", session_id, "--output", "json"]).unwrap();
168        serde_json::from_str::<serde_json::Value>(&show).unwrap()["runs"]
169            .as_array()
170            .unwrap()
171            .len()
172    }
173
174    fn stored_run_dir_count(config: &CliConfig, session_id: &str) -> usize {
175        config
176            .file_store_path
177            .join("sessions")
178            .join(session_id)
179            .join("runs")
180            .read_dir()
181            .unwrap()
182            .filter(|entry| entry.as_ref().unwrap().file_type().unwrap().is_dir())
183            .count()
184    }
185
186    fn create_current_retention_session(root: &Path) -> String {
187        write_trim_test_config(root, true);
188        output(root, &["-p", "one", "--output", "silent"]).unwrap();
189        output(root, &["-p", "two", "--continue", "--output", "silent"]).unwrap();
190        output(root, &["-p", "three", "--continue", "--output", "silent"]).unwrap();
191        let session = first_session_from_list_json(
192            &output(root, &["session", "list", "--output", "json"]).unwrap(),
193        )["session_id"]
194            .as_str()
195            .unwrap()
196            .to_string();
197        assert_eq!(session_run_count(root, &session), 1);
198        session
199    }
200
201    fn create_archive_retention_session(root: &Path) -> String {
202        write_trim_test_config(root, false);
203        output(
204            root,
205            &["-p", "archive-one", "--new-session", "--output", "silent"],
206        )
207        .unwrap();
208        let session = first_session_from_list_json(
209            &output(root, &["session", "list", "--output", "json"]).unwrap(),
210        )["session_id"]
211            .as_str()
212            .unwrap()
213            .to_string();
214        output(
215            root,
216            &[
217                "-p",
218                "archive-two",
219                "--session",
220                &session,
221                "--output",
222                "silent",
223            ],
224        )
225        .unwrap();
226        output(
227            root,
228            &[
229                "-p",
230                "archive-three",
231                "--session",
232                &session,
233                "--output",
234                "silent",
235            ],
236        )
237        .unwrap();
238        session
239    }
240
241    fn mark_session_as_retention_eligible(config: &CliConfig, session_id: &str) {
242        let old = chrono::DateTime::parse_from_rfc3339("2000-01-01T00:00:00+00:00")
243            .unwrap()
244            .to_rfc3339();
245        let conn = rusqlite::Connection::open(&config.database_path).unwrap();
246        conn.execute(
247            "UPDATE runs SET updated_at = ?1 WHERE session_id = ?2",
248            rusqlite::params![old, session_id],
249        )
250        .unwrap();
251        conn.execute(
252            "UPDATE sessions SET updated_at = ?1 WHERE session_id = ?2",
253            rusqlite::params![old, session_id],
254        )
255        .unwrap();
256        drop(conn);
257
258        let state_path = config.project_dir.join("state.json");
259        let mut state = serde_json::from_str::<serde_json::Value>(
260            &std::fs::read_to_string(&state_path).unwrap(),
261        )
262        .unwrap();
263        state["last_retention_maintenance_at"] = serde_json::json!(old);
264        std::fs::write(&state_path, serde_json::to_vec_pretty(&state).unwrap()).unwrap();
265    }
266
267    #[test]
268    #[allow(clippy::too_many_lines)]
269    fn args_and_error_helpers_cover_edge_branches() {
270        let run = args::RunCommand {
271            prompt: Some(" explicit ".to_string()),
272            prompt_parts: vec!["ignored".to_string()],
273            continue_session: false,
274            session: None,
275            new_session: false,
276            run: None,
277            branch_from: None,
278            profile: None,
279            output: None,
280            hitl: None,
281            goal: None,
282            worker: None,
283            worker_label: None,
284            worktree: None,
285            worktree_name: None,
286            branch: None,
287            session_affinity_id: None,
288            environment_attachments: Vec::new(),
289        };
290        assert_eq!(run.prompt_text().unwrap(), " explicit ");
291
292        let joined = args::RunCommand {
293            prompt: None,
294            prompt_parts: vec!["hello".to_string(), "world".to_string()],
295            continue_session: false,
296            session: None,
297            new_session: false,
298            run: None,
299            branch_from: None,
300            profile: None,
301            output: None,
302            hitl: None,
303            goal: None,
304            worker: None,
305            worker_label: None,
306            worktree: None,
307            worktree_name: None,
308            branch: None,
309            session_affinity_id: None,
310            environment_attachments: Vec::new(),
311        };
312        assert_eq!(joined.prompt_text().unwrap(), "hello world");
313
314        let empty = args::RunCommand {
315            prompt: Some("   ".to_string()),
316            prompt_parts: Vec::new(),
317            continue_session: false,
318            session: None,
319            new_session: false,
320            run: None,
321            branch_from: None,
322            profile: None,
323            output: None,
324            hitl: None,
325            goal: None,
326            worker: None,
327            worker_label: None,
328            worktree: None,
329            worktree_name: None,
330            branch: None,
331            session_affinity_id: None,
332            environment_attachments: Vec::new(),
333        };
334        assert!(
335            matches!(empty.prompt_text(), Err(CliError::Usage(message)) if message.contains("run -p"))
336        );
337
338        let parsed = args::parse_os([
339            OsString::from("starweaver-cli"),
340            OsString::from("run"),
341            OsString::from("hello"),
342        ])
343        .unwrap();
344        assert!(matches!(parsed.command, Some(args::CliCommand::Run(_))));
345
346        let parsed = args::parse_os([
347            OsString::from("starweaver-cli"),
348            OsString::from("-p"),
349            OsString::from("hello"),
350            OsString::from("-s"),
351            OsString::from("session_test"),
352            OsString::from("--profile"),
353            OsString::from("coding"),
354            OsString::from("--worker"),
355            OsString::from("off"),
356            OsString::from("--worktree"),
357            OsString::from("feature"),
358            OsString::from("--branch"),
359            OsString::from("feature/work"),
360        ])
361        .unwrap();
362        assert_eq!(parsed.session.as_deref(), Some("session_test"));
363        assert_eq!(parsed.profile.as_deref(), Some("coding"));
364        assert_eq!(parsed.worker.as_deref(), Some("off"));
365        assert_eq!(parsed.worktree.as_deref(), Some("feature"));
366        assert_eq!(parsed.branch.as_deref(), Some("feature/work"));
367
368        let parsed = args::parse_os([
369            OsString::from("starweaver-cli"),
370            OsString::from("-p"),
371            OsString::from("hello"),
372            OsString::from("--worker"),
373            OsString::from("-w"),
374            OsString::from("--worker-label"),
375            OsString::from("executor"),
376            OsString::from("--worktree-name"),
377            OsString::from("feature"),
378        ])
379        .unwrap();
380        assert_eq!(parsed.worker.as_deref(), Some("true"));
381        assert_eq!(parsed.worker_label.as_deref(), Some("executor"));
382        assert_eq!(parsed.worktree.as_deref(), Some("true"));
383        assert_eq!(parsed.worktree_name.as_deref(), Some("feature"));
384
385        let parse_error =
386            args::parse_os([OsString::from("starweaver-cli"), OsString::from("--bad")]);
387        assert!(
388            matches!(parse_error, Err(CliError::Usage(message)) if message.contains("unexpected argument"))
389        );
390
391        assert!(format!(
392            "{}",
393            CliError::from(serde_json::from_str::<serde_json::Value>("{").unwrap_err())
394        )
395        .contains("serialization error"));
396        assert!(format!(
397            "{}",
398            CliError::from(toml::from_str::<toml::Value>("=").unwrap_err())
399        )
400        .contains("configuration error"));
401        assert!(format!(
402            "{}",
403            CliError::from(toml::to_string(&f64::NAN).unwrap_err())
404        )
405        .contains("configuration error"));
406        let io_error = error::io_error(
407            "/tmp/missing",
408            io::Error::new(io::ErrorKind::NotFound, "gone"),
409        );
410        assert!(format!("{io_error}").contains("filesystem error at /tmp/missing"));
411    }
412
413    #[test]
414    fn version_and_diagnostics_work() {
415        let temp = tempfile::tempdir().unwrap();
416        assert_eq!(
417            output(temp.path(), &["version"]).unwrap(),
418            "starweaver-agent-sdk\n"
419        );
420        let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
421        assert!(diagnostics.contains("sdk=starweaver-agent-sdk"));
422        assert!(diagnostics.contains("database_path="));
423        assert!(diagnostics.contains("model_profiles="));
424        assert!(diagnostics.contains("wal=true"));
425    }
426
427    #[test]
428    fn config_model_profiles_work() {
429        let temp = tempfile::tempdir().unwrap();
430        let global = temp.path().join("global");
431        std::fs::create_dir_all(&global).unwrap();
432        std::fs::write(
433            global.join("config.toml"),
434            r#"
435[general]
436model = "homelab@openai-responses:gpt-5.5"
437model_settings = "openai_responses_high"
438model_cfg = "gpt5_270k"
439
440[model_profiles.codex-subs]
441label = "Codex Subs"
442model = "oauth@codex:gpt-5.5"
443model_settings = "openai_responses_high"
444model_cfg = "gpt5_270k"
445
446[providers.homelab]
447base_url = "https://gateway.example/v1"
448max_tokens_parameter = "omit"
449
450[oauth_refresh]
451enabled = true
452interval_seconds = 42
453failure_retry_seconds = 7
454refresh_on_startup = false
455
456[env]
457HOMELAB_API_KEY = "test-key"
458"#,
459        )
460        .unwrap();
461        let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
462        assert!(diagnostics.contains("profile=default_model"));
463        assert!(diagnostics.contains("model_profiles=1"));
464        assert_eq!(
465            output(
466                temp.path(),
467                &["config", "get", "providers.homelab.max_tokens_parameter"]
468            )
469            .unwrap(),
470            "omit\n"
471        );
472        assert_eq!(
473            output(
474                temp.path(),
475                &["config", "get", "oauth_refresh.interval_seconds"]
476            )
477            .unwrap(),
478            "42\n"
479        );
480        assert_eq!(
481            output(
482                temp.path(),
483                &["config", "get", "oauth_refresh.refresh_on_startup"]
484            )
485            .unwrap(),
486            "false\n"
487        );
488        let profiles = output(temp.path(), &["profile", "list"]).unwrap();
489        assert!(profiles.contains("default_model"));
490        assert!(profiles.contains("codex-subs"));
491        let default_profile = output(temp.path(), &["profile", "show", "default_model"]).unwrap();
492        assert!(default_profile.contains("model_id: homelab@openai-responses:gpt-5.5"));
493        assert!(default_profile.contains("settings_preset: openai_responses_high"));
494        assert!(default_profile.contains("config_preset: gpt5_270k"));
495        assert!(default_profile.contains("# source: config"));
496    }
497
498    #[test]
499    fn configured_slash_commands_layer_aliases_and_redact_unmapped_metadata() {
500        let temp = tempfile::tempdir().unwrap();
501        let global = temp.path().join("global");
502        let project = temp.path().join("project/.starweaver");
503        std::fs::create_dir_all(&global).unwrap();
504        std::fs::create_dir_all(&project).unwrap();
505        std::fs::write(
506            global.join("config.toml"),
507            r#"
508[commands.review]
509description = "Global review"
510aliases = ["rv", "bad alias", "model"]
511prompt = "global secret prompt"
512
513[commands.other]
514aliases = ["review"]
515prompt = "Other command"
516"#,
517        )
518        .unwrap();
519        std::fs::write(
520            project.join("config.toml"),
521            r#"
522[commands.review]
523description = "Project review"
524aliases = ["pr"]
525prompt = "Project review prompt"
526
527[commands.bad_name]
528prompt = "ignored because underscore is valid"
529
530[commands."bad name"]
531prompt = "ignored invalid name"
532"#,
533        )
534        .unwrap();
535
536        let cli = args::parse(["starweaver-cli".to_string(), "diagnostics".to_string()]).unwrap();
537        let config = ConfigResolver::for_tests(temp.path())
538            .resolve(&cli)
539            .unwrap();
540        let review = config.slash_commands.get("review").unwrap();
541        assert_eq!(review.prompt, "Project review prompt");
542        assert_eq!(review.aliases, vec!["pr".to_string()]);
543        assert!(config.slash_commands.contains_key("pr"));
544        assert!(!config.slash_commands.contains_key("rv"));
545        assert!(!config.slash_commands.contains_key("bad alias"));
546        assert!(!config.slash_commands.contains_key("model"));
547        assert!(config.slash_commands.contains_key("bad_name"));
548        assert!(!config.slash_commands.contains_key("bad name"));
549        let unmapped = output(temp.path(), &["config", "get", "metadata.unmapped"]).unwrap();
550        assert!(!unmapped.contains("global secret prompt"));
551        assert!(!unmapped.contains("Project review prompt"));
552        assert!(!unmapped.contains("commands"));
553    }
554
555    #[test]
556    fn configured_subagent_inherits_profile_model() {
557        let temp = tempfile::tempdir().unwrap();
558        let global = temp.path().join("global");
559        let project = temp.path().join("project/.starweaver");
560        std::fs::create_dir_all(global.join("subagents")).unwrap();
561        std::fs::write(
562            global.join("config.toml"),
563            r#"
564[general]
565model = "local_echo"
566
567[subagents]
568dirs = ["subagents"]
569"#,
570        )
571        .unwrap();
572        std::fs::write(
573            global.join("subagents/helper.md"),
574            r"---
575name: helper
576description: Helper subagent
577model: inherit
578---
579You are a helper.
580",
581        )
582        .unwrap();
583
584        let cli = args::parse([
585            "starweaver-cli".to_string(),
586            "-p".to_string(),
587            "hello".to_string(),
588            "--profile".to_string(),
589            "default_model".to_string(),
590        ])
591        .unwrap();
592        let config = ConfigResolver::for_tests(temp.path())
593            .resolve(&cli)
594            .unwrap();
595        assert_eq!(config.project_dir, project);
596        let profile = crate::profiles::resolve_profile(&config, Some("default_model")).unwrap();
597        let agent = profile.build_agent().unwrap();
598        let tools = agent.tools().names();
599        assert!(tools.contains(&"delegate".to_string()));
600        assert!(tools.contains(&"subagent_info".to_string()));
601
602        let run = output(
603            temp.path(),
604            &[
605                "-p",
606                "hello",
607                "--profile",
608                "default_model",
609                "--output",
610                "silent",
611            ],
612        )
613        .unwrap();
614        assert!(run.contains("status=completed"));
615    }
616
617    #[test]
618    fn headless_run_expands_configured_slash_commands() {
619        let temp = tempfile::tempdir().unwrap();
620        let global = temp.path().join("global");
621        std::fs::create_dir_all(&global).unwrap();
622        std::fs::write(
623            global.join("config.toml"),
624            r#"
625[general]
626model = "local_echo"
627
628[commands.review]
629description = "Review the current changes"
630aliases = ["rv"]
631prompt = "Review carefully."
632"#,
633        )
634        .unwrap();
635
636        let run = output(
637            temp.path(),
638            &[
639                "-p",
640                "/rv staged diff",
641                "--profile",
642                "default_model",
643                "--output",
644                "text",
645            ],
646        )
647        .unwrap();
648        assert!(run.contains("local echo: Review carefully."));
649        assert!(run.contains("User instruction: staged diff"));
650
651        let sessions = output(temp.path(), &["session", "list"]).unwrap();
652        let session: serde_json::Value =
653            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
654        let session_id = session["session_id"].as_str().unwrap();
655        let cli = args::parse([
656            "starweaver-cli".to_string(),
657            "session".to_string(),
658            "list".to_string(),
659        ])
660        .unwrap();
661        let config = ConfigResolver::for_tests(temp.path())
662            .resolve(&cli)
663            .unwrap();
664        let store = LocalStore::open(&config).unwrap();
665        let run_id = session["head_run_id"].as_str().unwrap();
666        let run_record = store.load_run(session_id, run_id).unwrap();
667        let run_value = serde_json::to_value(&run_record).unwrap();
668        assert_eq!(
669            run_value["input"][0]["text"],
670            "Review carefully.\n\nUser instruction: staged diff"
671        );
672        assert_eq!(run_value["metadata"]["cli.slash_command.name"], "review");
673        assert_eq!(run_value["metadata"]["cli.slash_command.invoked"], "rv");
674    }
675
676    #[test]
677    fn headless_run_creates_session_and_run() {
678        let temp = tempfile::tempdir().unwrap();
679        let first = output(temp.path(), &["-p", "hello", "--output", "display-jsonl"]).unwrap();
680        let first_message: serde_json::Value =
681            serde_json::from_str(first.lines().next().unwrap()).unwrap();
682        assert_eq!(first_message["schema"], "starweaver.display.v1");
683        assert_eq!(first_message["type"], "RUN_QUEUED");
684        let agui_temp = tempfile::tempdir().unwrap();
685        let agui = output(agui_temp.path(), &["-p", "hello", "--output", "agui-jsonl"]).unwrap();
686        let agui_events = agui
687            .lines()
688            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
689            .collect::<Vec<_>>();
690        assert!(agui_events
691            .iter()
692            .any(|event| event["type"] == "RUN_STARTED"));
693        assert!(agui_events
694            .iter()
695            .any(|event| event["type"] == "TEXT_MESSAGE_CHUNK"));
696        assert!(agui_events
697            .iter()
698            .any(|event| event["type"] == "RUN_FINISHED"));
699        let sessions = output(temp.path(), &["session", "list"]).unwrap();
700        assert!(sessions.contains("session_"));
701        let value: serde_json::Value =
702            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
703        assert_eq!(value["run_count"], 1);
704        assert_eq!(value["head_success_run_id"], value["head_run_id"]);
705    }
706
707    #[test]
708    fn continue_appends_run_under_existing_session() {
709        let temp = tempfile::tempdir().unwrap();
710        output(temp.path(), &["-p", "one"]).unwrap();
711        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
712        let sessions = output(temp.path(), &["session", "list"]).unwrap();
713        let value: serde_json::Value =
714            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
715        assert_eq!(value["run_count"], 2);
716        let session_id = value["session_id"].as_str().unwrap();
717        let show = output(temp.path(), &["session", "show", session_id]).unwrap();
718        assert_eq!(show.lines().count(), 3);
719    }
720
721    #[test]
722    fn display_replay_window_uses_scoped_cursors() {
723        let temp = tempfile::tempdir().unwrap();
724        output(temp.path(), &["-p", "one"]).unwrap();
725        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
726        let sessions = output(temp.path(), &["session", "list"]).unwrap();
727        let value: serde_json::Value =
728            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
729        let session_id = value["session_id"].as_str().unwrap();
730        let cli = args::parse([
731            "starweaver-cli".to_string(),
732            "session".to_string(),
733            "list".to_string(),
734        ])
735        .unwrap();
736        let config = ConfigResolver::for_tests(temp.path())
737            .resolve(&cli)
738            .unwrap();
739        let store = LocalStore::open(&config).unwrap();
740
741        let session_window = store.replay_display_window(session_id, None, None).unwrap();
742        assert_eq!(
743            session_window.scope,
744            starweaver_stream::ReplayScope::session(session_id)
745        );
746        assert_eq!(session_window.next_sequence, session_window.events.len());
747        for (sequence, event) in session_window.events.iter().enumerate() {
748            assert_eq!(
749                event.scope,
750                starweaver_stream::ReplayScope::session(session_id)
751            );
752            assert_eq!(event.sequence, sequence);
753        }
754
755        let session_cursor = starweaver_stream::ReplayCursor::new(session_window.scope.clone(), 0);
756        let session_tail = store
757            .replay_display_window(session_id, None, Some(&session_cursor))
758            .unwrap();
759        assert!(session_tail.events.iter().all(|event| event.sequence > 0));
760        assert_eq!(session_tail.next_sequence, session_window.next_sequence);
761
762        let first_run = store.list_runs(session_id, 10).unwrap().remove(0);
763        let run_window = store
764            .replay_display_window(session_id, Some(&first_run.run_id), None)
765            .unwrap();
766        assert_eq!(
767            run_window.scope,
768            starweaver_stream::ReplayScope::run(&first_run.run_id)
769        );
770        assert!(run_window.next_sequence > 0);
771        assert!(run_window
772            .events
773            .iter()
774            .all(|event| event.scope == starweaver_stream::ReplayScope::run(&first_run.run_id)));
775    }
776
777    #[test]
778    fn local_stream_archive_implements_shared_stream_contract() {
779        use starweaver_stream::StreamArchive as _;
780
781        let temp = tempfile::tempdir().unwrap();
782        output(temp.path(), &["-p", "archive"]).unwrap();
783        let sessions = output(temp.path(), &["session", "list"]).unwrap();
784        let value: serde_json::Value =
785            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
786        let session_id = value["session_id"].as_str().unwrap();
787        let cli = args::parse([
788            "starweaver-cli".to_string(),
789            "session".to_string(),
790            "list".to_string(),
791        ])
792        .unwrap();
793        let config = ConfigResolver::for_tests(temp.path())
794            .resolve(&cli)
795            .unwrap();
796        let store = LocalStore::open(&config).unwrap();
797        let run = store.list_runs(session_id, 10).unwrap().remove(0);
798        let archive = LocalStreamArchive::new(config);
799        let runtime = tokio::runtime::Runtime::new().unwrap();
800        let run_scope = starweaver_stream::ReplayScope::run(&run.run_id);
801        let session_scope = starweaver_stream::ReplayScope::session(session_id);
802
803        let run_messages = runtime
804            .block_on(archive.replay_display_after(&run_scope, None))
805            .unwrap();
806        assert!(!run_messages.is_empty());
807        let run_range = runtime.block_on(archive.cursor_range(&run_scope)).unwrap();
808        assert!(run_range.is_some());
809
810        let session_messages = runtime
811            .block_on(archive.replay_display_after(
812                &session_scope,
813                Some(starweaver_stream::ReplayCursor::new(
814                    session_scope.clone(),
815                    0,
816                )),
817            ))
818            .unwrap();
819        assert!(session_messages.len() < run_messages.len());
820        let session_range = runtime
821            .block_on(archive.cursor_range(&session_scope))
822            .unwrap()
823            .unwrap();
824        assert_eq!(session_range.0.sequence, 0);
825
826        let extra = starweaver_stream::DisplayMessage::new(
827            999,
828            starweaver_core::SessionId::from_string(session_id),
829            starweaver_core::RunId::from_string(&run.run_id),
830            starweaver_stream::DisplayMessageKind::HostEvent,
831        )
832        .with_preview("extra archive message");
833        runtime
834            .block_on(archive.append_display_messages(run_scope.clone(), vec![extra]))
835            .unwrap();
836        let appended = runtime
837            .block_on(archive.replay_display_after(
838                &run_scope,
839                Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 998)),
840            ))
841            .unwrap();
842        assert_eq!(appended.len(), 1);
843        assert_eq!(
844            appended[0].preview.as_deref(),
845            Some("extra archive message")
846        );
847
848        let snapshot = starweaver_stream::ReplaySnapshot {
849            scope: Some(run_scope.clone()),
850            revision: 7,
851            cursor: Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 999)),
852            display_messages: appended,
853            metadata: serde_json::Map::default(),
854        };
855        runtime
856            .block_on(archive.append_snapshot(run_scope.clone(), snapshot.clone()))
857            .unwrap();
858        let latest = runtime
859            .block_on(archive.latest_snapshot(&run_scope))
860            .unwrap()
861            .unwrap();
862        assert_eq!(latest.revision, snapshot.revision);
863
864        let _raw_records = runtime
865            .block_on(archive.replay_raw_after(
866                &starweaver_core::SessionId::from_string(session_id),
867                &starweaver_core::RunId::from_string(&run.run_id),
868                None,
869            ))
870            .unwrap();
871    }
872
873    #[test]
874    fn local_session_and_stream_adapters_back_agent_runtime_builder() {
875        use std::sync::Arc;
876
877        use starweaver_session::SessionStore as _;
878        use starweaver_stream::StreamArchive as _;
879
880        let temp = tempfile::tempdir().unwrap();
881        let cli = args::parse([
882            "starweaver-cli".to_string(),
883            "session".to_string(),
884            "list".to_string(),
885        ])
886        .unwrap();
887        let config = ConfigResolver::for_tests(temp.path())
888            .resolve(&cli)
889            .unwrap();
890        let session_id = starweaver_core::SessionId::from_string("session_local_runtime");
891        let session_store = Arc::new(LocalSessionStore::new(config.clone()));
892        let stream_archive = Arc::new(LocalStreamArchive::new(config));
893        let runtime = tokio::runtime::Runtime::new().unwrap();
894        let mut agent_runtime = starweaver_agent::AgentRuntimeBuilder::new(Arc::new(
895            starweaver_agent::TestModel::with_text("ok"),
896        ))
897        .durable_session_id(session_id.clone())
898        .session_store(session_store.clone())
899        .stream_archive(stream_archive.clone())
900        .build();
901
902        let result = runtime.block_on(agent_runtime.run_stream("hello")).unwrap();
903        assert_eq!(result.result.output, "ok");
904
905        let runs = runtime
906            .block_on(session_store.list_runs(&session_id))
907            .unwrap();
908        assert_eq!(runs.len(), 1);
909        assert_eq!(runs[0].status, starweaver_session::RunStatus::Completed);
910        let run_scope = starweaver_stream::ReplayScope::run(runs[0].run_id.as_str());
911        let display_messages = runtime
912            .block_on(stream_archive.replay_display_after(&run_scope, None))
913            .unwrap();
914        assert!(!display_messages.is_empty());
915        let trace = runtime
916            .block_on(session_store.compact_session_trace(&session_id))
917            .unwrap();
918        assert_eq!(trace.runs, 1);
919    }
920
921    #[test]
922    fn automatic_retention_prunes_old_runs_without_deleting_sessions() {
923        let temp = tempfile::tempdir().unwrap();
924        let current_session = create_current_retention_session(temp.path());
925        let archive_session = create_archive_retention_session(temp.path());
926        let config = ConfigResolver::for_tests(temp.path())
927            .resolve(&args::parse(["starweaver-cli".to_string()]).unwrap())
928            .unwrap();
929        mark_session_as_retention_eligible(&config, &archive_session);
930        assert_eq!(session_run_count(temp.path(), &archive_session), 3);
931        assert_eq!(stored_run_dir_count(&config, &archive_session), 3);
932
933        write_trim_test_config(temp.path(), true);
934        output(
935            temp.path(),
936            &[
937                "-p",
938                "trigger",
939                "--session",
940                &current_session,
941                "--output",
942                "silent",
943            ],
944        )
945        .unwrap();
946
947        assert_eq!(session_run_count(temp.path(), &current_session), 1);
948        assert_eq!(session_run_count(temp.path(), &archive_session), 1);
949        assert_eq!(stored_run_dir_count(&config, &archive_session), 1);
950        let sessions = output(temp.path(), &["session", "list", "--output", "json"]).unwrap();
951        let sessions = serde_json::from_str::<serde_json::Value>(&sessions).unwrap()["sessions"]
952            .as_array()
953            .unwrap()
954            .len();
955        assert_eq!(sessions, 2);
956        assert!(
957            std::fs::read_to_string(config.project_dir.join("state.json"))
958                .unwrap()
959                .contains("last_retention_maintenance_at")
960        );
961    }
962
963    #[test]
964    fn replay_and_trim_work() {
965        let temp = tempfile::tempdir().unwrap();
966        output(temp.path(), &["-p", "one"]).unwrap();
967        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
968        output(temp.path(), &["-p", "three", "--continue"]).unwrap();
969        let sessions = output(temp.path(), &["session", "list"]).unwrap();
970        let session: serde_json::Value =
971            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
972        let session_id = session["session_id"].as_str().unwrap();
973        let replay = output(temp.path(), &["session", "replay", session_id]).unwrap();
974        assert!(replay.contains("RUN_FINISHED"));
975        let dry = output(
976            temp.path(),
977            &[
978                "session",
979                "trim",
980                "--session",
981                session_id,
982                "--keep-runs",
983                "1",
984                "--dry-run",
985            ],
986        )
987        .unwrap();
988        let report: serde_json::Value = serde_json::from_str(dry.trim()).unwrap();
989        assert_eq!(report["runs_to_trim"], 2);
990        output(
991            temp.path(),
992            &[
993                "session",
994                "trim",
995                "--session",
996                session_id,
997                "--keep-runs",
998                "1",
999            ],
1000        )
1001        .unwrap();
1002        let show = output(temp.path(), &["session", "show", session_id]).unwrap();
1003        assert_eq!(show.lines().count(), 2);
1004    }
1005}