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    #[test]
141    #[allow(clippy::too_many_lines)]
142    fn args_and_error_helpers_cover_edge_branches() {
143        let run = args::RunCommand {
144            prompt: Some(" explicit ".to_string()),
145            prompt_parts: vec!["ignored".to_string()],
146            continue_session: false,
147            session: None,
148            new_session: false,
149            run: None,
150            branch_from: None,
151            profile: None,
152            output: None,
153            hitl: None,
154            goal: None,
155            worker: None,
156            worker_label: None,
157            worktree: None,
158            worktree_name: None,
159            branch: None,
160            session_affinity_id: None,
161        };
162        assert_eq!(run.prompt_text().unwrap(), " explicit ");
163
164        let joined = args::RunCommand {
165            prompt: None,
166            prompt_parts: vec!["hello".to_string(), "world".to_string()],
167            continue_session: false,
168            session: None,
169            new_session: false,
170            run: None,
171            branch_from: None,
172            profile: None,
173            output: None,
174            hitl: None,
175            goal: None,
176            worker: None,
177            worker_label: None,
178            worktree: None,
179            worktree_name: None,
180            branch: None,
181            session_affinity_id: None,
182        };
183        assert_eq!(joined.prompt_text().unwrap(), "hello world");
184
185        let empty = args::RunCommand {
186            prompt: Some("   ".to_string()),
187            prompt_parts: Vec::new(),
188            continue_session: false,
189            session: None,
190            new_session: false,
191            run: None,
192            branch_from: None,
193            profile: None,
194            output: None,
195            hitl: None,
196            goal: None,
197            worker: None,
198            worker_label: None,
199            worktree: None,
200            worktree_name: None,
201            branch: None,
202            session_affinity_id: None,
203        };
204        assert!(
205            matches!(empty.prompt_text(), Err(CliError::Usage(message)) if message.contains("run -p"))
206        );
207
208        let parsed = args::parse_os([
209            OsString::from("starweaver-cli"),
210            OsString::from("run"),
211            OsString::from("hello"),
212        ])
213        .unwrap();
214        assert!(matches!(parsed.command, Some(args::CliCommand::Run(_))));
215
216        let parsed = args::parse_os([
217            OsString::from("starweaver-cli"),
218            OsString::from("-p"),
219            OsString::from("hello"),
220            OsString::from("-s"),
221            OsString::from("session_test"),
222            OsString::from("--profile"),
223            OsString::from("coding"),
224            OsString::from("--worker"),
225            OsString::from("off"),
226            OsString::from("--worktree"),
227            OsString::from("feature"),
228            OsString::from("--branch"),
229            OsString::from("feature/work"),
230        ])
231        .unwrap();
232        assert_eq!(parsed.session.as_deref(), Some("session_test"));
233        assert_eq!(parsed.profile.as_deref(), Some("coding"));
234        assert_eq!(parsed.worker.as_deref(), Some("off"));
235        assert_eq!(parsed.worktree.as_deref(), Some("feature"));
236        assert_eq!(parsed.branch.as_deref(), Some("feature/work"));
237
238        let parsed = args::parse_os([
239            OsString::from("starweaver-cli"),
240            OsString::from("-p"),
241            OsString::from("hello"),
242            OsString::from("--worker"),
243            OsString::from("-w"),
244            OsString::from("--worker-label"),
245            OsString::from("executor"),
246            OsString::from("--worktree-name"),
247            OsString::from("feature"),
248        ])
249        .unwrap();
250        assert_eq!(parsed.worker.as_deref(), Some("true"));
251        assert_eq!(parsed.worker_label.as_deref(), Some("executor"));
252        assert_eq!(parsed.worktree.as_deref(), Some("true"));
253        assert_eq!(parsed.worktree_name.as_deref(), Some("feature"));
254
255        let parse_error =
256            args::parse_os([OsString::from("starweaver-cli"), OsString::from("--bad")]);
257        assert!(
258            matches!(parse_error, Err(CliError::Usage(message)) if message.contains("unexpected argument"))
259        );
260
261        assert!(format!(
262            "{}",
263            CliError::from(serde_json::from_str::<serde_json::Value>("{").unwrap_err())
264        )
265        .contains("serialization error"));
266        assert!(format!(
267            "{}",
268            CliError::from(toml::from_str::<toml::Value>("=").unwrap_err())
269        )
270        .contains("configuration error"));
271        assert!(format!(
272            "{}",
273            CliError::from(toml::to_string(&f64::NAN).unwrap_err())
274        )
275        .contains("configuration error"));
276        let io_error = error::io_error(
277            "/tmp/missing",
278            io::Error::new(io::ErrorKind::NotFound, "gone"),
279        );
280        assert!(format!("{io_error}").contains("filesystem error at /tmp/missing"));
281    }
282
283    #[test]
284    fn version_and_diagnostics_work() {
285        let temp = tempfile::tempdir().unwrap();
286        assert_eq!(
287            output(temp.path(), &["version"]).unwrap(),
288            "starweaver-agent-sdk\n"
289        );
290        let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
291        assert!(diagnostics.contains("sdk=starweaver-agent-sdk"));
292        assert!(diagnostics.contains("database_path="));
293        assert!(diagnostics.contains("model_profiles="));
294        assert!(diagnostics.contains("wal=true"));
295    }
296
297    #[test]
298    fn config_model_profiles_work() {
299        let temp = tempfile::tempdir().unwrap();
300        let global = temp.path().join("global");
301        std::fs::create_dir_all(&global).unwrap();
302        std::fs::write(
303            global.join("config.toml"),
304            r#"
305[general]
306model = "homelab@openai-responses:gpt-5.5"
307model_settings = "openai_responses_high"
308model_cfg = "gpt5_270k"
309
310[model_profiles.codex-subs]
311label = "Codex Subs"
312model = "oauth@codex:gpt-5.5"
313model_settings = "openai_responses_high"
314model_cfg = "gpt5_270k"
315
316[providers.homelab]
317base_url = "https://gateway.example/v1"
318max_tokens_parameter = "omit"
319
320[oauth_refresh]
321enabled = true
322interval_seconds = 42
323failure_retry_seconds = 7
324refresh_on_startup = false
325
326[env]
327HOMELAB_API_KEY = "test-key"
328"#,
329        )
330        .unwrap();
331        let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
332        assert!(diagnostics.contains("profile=default_model"));
333        assert!(diagnostics.contains("model_profiles=1"));
334        assert_eq!(
335            output(
336                temp.path(),
337                &["config", "get", "providers.homelab.max_tokens_parameter"]
338            )
339            .unwrap(),
340            "omit\n"
341        );
342        assert_eq!(
343            output(
344                temp.path(),
345                &["config", "get", "oauth_refresh.interval_seconds"]
346            )
347            .unwrap(),
348            "42\n"
349        );
350        assert_eq!(
351            output(
352                temp.path(),
353                &["config", "get", "oauth_refresh.refresh_on_startup"]
354            )
355            .unwrap(),
356            "false\n"
357        );
358        let profiles = output(temp.path(), &["profile", "list"]).unwrap();
359        assert!(profiles.contains("default_model"));
360        assert!(profiles.contains("codex-subs"));
361        let default_profile = output(temp.path(), &["profile", "show", "default_model"]).unwrap();
362        assert!(default_profile.contains("model_id: homelab@openai-responses:gpt-5.5"));
363        assert!(default_profile.contains("settings_preset: openai_responses_high"));
364        assert!(default_profile.contains("config_preset: gpt5_270k"));
365        assert!(default_profile.contains("# source: config"));
366    }
367
368    #[test]
369    fn configured_slash_commands_layer_aliases_and_redact_unmapped_metadata() {
370        let temp = tempfile::tempdir().unwrap();
371        let global = temp.path().join("global");
372        let project = temp.path().join("project/.starweaver");
373        std::fs::create_dir_all(&global).unwrap();
374        std::fs::create_dir_all(&project).unwrap();
375        std::fs::write(
376            global.join("config.toml"),
377            r#"
378[commands.review]
379description = "Global review"
380aliases = ["rv", "bad alias", "model"]
381prompt = "global secret prompt"
382
383[commands.other]
384aliases = ["review"]
385prompt = "Other command"
386"#,
387        )
388        .unwrap();
389        std::fs::write(
390            project.join("config.toml"),
391            r#"
392[commands.review]
393description = "Project review"
394aliases = ["pr"]
395prompt = "Project review prompt"
396
397[commands.bad_name]
398prompt = "ignored because underscore is valid"
399
400[commands."bad name"]
401prompt = "ignored invalid name"
402"#,
403        )
404        .unwrap();
405
406        let cli = args::parse(["starweaver-cli".to_string(), "diagnostics".to_string()]).unwrap();
407        let config = ConfigResolver::for_tests(temp.path())
408            .resolve(&cli)
409            .unwrap();
410        let review = config.slash_commands.get("review").unwrap();
411        assert_eq!(review.prompt, "Project review prompt");
412        assert_eq!(review.aliases, vec!["pr".to_string()]);
413        assert!(config.slash_commands.contains_key("pr"));
414        assert!(!config.slash_commands.contains_key("rv"));
415        assert!(!config.slash_commands.contains_key("bad alias"));
416        assert!(!config.slash_commands.contains_key("model"));
417        assert!(config.slash_commands.contains_key("bad_name"));
418        assert!(!config.slash_commands.contains_key("bad name"));
419        let unmapped = output(temp.path(), &["config", "get", "metadata.unmapped"]).unwrap();
420        assert!(!unmapped.contains("global secret prompt"));
421        assert!(!unmapped.contains("Project review prompt"));
422        assert!(!unmapped.contains("commands"));
423    }
424
425    #[test]
426    fn configured_subagent_inherits_profile_model() {
427        let temp = tempfile::tempdir().unwrap();
428        let global = temp.path().join("global");
429        let project = temp.path().join("project/.starweaver");
430        std::fs::create_dir_all(global.join("subagents")).unwrap();
431        std::fs::write(
432            global.join("config.toml"),
433            r#"
434[general]
435model = "local_echo"
436
437[subagents]
438dirs = ["subagents"]
439"#,
440        )
441        .unwrap();
442        std::fs::write(
443            global.join("subagents/helper.md"),
444            r"---
445name: helper
446description: Helper subagent
447model: inherit
448---
449You are a helper.
450",
451        )
452        .unwrap();
453
454        let cli = args::parse([
455            "starweaver-cli".to_string(),
456            "-p".to_string(),
457            "hello".to_string(),
458            "--profile".to_string(),
459            "default_model".to_string(),
460        ])
461        .unwrap();
462        let config = ConfigResolver::for_tests(temp.path())
463            .resolve(&cli)
464            .unwrap();
465        assert_eq!(config.project_dir, project);
466        let profile = crate::profiles::resolve_profile(&config, Some("default_model")).unwrap();
467        let agent = profile.build_agent().unwrap();
468        let tools = agent.tools().names();
469        assert!(tools.contains(&"delegate".to_string()));
470        assert!(tools.contains(&"subagent_info".to_string()));
471
472        let run = output(
473            temp.path(),
474            &[
475                "-p",
476                "hello",
477                "--profile",
478                "default_model",
479                "--output",
480                "silent",
481            ],
482        )
483        .unwrap();
484        assert!(run.contains("status=completed"));
485    }
486
487    #[test]
488    fn headless_run_expands_configured_slash_commands() {
489        let temp = tempfile::tempdir().unwrap();
490        let global = temp.path().join("global");
491        std::fs::create_dir_all(&global).unwrap();
492        std::fs::write(
493            global.join("config.toml"),
494            r#"
495[general]
496model = "local_echo"
497
498[commands.review]
499description = "Review the current changes"
500aliases = ["rv"]
501prompt = "Review carefully."
502"#,
503        )
504        .unwrap();
505
506        let run = output(
507            temp.path(),
508            &[
509                "-p",
510                "/rv staged diff",
511                "--profile",
512                "default_model",
513                "--output",
514                "text",
515            ],
516        )
517        .unwrap();
518        assert!(run.contains("local echo: Review carefully."));
519        assert!(run.contains("User instruction: staged diff"));
520
521        let sessions = output(temp.path(), &["session", "list"]).unwrap();
522        let session: serde_json::Value =
523            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
524        let session_id = session["session_id"].as_str().unwrap();
525        let cli = args::parse([
526            "starweaver-cli".to_string(),
527            "session".to_string(),
528            "list".to_string(),
529        ])
530        .unwrap();
531        let config = ConfigResolver::for_tests(temp.path())
532            .resolve(&cli)
533            .unwrap();
534        let store = LocalStore::open(&config).unwrap();
535        let run_id = session["head_run_id"].as_str().unwrap();
536        let run_record = store.load_run(session_id, run_id).unwrap();
537        let run_value = serde_json::to_value(&run_record).unwrap();
538        assert_eq!(
539            run_value["input"][0]["text"],
540            "Review carefully.\n\nUser instruction: staged diff"
541        );
542        assert_eq!(run_value["metadata"]["cli.slash_command.name"], "review");
543        assert_eq!(run_value["metadata"]["cli.slash_command.invoked"], "rv");
544    }
545
546    #[test]
547    fn headless_run_creates_session_and_run() {
548        let temp = tempfile::tempdir().unwrap();
549        let first = output(temp.path(), &["-p", "hello", "--output", "display-jsonl"]).unwrap();
550        let first_message: serde_json::Value =
551            serde_json::from_str(first.lines().next().unwrap()).unwrap();
552        assert_eq!(first_message["schema"], "starweaver.display.v1");
553        assert_eq!(first_message["type"], "RUN_QUEUED");
554        let agui_temp = tempfile::tempdir().unwrap();
555        let agui = output(agui_temp.path(), &["-p", "hello", "--output", "agui-jsonl"]).unwrap();
556        let agui_events = agui
557            .lines()
558            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
559            .collect::<Vec<_>>();
560        assert!(agui_events
561            .iter()
562            .any(|event| event["type"] == "RUN_STARTED"));
563        assert!(agui_events
564            .iter()
565            .any(|event| event["type"] == "TEXT_MESSAGE_CHUNK"));
566        assert!(agui_events
567            .iter()
568            .any(|event| event["type"] == "RUN_FINISHED"));
569        let sessions = output(temp.path(), &["session", "list"]).unwrap();
570        assert!(sessions.contains("session_"));
571        let value: serde_json::Value =
572            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
573        assert_eq!(value["run_count"], 1);
574        assert_eq!(value["head_success_run_id"], value["head_run_id"]);
575    }
576
577    #[test]
578    fn continue_appends_run_under_existing_session() {
579        let temp = tempfile::tempdir().unwrap();
580        output(temp.path(), &["-p", "one"]).unwrap();
581        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
582        let sessions = output(temp.path(), &["session", "list"]).unwrap();
583        let value: serde_json::Value =
584            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
585        assert_eq!(value["run_count"], 2);
586        let session_id = value["session_id"].as_str().unwrap();
587        let show = output(temp.path(), &["session", "show", session_id]).unwrap();
588        assert_eq!(show.lines().count(), 3);
589    }
590
591    #[test]
592    fn display_replay_window_uses_scoped_cursors() {
593        let temp = tempfile::tempdir().unwrap();
594        output(temp.path(), &["-p", "one"]).unwrap();
595        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
596        let sessions = output(temp.path(), &["session", "list"]).unwrap();
597        let value: serde_json::Value =
598            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
599        let session_id = value["session_id"].as_str().unwrap();
600        let cli = args::parse([
601            "starweaver-cli".to_string(),
602            "session".to_string(),
603            "list".to_string(),
604        ])
605        .unwrap();
606        let config = ConfigResolver::for_tests(temp.path())
607            .resolve(&cli)
608            .unwrap();
609        let store = LocalStore::open(&config).unwrap();
610
611        let session_window = store.replay_display_window(session_id, None, None).unwrap();
612        assert_eq!(
613            session_window.scope,
614            starweaver_stream::ReplayScope::session(session_id)
615        );
616        assert_eq!(session_window.next_sequence, session_window.events.len());
617        for (sequence, event) in session_window.events.iter().enumerate() {
618            assert_eq!(
619                event.scope,
620                starweaver_stream::ReplayScope::session(session_id)
621            );
622            assert_eq!(event.sequence, sequence);
623        }
624
625        let session_cursor = starweaver_stream::ReplayCursor::new(session_window.scope.clone(), 0);
626        let session_tail = store
627            .replay_display_window(session_id, None, Some(&session_cursor))
628            .unwrap();
629        assert!(session_tail.events.iter().all(|event| event.sequence > 0));
630        assert_eq!(session_tail.next_sequence, session_window.next_sequence);
631
632        let first_run = store.list_runs(session_id, 10).unwrap().remove(0);
633        let run_window = store
634            .replay_display_window(session_id, Some(&first_run.run_id), None)
635            .unwrap();
636        assert_eq!(
637            run_window.scope,
638            starweaver_stream::ReplayScope::run(&first_run.run_id)
639        );
640        assert!(run_window.next_sequence > 0);
641        assert!(run_window
642            .events
643            .iter()
644            .all(|event| event.scope == starweaver_stream::ReplayScope::run(&first_run.run_id)));
645    }
646
647    #[test]
648    fn local_stream_archive_implements_shared_stream_contract() {
649        use starweaver_stream::StreamArchive as _;
650
651        let temp = tempfile::tempdir().unwrap();
652        output(temp.path(), &["-p", "archive"]).unwrap();
653        let sessions = output(temp.path(), &["session", "list"]).unwrap();
654        let value: serde_json::Value =
655            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
656        let session_id = value["session_id"].as_str().unwrap();
657        let cli = args::parse([
658            "starweaver-cli".to_string(),
659            "session".to_string(),
660            "list".to_string(),
661        ])
662        .unwrap();
663        let config = ConfigResolver::for_tests(temp.path())
664            .resolve(&cli)
665            .unwrap();
666        let store = LocalStore::open(&config).unwrap();
667        let run = store.list_runs(session_id, 10).unwrap().remove(0);
668        let archive = LocalStreamArchive::new(config);
669        let runtime = tokio::runtime::Runtime::new().unwrap();
670        let run_scope = starweaver_stream::ReplayScope::run(&run.run_id);
671        let session_scope = starweaver_stream::ReplayScope::session(session_id);
672
673        let run_messages = runtime
674            .block_on(archive.replay_display_after(&run_scope, None))
675            .unwrap();
676        assert!(!run_messages.is_empty());
677        let run_range = runtime.block_on(archive.cursor_range(&run_scope)).unwrap();
678        assert!(run_range.is_some());
679
680        let session_messages = runtime
681            .block_on(archive.replay_display_after(
682                &session_scope,
683                Some(starweaver_stream::ReplayCursor::new(
684                    session_scope.clone(),
685                    0,
686                )),
687            ))
688            .unwrap();
689        assert!(session_messages.len() < run_messages.len());
690        let session_range = runtime
691            .block_on(archive.cursor_range(&session_scope))
692            .unwrap()
693            .unwrap();
694        assert_eq!(session_range.0.sequence, 0);
695
696        let extra = starweaver_stream::DisplayMessage::new(
697            999,
698            starweaver_core::SessionId::from_string(session_id),
699            starweaver_core::RunId::from_string(&run.run_id),
700            starweaver_stream::DisplayMessageKind::HostOperation,
701        )
702        .with_preview("extra archive message");
703        runtime
704            .block_on(archive.append_display_messages(run_scope.clone(), vec![extra]))
705            .unwrap();
706        let appended = runtime
707            .block_on(archive.replay_display_after(
708                &run_scope,
709                Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 998)),
710            ))
711            .unwrap();
712        assert_eq!(appended.len(), 1);
713        assert_eq!(
714            appended[0].preview.as_deref(),
715            Some("extra archive message")
716        );
717
718        let snapshot = starweaver_stream::ReplaySnapshot {
719            scope: Some(run_scope.clone()),
720            revision: 7,
721            cursor: Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 999)),
722            display_messages: appended,
723            metadata: serde_json::Map::default(),
724        };
725        runtime
726            .block_on(archive.append_snapshot(run_scope.clone(), snapshot.clone()))
727            .unwrap();
728        let latest = runtime
729            .block_on(archive.latest_snapshot(&run_scope))
730            .unwrap()
731            .unwrap();
732        assert_eq!(latest.revision, snapshot.revision);
733
734        let _raw_records = runtime
735            .block_on(archive.replay_raw_after(
736                &starweaver_core::SessionId::from_string(session_id),
737                &starweaver_core::RunId::from_string(&run.run_id),
738                None,
739            ))
740            .unwrap();
741    }
742
743    #[test]
744    fn local_session_and_stream_adapters_back_agent_runtime_builder() {
745        use std::sync::Arc;
746
747        use starweaver_session::SessionStore as _;
748        use starweaver_stream::StreamArchive as _;
749
750        let temp = tempfile::tempdir().unwrap();
751        let cli = args::parse([
752            "starweaver-cli".to_string(),
753            "session".to_string(),
754            "list".to_string(),
755        ])
756        .unwrap();
757        let config = ConfigResolver::for_tests(temp.path())
758            .resolve(&cli)
759            .unwrap();
760        let session_id = starweaver_core::SessionId::from_string("session_local_runtime");
761        let session_store = Arc::new(LocalSessionStore::new(config.clone()));
762        let stream_archive = Arc::new(LocalStreamArchive::new(config));
763        let runtime = tokio::runtime::Runtime::new().unwrap();
764        let mut agent_runtime = starweaver_agent::AgentRuntimeBuilder::new(Arc::new(
765            starweaver_agent::TestModel::with_text("ok"),
766        ))
767        .durable_session_id(session_id.clone())
768        .session_store(session_store.clone())
769        .stream_archive(stream_archive.clone())
770        .build();
771
772        let result = runtime.block_on(agent_runtime.run_stream("hello")).unwrap();
773        assert_eq!(result.result.output, "ok");
774
775        let runs = runtime
776            .block_on(session_store.list_runs(&session_id))
777            .unwrap();
778        assert_eq!(runs.len(), 1);
779        assert_eq!(runs[0].status, starweaver_session::RunStatus::Completed);
780        let run_scope = starweaver_stream::ReplayScope::run(runs[0].run_id.as_str());
781        let display_messages = runtime
782            .block_on(stream_archive.replay_display_after(&run_scope, None))
783            .unwrap();
784        assert!(!display_messages.is_empty());
785        let trace = runtime
786            .block_on(session_store.compact_session_trace(&session_id))
787            .unwrap();
788        assert_eq!(trace.runs, 1);
789    }
790
791    #[test]
792    fn replay_and_trim_work() {
793        let temp = tempfile::tempdir().unwrap();
794        output(temp.path(), &["-p", "one"]).unwrap();
795        output(temp.path(), &["-p", "two", "--continue"]).unwrap();
796        output(temp.path(), &["-p", "three", "--continue"]).unwrap();
797        let sessions = output(temp.path(), &["session", "list"]).unwrap();
798        let session: serde_json::Value =
799            serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
800        let session_id = session["session_id"].as_str().unwrap();
801        let replay = output(temp.path(), &["session", "replay", session_id]).unwrap();
802        assert!(replay.contains("RUN_FINISHED"));
803        let dry = output(
804            temp.path(),
805            &[
806                "session",
807                "trim",
808                "--session",
809                session_id,
810                "--keep-runs",
811                "1",
812                "--dry-run",
813            ],
814        )
815        .unwrap();
816        let report: serde_json::Value = serde_json::from_str(dry.trim()).unwrap();
817        assert_eq!(report["runs_to_trim"], 2);
818        output(
819            temp.path(),
820            &[
821                "session",
822                "trim",
823                "--session",
824                session_id,
825                "--keep-runs",
826                "1",
827            ],
828        )
829        .unwrap();
830        let show = output(temp.path(), &["session", "show", session_id]).unwrap();
831        assert_eq!(show.lines().count(), 2);
832    }
833}