Skip to main content

starweaver_cli/
lib.rs

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