use claude_wrapper::{Effort, PermissionMode, QueryCommand, RetryPolicy};
use crate::cli::{AskArgs, EffortLevel, PermMode};
pub fn apply_session(mut cmd: QueryCommand, args: &AskArgs) -> QueryCommand {
if let Some(id) = &args.session_id {
cmd = cmd.session_id(id.clone());
} else {
match &args.continue_session {
None => {} Some(None) => cmd = cmd.continue_session(), Some(Some(id)) => cmd = cmd.resume(id.clone()), }
}
if args.fork {
cmd = cmd.fork_session();
}
if let Some(m) = &args.model {
cmd = cmd.model(m.clone());
}
if let Some(m) = &args.fallback_model {
cmd = cmd.fallback_model(m.clone());
}
if let Some(e) = args.effort {
cmd = cmd.effort(match e {
EffortLevel::Low => Effort::Low,
EffortLevel::Medium => Effort::Medium,
EffortLevel::High => Effort::High,
EffortLevel::Xhigh => Effort::Xhigh,
EffortLevel::Max => Effort::Max,
});
}
if let Some(name) = &args.agent {
cmd = cmd.agent(name.clone());
}
if let Some(name) = &args.worktree {
cmd = match name {
Some(n) => cmd.worktree_named(n.clone()),
None => cmd.worktree(),
};
}
if let Some(ref text) = args.system_prompt {
cmd = cmd.system_prompt(text.clone());
}
if let Some(text) = compose_append_system_prompt(args) {
cmd = cmd.append_system_prompt(text);
}
if args.show_thinking && (args.stream || args.trace.is_some()) {
cmd = cmd.include_partial_messages();
}
if args.no_retry {
cmd = cmd.retry(RetryPolicy::new().max_attempts(1));
}
if let Some(n) = args.max_turns {
cmd = cmd.max_turns(n);
}
if let Some(v) = args.max_budget_usd {
cmd = cmd.max_budget_usd(v);
}
if let Some(schema) = &args.json_schema {
cmd = cmd.json_schema(schema.clone());
}
if args.bare {
cmd = cmd.bare();
}
if args.no_session_persistence {
cmd = cmd.no_session_persistence();
}
for d in &args.add_dir {
cmd = cmd.add_dir(d.clone());
}
for p in &args.mcp_config {
cmd = cmd.mcp_config(p.clone());
}
if args.strict_mcp_config {
cmd = cmd.strict_mcp_config();
}
apply_permissions(cmd, args)
}
pub fn apply_permissions(mut cmd: QueryCommand, args: &AskArgs) -> QueryCommand {
if args.full_auto {
return cmd.dangerously_skip_permissions();
}
if let Some(mode) = args.permission_mode {
let cw_mode = permission_mode_to_cw(mode);
cmd = cmd.permission_mode(cw_mode);
}
let mut allow: Vec<String> = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
if args.writable {
push_unique(&mut allow, "Edit");
push_unique(&mut allow, "Write");
}
for t in &args.allow_tool {
push_unique(&mut allow, t);
}
cmd = cmd.allowed_tools(allow);
if !args.deny_tool.is_empty() {
cmd = cmd.disallowed_tools(args.deny_tool.clone());
}
cmd
}
fn permission_mode_to_cw(mode: PermMode) -> PermissionMode {
match mode {
PermMode::AcceptEdits => PermissionMode::AcceptEdits,
PermMode::Auto => PermissionMode::Auto,
#[allow(deprecated)]
PermMode::BypassPermissions => PermissionMode::BypassPermissions,
PermMode::Default => PermissionMode::Default,
PermMode::DontAsk => PermissionMode::DontAsk,
PermMode::Plan => PermissionMode::Plan,
}
}
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(args: &AskArgs) -> Option<String> {
if args.no_agent_notice {
return None;
}
let text = args.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(args: &AskArgs) -> Option<String> {
let user = args
.append_system_prompt
.as_deref()
.filter(|s| !s.is_empty());
let notice = resolve_agent_notice(args);
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 show_thinking_gated_on_stream_or_trace() {
use crate::cli::Cli;
use clap::Parser;
let apply = |argv: &[&str]| {
let cli = Cli::try_parse_from(argv).unwrap();
format!("{:?}", apply_session(QueryCommand::new("hi"), &cli.ask))
};
assert!(
apply(&["roba", "--show-thinking", "prompt"])
.contains("include_partial_messages: false")
);
assert!(
apply(&["roba", "--show-thinking", "--stream", "prompt"])
.contains("include_partial_messages: true")
);
assert!(
apply(&[
"roba",
"--show-thinking",
"--trace",
"/tmp/x.jsonl",
"prompt"
])
.contains("include_partial_messages: true")
);
}
fn ask(argv: &[&str]) -> AskArgs {
use crate::cli::Cli;
use clap::Parser;
Cli::try_parse_from(argv).unwrap().ask
}
#[test]
fn notice_injected_by_default() {
let composed = compose_append_system_prompt(&ask(&["roba", "prompt"])).unwrap();
assert!(
composed.contains("single, non-interactive"),
"got: {composed}"
);
}
#[test]
fn notice_absent_under_no_agent_notice() {
let args = ask(&["roba", "--no-agent-notice", "prompt"]);
assert!(compose_append_system_prompt(&args).is_none());
}
#[test]
fn notice_override_replaces_builtin() {
let mut args = ask(&["roba", "prompt"]);
args.agent_notice = Some("CUSTOM NOTICE".to_string());
let composed = compose_append_system_prompt(&args).unwrap();
assert_eq!(composed, "CUSTOM NOTICE");
assert!(!composed.contains("single, non-interactive"));
}
#[test]
fn notice_composes_with_user_append() {
let args = ask(&["roba", "--append-system-prompt", "Be terse.", "prompt"]);
let composed = compose_append_system_prompt(&args).unwrap();
assert!(composed.contains("Be terse."), "got: {composed}");
assert!(
composed.contains("single, non-interactive"),
"got: {composed}"
);
}
#[test]
fn notice_empty_override_injects_nothing() {
let mut args = ask(&["roba", "prompt"]);
args.agent_notice = Some(String::new());
assert!(compose_append_system_prompt(&args).is_none());
}
#[test]
fn notice_empty_override_keeps_user_append_only() {
let mut args = ask(&["roba", "--append-system-prompt", "Be terse.", "prompt"]);
args.agent_notice = Some(String::new());
assert_eq!(
compose_append_system_prompt(&args).as_deref(),
Some("Be terse.")
);
}
#[test]
fn apply_session_appends_notice_to_command() {
let dbg = format!(
"{:?}",
apply_session(QueryCommand::new("hi"), &ask(&["roba", "prompt"]))
);
assert!(dbg.contains("single, non-interactive"), "got: {dbg}");
}
#[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);
}
}