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