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