use claude_wrapper::{QueryCommand, RetryPolicy};
use crate::engine::{Config, Permissions, Session};
pub fn continue_defeated_by_anon_worktree(config: &Config) -> bool {
matches!(config.session, Session::Continue | Session::Resume(_))
&& matches!(config.worktree, Some(None))
}
pub fn apply_session(mut cmd: QueryCommand, config: &Config) -> QueryCommand {
match &config.session {
Session::Fresh => {}
Session::Continue => cmd = cmd.continue_session(),
Session::Resume(id) => cmd = cmd.resume(id.clone()),
Session::WithId(id) => cmd = cmd.session_id(id.clone()),
}
if config.fork {
cmd = cmd.fork_session();
}
if let Some(m) = &config.model {
cmd = cmd.model(m.clone());
}
if let Some(m) = &config.fallback_model {
cmd = cmd.fallback_model(m.clone());
}
if let Some(e) = config.effort {
cmd = cmd.effort(e);
}
if let Some(name) = &config.agent {
cmd = cmd.agent(name.clone());
}
if let Some(name) = &config.worktree {
cmd = match name {
Some(n) => cmd.worktree_named(n.clone()),
None => cmd.worktree(),
};
}
if let Some(ref text) = config.system_prompt {
cmd = cmd.system_prompt(text.clone());
}
if let Some(text) = compose_append_system_prompt(config) {
cmd = cmd.append_system_prompt(text);
}
if config.no_retry {
cmd = cmd.retry(RetryPolicy::new().max_attempts(1));
}
if let Some(n) = config.max_turns {
cmd = cmd.max_turns(n);
}
if let Some(v) = config.max_budget_usd {
cmd = cmd.max_budget_usd(v);
}
if let Some(schema) = &config.json_schema {
cmd = cmd.json_schema(schema.clone());
}
if config.bare {
cmd = cmd.bare();
}
if config.safe_mode {
cmd = cmd.safe_mode();
}
if config.no_session_persistence {
cmd = cmd.no_session_persistence();
}
for d in &config.add_dir {
cmd = cmd.add_dir(d.clone());
}
for p in &config.mcp_config {
cmd = cmd.mcp_config(p.clone());
}
if config.strict_mcp_config {
cmd = cmd.strict_mcp_config();
}
apply_permissions(cmd, config)
}
pub fn apply_permissions(mut cmd: QueryCommand, config: &Config) -> QueryCommand {
if matches!(config.permissions, Permissions::FullAuto) {
return cmd.dangerously_skip_permissions();
}
if let Some(mode) = config.permission_mode {
cmd = cmd.permission_mode(mode);
}
let mut allow: Vec<String> = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
if matches!(config.permissions, Permissions::Writable) {
push_unique(&mut allow, "Edit");
push_unique(&mut allow, "Write");
}
for t in &config.allow_tools {
push_unique(&mut allow, t);
}
cmd = cmd.allowed_tools(allow);
if !config.deny_tools.is_empty() {
cmd = cmd.disallowed_tools(config.deny_tools.clone());
}
cmd
}
fn push_unique(list: &mut Vec<String>, item: &str) {
if !list.iter().any(|s| s == item) {
list.push(item.to_string());
}
}
pub const BUILTIN_AGENT_NOTICE: &str = "You are running as a single, \
non-interactive `claude -p` turn via roba -- not an interactive or persistent \
session. When you stop calling tools and produce your final response, this \
process exits: you will not be re-invoked, and you will not receive \
background-task-completion notifications across turns. If you start \
asynchronous work (for example a detached `roba --detach` worker), either \
block on it synchronously within this turn (`roba show <id> --wait` in the \
foreground), or end your turn by explicitly handing the session handle back \
to the caller. Never background a task and then stop while expecting to be \
auto-resumed.";
fn resolve_agent_notice(config: &Config) -> Option<String> {
if config.no_agent_notice {
return None;
}
let text = config
.agent_notice
.as_deref()
.unwrap_or(BUILTIN_AGENT_NOTICE);
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
pub fn compose_append_system_prompt(config: &Config) -> Option<String> {
let user = config
.append_system_prompt
.as_deref()
.filter(|s| !s.is_empty());
let notice = resolve_agent_notice(config);
match (user, notice) {
(Some(u), Some(n)) => Some(format!("{u}\n\n{n}")),
(Some(u), None) => Some(u.to_string()),
(None, Some(n)) => Some(n),
(None, None) => None,
}
}
pub fn derive_session_name(prompt: &str) -> String {
let first_line = prompt
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("");
let preview: String = if first_line.chars().count() > 40 {
let head: String = first_line.chars().take(40).collect();
format!("{head}…")
} else {
first_line.to_string()
};
if preview.is_empty() {
"roba".to_string()
} else {
format!("roba: {preview}")
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Resolution {
Unique(String),
Ambiguous(Vec<String>),
NoMatch,
}
pub fn resolve_session_prefix(input: &str, available_ids: &[String]) -> Resolution {
if input.is_empty() {
return Resolution::NoMatch;
}
let needle = input.to_ascii_lowercase();
let matches: Vec<String> = available_ids
.iter()
.filter(|id| id.to_ascii_lowercase().starts_with(&needle))
.cloned()
.collect();
match matches.len() {
0 => Resolution::NoMatch,
1 => Resolution::Unique(matches.into_iter().next().unwrap()),
_ => Resolution::Ambiguous(matches),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn prefix_unique_match_expands_to_full() {
let available = ids(&[
"ef7de917-1234-4abc-8def-000000000001",
"a1b2c3d4-5678-4abc-8def-000000000002",
]);
assert_eq!(
resolve_session_prefix("ef7de917", &available),
Resolution::Unique("ef7de917-1234-4abc-8def-000000000001".to_string())
);
}
#[test]
fn prefix_ambiguous_returns_all_candidates() {
let available = ids(&[
"ef7de917-1234-4abc-8def-000000000001",
"ef7de917-9999-4abc-8def-000000000002",
"00000000-0000-4abc-8def-000000000003",
]);
match resolve_session_prefix("ef7de917", &available) {
Resolution::Ambiguous(candidates) => {
assert_eq!(candidates.len(), 2);
assert!(candidates.contains(&"ef7de917-1234-4abc-8def-000000000001".to_string()));
assert!(candidates.contains(&"ef7de917-9999-4abc-8def-000000000002".to_string()));
}
other => panic!("expected Ambiguous, got {other:?}"),
}
}
#[test]
fn prefix_no_match_falls_through() {
let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
assert_eq!(
resolve_session_prefix("deadbeef", &available),
Resolution::NoMatch
);
}
#[test]
fn prefix_full_uuid_present_matches_itself() {
let full = "ef7de917-1234-4abc-8def-000000000001";
let available = ids(&[full, "a1b2c3d4-5678-4abc-8def-000000000002"]);
assert_eq!(
resolve_session_prefix(full, &available),
Resolution::Unique(full.to_string())
);
}
#[test]
fn prefix_title_like_input_no_match() {
let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
assert_eq!(
resolve_session_prefix("the real prompt", &available),
Resolution::NoMatch
);
}
#[test]
fn prefix_eight_char_displayed_form_resolves() {
let available = ids(&[
"ef7de917-1234-4abc-8def-000000000001",
"ffffffff-5678-4abc-8def-000000000002",
]);
assert_eq!(
resolve_session_prefix("ef7de917", &available),
Resolution::Unique("ef7de917-1234-4abc-8def-000000000001".to_string())
);
}
#[test]
fn prefix_is_case_insensitive() {
let available = ids(&["ABCDEF12-1234-4abc-8def-000000000001"]);
assert_eq!(
resolve_session_prefix("abcdef12", &available),
Resolution::Unique("ABCDEF12-1234-4abc-8def-000000000001".to_string())
);
}
#[test]
fn prefix_empty_input_no_match() {
let available = ids(&["ef7de917-1234-4abc-8def-000000000001"]);
assert_eq!(resolve_session_prefix("", &available), Resolution::NoMatch);
}
#[test]
fn prefix_empty_id_list_no_match() {
assert_eq!(resolve_session_prefix("ef7de917", &[]), Resolution::NoMatch);
}
#[test]
fn name_short_prompt_passes_through() {
assert_eq!(derive_session_name("hello"), "roba: hello");
}
#[test]
fn name_truncates_at_40_chars_with_ellipsis() {
let prompt = "this is a fairly long prompt that should get cut off somewhere";
let name = derive_session_name(prompt);
assert!(name.starts_with("roba: "), "got: {name}");
assert!(name.ends_with('…'), "got: {name}");
let body = name.trim_start_matches("roba: ").trim_end_matches('…');
assert_eq!(body.chars().count(), 40, "got body: {body:?}");
}
#[test]
fn name_uses_first_nonempty_line() {
let prompt = "\n\n \nthe real prompt\nignored continuation";
assert_eq!(derive_session_name(prompt), "roba: the real prompt");
}
#[test]
fn name_empty_prompt_falls_back_to_bare_roba() {
assert_eq!(derive_session_name(""), "roba");
assert_eq!(derive_session_name(" \n \n"), "roba");
}
#[test]
fn apply_session_never_forwards_include_partial_messages() {
let dbg = format!(
"{:?}",
apply_session(QueryCommand::new("hi"), &Config::new("p"))
);
assert!(
dbg.contains("include_partial_messages: false"),
"apply_session must not forward include_partial_messages: {dbg}"
);
}
#[test]
fn notice_injected_by_default() {
let composed = compose_append_system_prompt(&Config::new("p")).unwrap();
assert!(
composed.contains("single, non-interactive"),
"got: {composed}"
);
}
#[test]
fn notice_absent_under_no_agent_notice() {
let c = Config {
no_agent_notice: true,
..Config::new("p")
};
assert!(compose_append_system_prompt(&c).is_none());
}
#[test]
fn notice_override_replaces_builtin() {
let c = Config {
agent_notice: Some("CUSTOM NOTICE".to_string()),
..Config::new("p")
};
let composed = compose_append_system_prompt(&c).unwrap();
assert_eq!(composed, "CUSTOM NOTICE");
assert!(!composed.contains("single, non-interactive"));
}
#[test]
fn notice_composes_with_user_append() {
let c = Config {
append_system_prompt: Some("Be terse.".to_string()),
..Config::new("p")
};
let composed = compose_append_system_prompt(&c).unwrap();
assert!(composed.contains("Be terse."), "got: {composed}");
assert!(
composed.contains("single, non-interactive"),
"got: {composed}"
);
}
#[test]
fn notice_empty_override_injects_nothing() {
let c = Config {
agent_notice: Some(String::new()),
..Config::new("p")
};
assert!(compose_append_system_prompt(&c).is_none());
}
#[test]
fn notice_empty_override_keeps_user_append_only() {
let c = Config {
append_system_prompt: Some("Be terse.".to_string()),
agent_notice: Some(String::new()),
..Config::new("p")
};
assert_eq!(
compose_append_system_prompt(&c).as_deref(),
Some("Be terse.")
);
}
#[test]
fn apply_session_appends_notice_to_command() {
let dbg = format!(
"{:?}",
apply_session(QueryCommand::new("hi"), &Config::new("p"))
);
assert!(dbg.contains("single, non-interactive"), "got: {dbg}");
}
#[test]
fn anon_worktree_defeats_continue_most_recent() {
let c = Config {
session: Session::Continue,
worktree: Some(None),
..Config::new("p")
};
assert!(continue_defeated_by_anon_worktree(&c));
}
#[test]
fn anon_worktree_defeats_resume_specific() {
let c = Config {
session: Session::Resume("abc123".into()),
worktree: Some(None),
..Config::new("p")
};
assert!(continue_defeated_by_anon_worktree(&c));
}
#[test]
fn named_worktree_does_not_defeat_continue() {
let c = Config {
session: Session::Continue,
worktree: Some(Some("mybranch".into())),
..Config::new("p")
};
assert!(!continue_defeated_by_anon_worktree(&c));
}
#[test]
fn anon_worktree_alone_is_fine() {
let c = Config {
session: Session::Fresh,
worktree: Some(None),
..Config::new("p")
};
assert!(!continue_defeated_by_anon_worktree(&c));
}
#[test]
fn continue_alone_is_fine() {
let c = Config {
session: Session::Continue,
..Config::new("p")
};
assert!(!continue_defeated_by_anon_worktree(&c));
}
#[test]
fn session_id_does_not_defeat_continue() {
let c = Config {
session: Session::WithId("ef7de917-1234-4abc-8def-000000000001".into()),
worktree: Some(None),
..Config::new("p")
};
assert!(!continue_defeated_by_anon_worktree(&c));
}
#[test]
fn name_handles_unicode_correctly() {
let prompt = "あ".repeat(50);
let name = derive_session_name(&prompt);
let body = name.trim_start_matches("roba: ").trim_end_matches('…');
assert_eq!(body.chars().count(), 40);
}
}