1use claude_wrapper::{QueryCommand, RetryPolicy};
11
12use crate::engine::{Config, Permissions, Session};
13
14pub fn continue_defeated_by_anon_worktree(config: &Config) -> bool {
20 matches!(config.session, Session::Continue | Session::Resume(_))
21 && matches!(config.worktree, Some(None))
22}
23
24pub fn apply_session(mut cmd: QueryCommand, config: &Config) -> QueryCommand {
28 match &config.session {
34 Session::Fresh => {}
35 Session::Continue => cmd = cmd.continue_session(),
36 Session::Resume(id) => cmd = cmd.resume(id.clone()),
37 Session::WithId(id) => cmd = cmd.session_id(id.clone()),
38 }
39 if config.fork {
40 cmd = cmd.fork_session();
41 }
42 if let Some(m) = &config.model {
43 cmd = cmd.model(m.clone());
44 }
45 if let Some(m) = &config.fallback_model {
46 cmd = cmd.fallback_model(m.clone());
47 }
48 if let Some(e) = config.effort {
49 cmd = cmd.effort(e);
50 }
51 if let Some(name) = &config.agent {
52 cmd = cmd.agent(name.clone());
53 }
54 if let Some(name) = &config.worktree {
55 cmd = match name {
60 Some(n) => cmd.worktree_named(n.clone()),
61 None => cmd.worktree(),
62 };
63 }
64 if let Some(ref text) = config.system_prompt {
65 cmd = cmd.system_prompt(text.clone());
66 }
67 if let Some(text) = compose_append_system_prompt(config) {
72 cmd = cmd.append_system_prompt(text);
73 }
74 if config.no_retry {
78 cmd = cmd.retry(RetryPolicy::new().max_attempts(1));
84 }
85 if let Some(n) = config.max_turns {
86 cmd = cmd.max_turns(n);
87 }
88 if let Some(v) = config.max_budget_usd {
89 cmd = cmd.max_budget_usd(v);
90 }
91 if let Some(schema) = &config.json_schema {
92 cmd = cmd.json_schema(schema.clone());
97 }
98 if config.bare {
99 cmd = cmd.bare();
100 }
101 if config.safe_mode {
102 cmd = cmd.safe_mode();
103 }
104 if config.no_session_persistence {
105 cmd = cmd.no_session_persistence();
106 }
107 for d in &config.add_dir {
110 cmd = cmd.add_dir(d.clone());
111 }
112 for p in &config.mcp_config {
115 cmd = cmd.mcp_config(p.clone());
116 }
117 if config.strict_mcp_config {
118 cmd = cmd.strict_mcp_config();
119 }
120 apply_permissions(cmd, config)
121}
122
123pub fn apply_permissions(mut cmd: QueryCommand, config: &Config) -> QueryCommand {
142 if matches!(config.permissions, Permissions::FullAuto) {
143 return cmd.dangerously_skip_permissions();
144 }
145
146 if let Some(mode) = config.permission_mode {
150 cmd = cmd.permission_mode(mode);
151 }
152
153 let mut allow: Vec<String> = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
156 if matches!(config.permissions, Permissions::Writable) {
157 push_unique(&mut allow, "Edit");
158 push_unique(&mut allow, "Write");
159 }
160 for t in &config.allow_tools {
161 push_unique(&mut allow, t);
162 }
163 cmd = cmd.allowed_tools(allow);
164
165 if !config.deny_tools.is_empty() {
166 cmd = cmd.disallowed_tools(config.deny_tools.clone());
167 }
168
169 cmd
170}
171
172fn push_unique(list: &mut Vec<String>, item: &str) {
173 if !list.iter().any(|s| s == item) {
174 list.push(item.to_string());
175 }
176}
177
178pub const BUILTIN_AGENT_NOTICE: &str = "You are running as a single, \
185non-interactive `claude -p` turn via roba -- not an interactive or persistent \
186session. When you stop calling tools and produce your final response, this \
187process exits: you will not be re-invoked, and you will not receive \
188background-task-completion notifications across turns. If you start \
189asynchronous work (for example a detached `roba --detach` worker), either \
190block on it synchronously within this turn (`roba show <id> --wait` in the \
191foreground), or end your turn by explicitly handing the session handle back \
192to the caller. Never background a task and then stop while expecting to be \
193auto-resumed.";
194
195fn resolve_agent_notice(config: &Config) -> Option<String> {
200 if config.no_agent_notice {
201 return None;
202 }
203 let text = config
204 .agent_notice
205 .as_deref()
206 .unwrap_or(BUILTIN_AGENT_NOTICE);
207 if text.is_empty() {
208 None
209 } else {
210 Some(text.to_string())
211 }
212}
213
214pub fn compose_append_system_prompt(config: &Config) -> Option<String> {
224 let user = config
225 .append_system_prompt
226 .as_deref()
227 .filter(|s| !s.is_empty());
228 let notice = resolve_agent_notice(config);
229 match (user, notice) {
230 (Some(u), Some(n)) => Some(format!("{u}\n\n{n}")),
231 (Some(u), None) => Some(u.to_string()),
232 (None, Some(n)) => Some(n),
233 (None, None) => None,
234 }
235}
236
237pub fn derive_session_name(prompt: &str) -> String {
246 let first_line = prompt
247 .lines()
248 .map(str::trim)
249 .find(|line| !line.is_empty())
250 .unwrap_or("");
251 let preview: String = if first_line.chars().count() > 40 {
252 let head: String = first_line.chars().take(40).collect();
253 format!("{head}…")
254 } else {
255 first_line.to_string()
256 };
257 if preview.is_empty() {
258 "roba".to_string()
259 } else {
260 format!("roba: {preview}")
261 }
262}
263
264#[derive(Debug, PartialEq, Eq)]
267pub enum Resolution {
268 Unique(String),
271 Ambiguous(Vec<String>),
274 NoMatch,
279}
280
281pub fn resolve_session_prefix(input: &str, available_ids: &[String]) -> Resolution {
299 if input.is_empty() {
300 return Resolution::NoMatch;
301 }
302 let needle = input.to_ascii_lowercase();
303 let matches: Vec<String> = available_ids
304 .iter()
305 .filter(|id| id.to_ascii_lowercase().starts_with(&needle))
306 .cloned()
307 .collect();
308 match matches.len() {
309 0 => Resolution::NoMatch,
310 1 => Resolution::Unique(matches.into_iter().next().unwrap()),
311 _ => Resolution::Ambiguous(matches),
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn ids(list: &[&str]) -> Vec<String> {
320 list.iter().map(|s| s.to_string()).collect()
321 }
322
323 #[test]
324 fn prefix_unique_match_expands_to_full() {
325 let available = ids(&[
326 "ef7de917-1234-4abc-8def-000000000001",
327 "a1b2c3d4-5678-4abc-8def-000000000002",
328 ]);
329 assert_eq!(
330 resolve_session_prefix("ef7de917", &available),
331 Resolution::Unique("ef7de917-1234-4abc-8def-000000000001".to_string())
332 );
333 }
334
335 #[test]
336 fn prefix_ambiguous_returns_all_candidates() {
337 let available = ids(&[
338 "ef7de917-1234-4abc-8def-000000000001",
339 "ef7de917-9999-4abc-8def-000000000002",
340 "00000000-0000-4abc-8def-000000000003",
341 ]);
342 match resolve_session_prefix("ef7de917", &available) {
343 Resolution::Ambiguous(candidates) => {
344 assert_eq!(candidates.len(), 2);
345 assert!(candidates.contains(&"ef7de917-1234-4abc-8def-000000000001".to_string()));
346 assert!(candidates.contains(&"ef7de917-9999-4abc-8def-000000000002".to_string()));
347 }
348 other => panic!("expected Ambiguous, got {other:?}"),
349 }
350 }
351
352 #[test]
353 fn prefix_no_match_falls_through() {
354 let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
355 assert_eq!(
358 resolve_session_prefix("deadbeef", &available),
359 Resolution::NoMatch
360 );
361 }
362
363 #[test]
364 fn prefix_full_uuid_present_matches_itself() {
365 let full = "ef7de917-1234-4abc-8def-000000000001";
366 let available = ids(&[full, "a1b2c3d4-5678-4abc-8def-000000000002"]);
367 assert_eq!(
368 resolve_session_prefix(full, &available),
369 Resolution::Unique(full.to_string())
370 );
371 }
372
373 #[test]
374 fn prefix_title_like_input_no_match() {
375 let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
378 assert_eq!(
379 resolve_session_prefix("the real prompt", &available),
380 Resolution::NoMatch
381 );
382 }
383
384 #[test]
385 fn prefix_eight_char_displayed_form_resolves() {
386 let available = ids(&[
388 "ef7de917-1234-4abc-8def-000000000001",
389 "ffffffff-5678-4abc-8def-000000000002",
390 ]);
391 assert_eq!(
392 resolve_session_prefix("ef7de917", &available),
393 Resolution::Unique("ef7de917-1234-4abc-8def-000000000001".to_string())
394 );
395 }
396
397 #[test]
398 fn prefix_is_case_insensitive() {
399 let available = ids(&["ABCDEF12-1234-4abc-8def-000000000001"]);
400 assert_eq!(
401 resolve_session_prefix("abcdef12", &available),
402 Resolution::Unique("ABCDEF12-1234-4abc-8def-000000000001".to_string())
403 );
404 }
405
406 #[test]
407 fn prefix_empty_input_no_match() {
408 let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
409 assert_eq!(resolve_session_prefix("", &available), Resolution::NoMatch);
410 }
411
412 #[test]
413 fn prefix_empty_id_list_no_match() {
414 assert_eq!(resolve_session_prefix("ef7de917", &[]), Resolution::NoMatch);
415 }
416
417 #[test]
418 fn name_short_prompt_passes_through() {
419 assert_eq!(derive_session_name("hello"), "roba: hello");
420 }
421
422 #[test]
423 fn name_truncates_at_40_chars_with_ellipsis() {
424 let prompt = "this is a fairly long prompt that should get cut off somewhere";
425 let name = derive_session_name(prompt);
426 assert!(name.starts_with("roba: "), "got: {name}");
427 assert!(name.ends_with('…'), "got: {name}");
428 let body = name.trim_start_matches("roba: ").trim_end_matches('…');
429 assert_eq!(body.chars().count(), 40, "got body: {body:?}");
430 }
431
432 #[test]
433 fn name_uses_first_nonempty_line() {
434 let prompt = "\n\n \nthe real prompt\nignored continuation";
435 assert_eq!(derive_session_name(prompt), "roba: the real prompt");
436 }
437
438 #[test]
439 fn name_empty_prompt_falls_back_to_bare_roba() {
440 assert_eq!(derive_session_name(""), "roba");
441 assert_eq!(derive_session_name(" \n \n"), "roba");
442 }
443
444 #[test]
445 fn apply_session_never_forwards_include_partial_messages() {
446 let dbg = format!(
453 "{:?}",
454 apply_session(QueryCommand::new("hi"), &Config::new("p"))
455 );
456 assert!(
457 dbg.contains("include_partial_messages: false"),
458 "apply_session must not forward include_partial_messages: {dbg}"
459 );
460 }
461
462 #[test]
465 fn notice_injected_by_default() {
466 let composed = compose_append_system_prompt(&Config::new("p")).unwrap();
467 assert!(
468 composed.contains("single, non-interactive"),
469 "got: {composed}"
470 );
471 }
472
473 #[test]
474 fn notice_absent_under_no_agent_notice() {
475 let c = Config {
476 no_agent_notice: true,
477 ..Config::new("p")
478 };
479 assert!(compose_append_system_prompt(&c).is_none());
480 }
481
482 #[test]
483 fn notice_override_replaces_builtin() {
484 let c = Config {
485 agent_notice: Some("CUSTOM NOTICE".to_string()),
486 ..Config::new("p")
487 };
488 let composed = compose_append_system_prompt(&c).unwrap();
489 assert_eq!(composed, "CUSTOM NOTICE");
490 assert!(!composed.contains("single, non-interactive"));
491 }
492
493 #[test]
494 fn notice_composes_with_user_append() {
495 let c = Config {
496 append_system_prompt: Some("Be terse.".to_string()),
497 ..Config::new("p")
498 };
499 let composed = compose_append_system_prompt(&c).unwrap();
500 assert!(composed.contains("Be terse."), "got: {composed}");
501 assert!(
502 composed.contains("single, non-interactive"),
503 "got: {composed}"
504 );
505 }
506
507 #[test]
508 fn notice_empty_override_injects_nothing() {
509 let c = Config {
510 agent_notice: Some(String::new()),
511 ..Config::new("p")
512 };
513 assert!(compose_append_system_prompt(&c).is_none());
514 }
515
516 #[test]
517 fn notice_empty_override_keeps_user_append_only() {
518 let c = Config {
519 append_system_prompt: Some("Be terse.".to_string()),
520 agent_notice: Some(String::new()),
521 ..Config::new("p")
522 };
523 assert_eq!(
524 compose_append_system_prompt(&c).as_deref(),
525 Some("Be terse.")
526 );
527 }
528
529 #[test]
530 fn apply_session_appends_notice_to_command() {
531 let dbg = format!(
533 "{:?}",
534 apply_session(QueryCommand::new("hi"), &Config::new("p"))
535 );
536 assert!(dbg.contains("single, non-interactive"), "got: {dbg}");
537 }
538
539 #[test]
542 fn anon_worktree_defeats_continue_most_recent() {
543 let c = Config {
545 session: Session::Continue,
546 worktree: Some(None),
547 ..Config::new("p")
548 };
549 assert!(continue_defeated_by_anon_worktree(&c));
550 }
551
552 #[test]
553 fn anon_worktree_defeats_resume_specific() {
554 let c = Config {
556 session: Session::Resume("abc123".into()),
557 worktree: Some(None),
558 ..Config::new("p")
559 };
560 assert!(continue_defeated_by_anon_worktree(&c));
561 }
562
563 #[test]
564 fn named_worktree_does_not_defeat_continue() {
565 let c = Config {
567 session: Session::Continue,
568 worktree: Some(Some("mybranch".into())),
569 ..Config::new("p")
570 };
571 assert!(!continue_defeated_by_anon_worktree(&c));
572 }
573
574 #[test]
575 fn anon_worktree_alone_is_fine() {
576 let c = Config {
578 session: Session::Fresh,
579 worktree: Some(None),
580 ..Config::new("p")
581 };
582 assert!(!continue_defeated_by_anon_worktree(&c));
583 }
584
585 #[test]
586 fn continue_alone_is_fine() {
587 let c = Config {
589 session: Session::Continue,
590 ..Config::new("p")
591 };
592 assert!(!continue_defeated_by_anon_worktree(&c));
593 }
594
595 #[test]
596 fn session_id_does_not_defeat_continue() {
597 let c = Config {
600 session: Session::WithId("ef7de917-1234-4abc-8def-000000000001".into()),
601 worktree: Some(None),
602 ..Config::new("p")
603 };
604 assert!(!continue_defeated_by_anon_worktree(&c));
605 }
606
607 #[test]
608 fn name_handles_unicode_correctly() {
609 let prompt = "あ".repeat(50);
611 let name = derive_session_name(&prompt);
612 let body = name.trim_start_matches("roba: ").trim_end_matches('…');
614 assert_eq!(body.chars().count(), 40);
615 }
616}