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