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