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