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