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