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