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 #[test]
141 #[allow(clippy::too_many_lines)]
142 fn args_and_error_helpers_cover_edge_branches() {
143 let run = args::RunCommand {
144 prompt: Some(" explicit ".to_string()),
145 prompt_parts: vec!["ignored".to_string()],
146 continue_session: false,
147 session: None,
148 new_session: false,
149 run: None,
150 branch_from: None,
151 profile: None,
152 output: None,
153 hitl: None,
154 goal: None,
155 worker: None,
156 worker_label: None,
157 worktree: None,
158 worktree_name: None,
159 branch: None,
160 session_affinity_id: None,
161 };
162 assert_eq!(run.prompt_text().unwrap(), " explicit ");
163
164 let joined = args::RunCommand {
165 prompt: None,
166 prompt_parts: vec!["hello".to_string(), "world".to_string()],
167 continue_session: false,
168 session: None,
169 new_session: false,
170 run: None,
171 branch_from: None,
172 profile: None,
173 output: None,
174 hitl: None,
175 goal: None,
176 worker: None,
177 worker_label: None,
178 worktree: None,
179 worktree_name: None,
180 branch: None,
181 session_affinity_id: None,
182 };
183 assert_eq!(joined.prompt_text().unwrap(), "hello world");
184
185 let empty = args::RunCommand {
186 prompt: Some(" ".to_string()),
187 prompt_parts: Vec::new(),
188 continue_session: false,
189 session: None,
190 new_session: false,
191 run: None,
192 branch_from: None,
193 profile: None,
194 output: None,
195 hitl: None,
196 goal: None,
197 worker: None,
198 worker_label: None,
199 worktree: None,
200 worktree_name: None,
201 branch: None,
202 session_affinity_id: None,
203 };
204 assert!(
205 matches!(empty.prompt_text(), Err(CliError::Usage(message)) if message.contains("run -p"))
206 );
207
208 let parsed = args::parse_os([
209 OsString::from("starweaver-cli"),
210 OsString::from("run"),
211 OsString::from("hello"),
212 ])
213 .unwrap();
214 assert!(matches!(parsed.command, Some(args::CliCommand::Run(_))));
215
216 let parsed = args::parse_os([
217 OsString::from("starweaver-cli"),
218 OsString::from("-p"),
219 OsString::from("hello"),
220 OsString::from("-s"),
221 OsString::from("session_test"),
222 OsString::from("--profile"),
223 OsString::from("coding"),
224 OsString::from("--worker"),
225 OsString::from("off"),
226 OsString::from("--worktree"),
227 OsString::from("feature"),
228 OsString::from("--branch"),
229 OsString::from("feature/work"),
230 ])
231 .unwrap();
232 assert_eq!(parsed.session.as_deref(), Some("session_test"));
233 assert_eq!(parsed.profile.as_deref(), Some("coding"));
234 assert_eq!(parsed.worker.as_deref(), Some("off"));
235 assert_eq!(parsed.worktree.as_deref(), Some("feature"));
236 assert_eq!(parsed.branch.as_deref(), Some("feature/work"));
237
238 let parsed = args::parse_os([
239 OsString::from("starweaver-cli"),
240 OsString::from("-p"),
241 OsString::from("hello"),
242 OsString::from("--worker"),
243 OsString::from("-w"),
244 OsString::from("--worker-label"),
245 OsString::from("executor"),
246 OsString::from("--worktree-name"),
247 OsString::from("feature"),
248 ])
249 .unwrap();
250 assert_eq!(parsed.worker.as_deref(), Some("true"));
251 assert_eq!(parsed.worker_label.as_deref(), Some("executor"));
252 assert_eq!(parsed.worktree.as_deref(), Some("true"));
253 assert_eq!(parsed.worktree_name.as_deref(), Some("feature"));
254
255 let parse_error =
256 args::parse_os([OsString::from("starweaver-cli"), OsString::from("--bad")]);
257 assert!(
258 matches!(parse_error, Err(CliError::Usage(message)) if message.contains("unexpected argument"))
259 );
260
261 assert!(format!(
262 "{}",
263 CliError::from(serde_json::from_str::<serde_json::Value>("{").unwrap_err())
264 )
265 .contains("serialization error"));
266 assert!(format!(
267 "{}",
268 CliError::from(toml::from_str::<toml::Value>("=").unwrap_err())
269 )
270 .contains("configuration error"));
271 assert!(format!(
272 "{}",
273 CliError::from(toml::to_string(&f64::NAN).unwrap_err())
274 )
275 .contains("configuration error"));
276 let io_error = error::io_error(
277 "/tmp/missing",
278 io::Error::new(io::ErrorKind::NotFound, "gone"),
279 );
280 assert!(format!("{io_error}").contains("filesystem error at /tmp/missing"));
281 }
282
283 #[test]
284 fn version_and_diagnostics_work() {
285 let temp = tempfile::tempdir().unwrap();
286 assert_eq!(
287 output(temp.path(), &["version"]).unwrap(),
288 "starweaver-agent-sdk\n"
289 );
290 let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
291 assert!(diagnostics.contains("sdk=starweaver-agent-sdk"));
292 assert!(diagnostics.contains("database_path="));
293 assert!(diagnostics.contains("model_profiles="));
294 assert!(diagnostics.contains("wal=true"));
295 }
296
297 #[test]
298 fn config_model_profiles_work() {
299 let temp = tempfile::tempdir().unwrap();
300 let global = temp.path().join("global");
301 std::fs::create_dir_all(&global).unwrap();
302 std::fs::write(
303 global.join("config.toml"),
304 r#"
305[general]
306model = "homelab@openai-responses:gpt-5.5"
307model_settings = "openai_responses_high"
308model_cfg = "gpt5_270k"
309
310[model_profiles.codex-subs]
311label = "Codex Subs"
312model = "oauth@codex:gpt-5.5"
313model_settings = "openai_responses_high"
314model_cfg = "gpt5_270k"
315
316[providers.homelab]
317base_url = "https://gateway.example/v1"
318max_tokens_parameter = "omit"
319
320[oauth_refresh]
321enabled = true
322interval_seconds = 42
323failure_retry_seconds = 7
324refresh_on_startup = false
325
326[env]
327HOMELAB_API_KEY = "test-key"
328"#,
329 )
330 .unwrap();
331 let diagnostics = output(temp.path(), &["diagnostics"]).unwrap();
332 assert!(diagnostics.contains("profile=default_model"));
333 assert!(diagnostics.contains("model_profiles=1"));
334 assert_eq!(
335 output(
336 temp.path(),
337 &["config", "get", "providers.homelab.max_tokens_parameter"]
338 )
339 .unwrap(),
340 "omit\n"
341 );
342 assert_eq!(
343 output(
344 temp.path(),
345 &["config", "get", "oauth_refresh.interval_seconds"]
346 )
347 .unwrap(),
348 "42\n"
349 );
350 assert_eq!(
351 output(
352 temp.path(),
353 &["config", "get", "oauth_refresh.refresh_on_startup"]
354 )
355 .unwrap(),
356 "false\n"
357 );
358 let profiles = output(temp.path(), &["profile", "list"]).unwrap();
359 assert!(profiles.contains("default_model"));
360 assert!(profiles.contains("codex-subs"));
361 let default_profile = output(temp.path(), &["profile", "show", "default_model"]).unwrap();
362 assert!(default_profile.contains("model_id: homelab@openai-responses:gpt-5.5"));
363 assert!(default_profile.contains("settings_preset: openai_responses_high"));
364 assert!(default_profile.contains("config_preset: gpt5_270k"));
365 assert!(default_profile.contains("# source: config"));
366 }
367
368 #[test]
369 fn configured_slash_commands_layer_aliases_and_redact_unmapped_metadata() {
370 let temp = tempfile::tempdir().unwrap();
371 let global = temp.path().join("global");
372 let project = temp.path().join("project/.starweaver");
373 std::fs::create_dir_all(&global).unwrap();
374 std::fs::create_dir_all(&project).unwrap();
375 std::fs::write(
376 global.join("config.toml"),
377 r#"
378[commands.review]
379description = "Global review"
380aliases = ["rv", "bad alias", "model"]
381prompt = "global secret prompt"
382
383[commands.other]
384aliases = ["review"]
385prompt = "Other command"
386"#,
387 )
388 .unwrap();
389 std::fs::write(
390 project.join("config.toml"),
391 r#"
392[commands.review]
393description = "Project review"
394aliases = ["pr"]
395prompt = "Project review prompt"
396
397[commands.bad_name]
398prompt = "ignored because underscore is valid"
399
400[commands."bad name"]
401prompt = "ignored invalid name"
402"#,
403 )
404 .unwrap();
405
406 let cli = args::parse(["starweaver-cli".to_string(), "diagnostics".to_string()]).unwrap();
407 let config = ConfigResolver::for_tests(temp.path())
408 .resolve(&cli)
409 .unwrap();
410 let review = config.slash_commands.get("review").unwrap();
411 assert_eq!(review.prompt, "Project review prompt");
412 assert_eq!(review.aliases, vec!["pr".to_string()]);
413 assert!(config.slash_commands.contains_key("pr"));
414 assert!(!config.slash_commands.contains_key("rv"));
415 assert!(!config.slash_commands.contains_key("bad alias"));
416 assert!(!config.slash_commands.contains_key("model"));
417 assert!(config.slash_commands.contains_key("bad_name"));
418 assert!(!config.slash_commands.contains_key("bad name"));
419 let unmapped = output(temp.path(), &["config", "get", "metadata.unmapped"]).unwrap();
420 assert!(!unmapped.contains("global secret prompt"));
421 assert!(!unmapped.contains("Project review prompt"));
422 assert!(!unmapped.contains("commands"));
423 }
424
425 #[test]
426 fn configured_subagent_inherits_profile_model() {
427 let temp = tempfile::tempdir().unwrap();
428 let global = temp.path().join("global");
429 let project = temp.path().join("project/.starweaver");
430 std::fs::create_dir_all(global.join("subagents")).unwrap();
431 std::fs::write(
432 global.join("config.toml"),
433 r#"
434[general]
435model = "local_echo"
436
437[subagents]
438dirs = ["subagents"]
439"#,
440 )
441 .unwrap();
442 std::fs::write(
443 global.join("subagents/helper.md"),
444 r"---
445name: helper
446description: Helper subagent
447model: inherit
448---
449You are a helper.
450",
451 )
452 .unwrap();
453
454 let cli = args::parse([
455 "starweaver-cli".to_string(),
456 "-p".to_string(),
457 "hello".to_string(),
458 "--profile".to_string(),
459 "default_model".to_string(),
460 ])
461 .unwrap();
462 let config = ConfigResolver::for_tests(temp.path())
463 .resolve(&cli)
464 .unwrap();
465 assert_eq!(config.project_dir, project);
466 let profile = crate::profiles::resolve_profile(&config, Some("default_model")).unwrap();
467 let agent = profile.build_agent().unwrap();
468 let tools = agent.tools().names();
469 assert!(tools.contains(&"delegate".to_string()));
470 assert!(tools.contains(&"subagent_info".to_string()));
471
472 let run = output(
473 temp.path(),
474 &[
475 "-p",
476 "hello",
477 "--profile",
478 "default_model",
479 "--output",
480 "silent",
481 ],
482 )
483 .unwrap();
484 assert!(run.contains("status=completed"));
485 }
486
487 #[test]
488 fn headless_run_expands_configured_slash_commands() {
489 let temp = tempfile::tempdir().unwrap();
490 let global = temp.path().join("global");
491 std::fs::create_dir_all(&global).unwrap();
492 std::fs::write(
493 global.join("config.toml"),
494 r#"
495[general]
496model = "local_echo"
497
498[commands.review]
499description = "Review the current changes"
500aliases = ["rv"]
501prompt = "Review carefully."
502"#,
503 )
504 .unwrap();
505
506 let run = output(
507 temp.path(),
508 &[
509 "-p",
510 "/rv staged diff",
511 "--profile",
512 "default_model",
513 "--output",
514 "text",
515 ],
516 )
517 .unwrap();
518 assert!(run.contains("local echo: Review carefully."));
519 assert!(run.contains("User instruction: staged diff"));
520
521 let sessions = output(temp.path(), &["session", "list"]).unwrap();
522 let session: serde_json::Value =
523 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
524 let session_id = session["session_id"].as_str().unwrap();
525 let cli = args::parse([
526 "starweaver-cli".to_string(),
527 "session".to_string(),
528 "list".to_string(),
529 ])
530 .unwrap();
531 let config = ConfigResolver::for_tests(temp.path())
532 .resolve(&cli)
533 .unwrap();
534 let store = LocalStore::open(&config).unwrap();
535 let run_id = session["head_run_id"].as_str().unwrap();
536 let run_record = store.load_run(session_id, run_id).unwrap();
537 let run_value = serde_json::to_value(&run_record).unwrap();
538 assert_eq!(
539 run_value["input"][0]["text"],
540 "Review carefully.\n\nUser instruction: staged diff"
541 );
542 assert_eq!(run_value["metadata"]["cli.slash_command.name"], "review");
543 assert_eq!(run_value["metadata"]["cli.slash_command.invoked"], "rv");
544 }
545
546 #[test]
547 fn headless_run_creates_session_and_run() {
548 let temp = tempfile::tempdir().unwrap();
549 let first = output(temp.path(), &["-p", "hello", "--output", "display-jsonl"]).unwrap();
550 let first_message: serde_json::Value =
551 serde_json::from_str(first.lines().next().unwrap()).unwrap();
552 assert_eq!(first_message["schema"], "starweaver.display.v1");
553 assert_eq!(first_message["type"], "RUN_QUEUED");
554 let agui_temp = tempfile::tempdir().unwrap();
555 let agui = output(agui_temp.path(), &["-p", "hello", "--output", "agui-jsonl"]).unwrap();
556 let agui_events = agui
557 .lines()
558 .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
559 .collect::<Vec<_>>();
560 assert!(agui_events
561 .iter()
562 .any(|event| event["type"] == "RUN_STARTED"));
563 assert!(agui_events
564 .iter()
565 .any(|event| event["type"] == "TEXT_MESSAGE_CHUNK"));
566 assert!(agui_events
567 .iter()
568 .any(|event| event["type"] == "RUN_FINISHED"));
569 let sessions = output(temp.path(), &["session", "list"]).unwrap();
570 assert!(sessions.contains("session_"));
571 let value: serde_json::Value =
572 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
573 assert_eq!(value["run_count"], 1);
574 assert_eq!(value["head_success_run_id"], value["head_run_id"]);
575 }
576
577 #[test]
578 fn continue_appends_run_under_existing_session() {
579 let temp = tempfile::tempdir().unwrap();
580 output(temp.path(), &["-p", "one"]).unwrap();
581 output(temp.path(), &["-p", "two", "--continue"]).unwrap();
582 let sessions = output(temp.path(), &["session", "list"]).unwrap();
583 let value: serde_json::Value =
584 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
585 assert_eq!(value["run_count"], 2);
586 let session_id = value["session_id"].as_str().unwrap();
587 let show = output(temp.path(), &["session", "show", session_id]).unwrap();
588 assert_eq!(show.lines().count(), 3);
589 }
590
591 #[test]
592 fn display_replay_window_uses_scoped_cursors() {
593 let temp = tempfile::tempdir().unwrap();
594 output(temp.path(), &["-p", "one"]).unwrap();
595 output(temp.path(), &["-p", "two", "--continue"]).unwrap();
596 let sessions = output(temp.path(), &["session", "list"]).unwrap();
597 let value: serde_json::Value =
598 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
599 let session_id = value["session_id"].as_str().unwrap();
600 let cli = args::parse([
601 "starweaver-cli".to_string(),
602 "session".to_string(),
603 "list".to_string(),
604 ])
605 .unwrap();
606 let config = ConfigResolver::for_tests(temp.path())
607 .resolve(&cli)
608 .unwrap();
609 let store = LocalStore::open(&config).unwrap();
610
611 let session_window = store.replay_display_window(session_id, None, None).unwrap();
612 assert_eq!(
613 session_window.scope,
614 starweaver_stream::ReplayScope::session(session_id)
615 );
616 assert_eq!(session_window.next_sequence, session_window.events.len());
617 for (sequence, event) in session_window.events.iter().enumerate() {
618 assert_eq!(
619 event.scope,
620 starweaver_stream::ReplayScope::session(session_id)
621 );
622 assert_eq!(event.sequence, sequence);
623 }
624
625 let session_cursor = starweaver_stream::ReplayCursor::new(session_window.scope.clone(), 0);
626 let session_tail = store
627 .replay_display_window(session_id, None, Some(&session_cursor))
628 .unwrap();
629 assert!(session_tail.events.iter().all(|event| event.sequence > 0));
630 assert_eq!(session_tail.next_sequence, session_window.next_sequence);
631
632 let first_run = store.list_runs(session_id, 10).unwrap().remove(0);
633 let run_window = store
634 .replay_display_window(session_id, Some(&first_run.run_id), None)
635 .unwrap();
636 assert_eq!(
637 run_window.scope,
638 starweaver_stream::ReplayScope::run(&first_run.run_id)
639 );
640 assert!(run_window.next_sequence > 0);
641 assert!(run_window
642 .events
643 .iter()
644 .all(|event| event.scope == starweaver_stream::ReplayScope::run(&first_run.run_id)));
645 }
646
647 #[test]
648 fn local_stream_archive_implements_shared_stream_contract() {
649 use starweaver_stream::StreamArchive as _;
650
651 let temp = tempfile::tempdir().unwrap();
652 output(temp.path(), &["-p", "archive"]).unwrap();
653 let sessions = output(temp.path(), &["session", "list"]).unwrap();
654 let value: serde_json::Value =
655 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
656 let session_id = value["session_id"].as_str().unwrap();
657 let cli = args::parse([
658 "starweaver-cli".to_string(),
659 "session".to_string(),
660 "list".to_string(),
661 ])
662 .unwrap();
663 let config = ConfigResolver::for_tests(temp.path())
664 .resolve(&cli)
665 .unwrap();
666 let store = LocalStore::open(&config).unwrap();
667 let run = store.list_runs(session_id, 10).unwrap().remove(0);
668 let archive = LocalStreamArchive::new(config);
669 let runtime = tokio::runtime::Runtime::new().unwrap();
670 let run_scope = starweaver_stream::ReplayScope::run(&run.run_id);
671 let session_scope = starweaver_stream::ReplayScope::session(session_id);
672
673 let run_messages = runtime
674 .block_on(archive.replay_display_after(&run_scope, None))
675 .unwrap();
676 assert!(!run_messages.is_empty());
677 let run_range = runtime.block_on(archive.cursor_range(&run_scope)).unwrap();
678 assert!(run_range.is_some());
679
680 let session_messages = runtime
681 .block_on(archive.replay_display_after(
682 &session_scope,
683 Some(starweaver_stream::ReplayCursor::new(
684 session_scope.clone(),
685 0,
686 )),
687 ))
688 .unwrap();
689 assert!(session_messages.len() < run_messages.len());
690 let session_range = runtime
691 .block_on(archive.cursor_range(&session_scope))
692 .unwrap()
693 .unwrap();
694 assert_eq!(session_range.0.sequence, 0);
695
696 let extra = starweaver_stream::DisplayMessage::new(
697 999,
698 starweaver_core::SessionId::from_string(session_id),
699 starweaver_core::RunId::from_string(&run.run_id),
700 starweaver_stream::DisplayMessageKind::HostOperation,
701 )
702 .with_preview("extra archive message");
703 runtime
704 .block_on(archive.append_display_messages(run_scope.clone(), vec![extra]))
705 .unwrap();
706 let appended = runtime
707 .block_on(archive.replay_display_after(
708 &run_scope,
709 Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 998)),
710 ))
711 .unwrap();
712 assert_eq!(appended.len(), 1);
713 assert_eq!(
714 appended[0].preview.as_deref(),
715 Some("extra archive message")
716 );
717
718 let snapshot = starweaver_stream::ReplaySnapshot {
719 scope: Some(run_scope.clone()),
720 revision: 7,
721 cursor: Some(starweaver_stream::ReplayCursor::new(run_scope.clone(), 999)),
722 display_messages: appended,
723 metadata: serde_json::Map::default(),
724 };
725 runtime
726 .block_on(archive.append_snapshot(run_scope.clone(), snapshot.clone()))
727 .unwrap();
728 let latest = runtime
729 .block_on(archive.latest_snapshot(&run_scope))
730 .unwrap()
731 .unwrap();
732 assert_eq!(latest.revision, snapshot.revision);
733
734 let _raw_records = runtime
735 .block_on(archive.replay_raw_after(
736 &starweaver_core::SessionId::from_string(session_id),
737 &starweaver_core::RunId::from_string(&run.run_id),
738 None,
739 ))
740 .unwrap();
741 }
742
743 #[test]
744 fn local_session_and_stream_adapters_back_agent_runtime_builder() {
745 use std::sync::Arc;
746
747 use starweaver_session::SessionStore as _;
748 use starweaver_stream::StreamArchive as _;
749
750 let temp = tempfile::tempdir().unwrap();
751 let cli = args::parse([
752 "starweaver-cli".to_string(),
753 "session".to_string(),
754 "list".to_string(),
755 ])
756 .unwrap();
757 let config = ConfigResolver::for_tests(temp.path())
758 .resolve(&cli)
759 .unwrap();
760 let session_id = starweaver_core::SessionId::from_string("session_local_runtime");
761 let session_store = Arc::new(LocalSessionStore::new(config.clone()));
762 let stream_archive = Arc::new(LocalStreamArchive::new(config));
763 let runtime = tokio::runtime::Runtime::new().unwrap();
764 let mut agent_runtime = starweaver_agent::AgentRuntimeBuilder::new(Arc::new(
765 starweaver_agent::TestModel::with_text("ok"),
766 ))
767 .durable_session_id(session_id.clone())
768 .session_store(session_store.clone())
769 .stream_archive(stream_archive.clone())
770 .build();
771
772 let result = runtime.block_on(agent_runtime.run_stream("hello")).unwrap();
773 assert_eq!(result.result.output, "ok");
774
775 let runs = runtime
776 .block_on(session_store.list_runs(&session_id))
777 .unwrap();
778 assert_eq!(runs.len(), 1);
779 assert_eq!(runs[0].status, starweaver_session::RunStatus::Completed);
780 let run_scope = starweaver_stream::ReplayScope::run(runs[0].run_id.as_str());
781 let display_messages = runtime
782 .block_on(stream_archive.replay_display_after(&run_scope, None))
783 .unwrap();
784 assert!(!display_messages.is_empty());
785 let trace = runtime
786 .block_on(session_store.compact_session_trace(&session_id))
787 .unwrap();
788 assert_eq!(trace.runs, 1);
789 }
790
791 #[test]
792 fn replay_and_trim_work() {
793 let temp = tempfile::tempdir().unwrap();
794 output(temp.path(), &["-p", "one"]).unwrap();
795 output(temp.path(), &["-p", "two", "--continue"]).unwrap();
796 output(temp.path(), &["-p", "three", "--continue"]).unwrap();
797 let sessions = output(temp.path(), &["session", "list"]).unwrap();
798 let session: serde_json::Value =
799 serde_json::from_str(sessions.lines().next().unwrap()).unwrap();
800 let session_id = session["session_id"].as_str().unwrap();
801 let replay = output(temp.path(), &["session", "replay", session_id]).unwrap();
802 assert!(replay.contains("RUN_FINISHED"));
803 let dry = output(
804 temp.path(),
805 &[
806 "session",
807 "trim",
808 "--session",
809 session_id,
810 "--keep-runs",
811 "1",
812 "--dry-run",
813 ],
814 )
815 .unwrap();
816 let report: serde_json::Value = serde_json::from_str(dry.trim()).unwrap();
817 assert_eq!(report["runs_to_trim"], 2);
818 output(
819 temp.path(),
820 &[
821 "session",
822 "trim",
823 "--session",
824 session_id,
825 "--keep-runs",
826 "1",
827 ],
828 )
829 .unwrap();
830 let show = output(temp.path(), &["session", "show", session_id]).unwrap();
831 assert_eq!(show.lines().count(), 2);
832 }
833}