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