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