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