Skip to main content

starweaver_cli/
lib.rs

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