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