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 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
41pub fn run_from_env() -> CliResult<()> {
43 run(env::args())
44}
45
46pub 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
62pub 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 ¤t_session,
1180 "--output",
1181 "silent",
1182 ],
1183 )
1184 .unwrap();
1185
1186 assert_eq!(session_run_count(temp.path(), ¤t_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}