Skip to main content

roba_core/
session.rs

1//! Session continuation and permission preset application.
2//!
3//! [`apply_session`] is the single flag->`QueryCommand` mapper: it translates
4//! an [`crate::engine::Config`] (session continuity, model/effort/agent, the
5//! permission posture, caps, MCP, ...) into the matching builder calls. It is
6//! clap-free -- the CLI's `run_ask` builds a `Config` from its resolved
7//! `AskArgs` (`build_config`) and the side-effect-free `engine::run` takes one
8//! directly, so both share this one mapper with no second copy to drift.
9
10use claude_wrapper::{QueryCommand, RetryPolicy};
11
12use crate::engine::{Config, Permissions, Session};
13
14/// True when a continue/resume request will be silently defeated by an
15/// anonymous (unnamed) worktree: the worktree mints a fresh dir every run, so
16/// `-c` finds no prior session there. A NAMED worktree is stable and composes
17/// fine, so it is excluded; a `--session-id` (a new session with a chosen id,
18/// i.e. [`Session::WithId`]) is not a continue/resume, so it is excluded too.
19pub 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
24/// Apply session-related flags (-c / -c=ID, --fork), the model
25/// override (--model), the subagent override (--agent), and then
26/// permission-related flags. Returns the configured QueryCommand.
27pub fn apply_session(mut cmd: QueryCommand, config: &Config) -> QueryCommand {
28    // Session selection. `WithId` assigns a caller-chosen UUID to a new
29    // session; the others continue the most recent / resume a specific one /
30    // start fresh. The auto-derived `.name(...)` (display label) is applied
31    // elsewhere and coexists with either -- name (display) and session-id
32    // (UUID) are independent.
33    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        // The anonymous-worktree-defeats-continue advisory
56        // ([`continue_defeated_by_anon_worktree`]) is emitted by the CLI
57        // (`run_ask`), NOT here: this mapper is shared with the
58        // side-effect-free `engine::run`, so it must not print.
59        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    // Append the user's `--append-system-prompt` and the built-in agent
68    // notice as ONE combined value (claude-wrapper's append_system_prompt
69    // is a setter, so a second call would clobber the first -- see
70    // compose_append_system_prompt).
71    if let Some(text) = compose_append_system_prompt(config) {
72        cmd = cmd.append_system_prompt(text);
73    }
74    // NOTE: include_partial_messages (extended-thinking on the stream/trace
75    // path) is a display concern applied by the streaming pipeline
76    // (`run_streaming`), not here -- `Config` carries no display flags.
77    if config.no_retry {
78        // Force a per-command retry policy of a single attempt. This
79        // overrides any client-level default (the wrapper resolves
80        // per-command policy first), so a transient failure surfaces
81        // immediately instead of being silently re-tried with backoff.
82        // `max_attempts(1)` means "no retries."
83        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        // `config.json_schema` is the inline schema JSON (the CLI reads the
93        // PATH its `--json-schema` flag named and stores the contents; a
94        // programmatic caller sets the JSON directly). claude's
95        // `--json-schema` takes inline JSON, so pass it straight through.
96        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    // Additional tool-access directories: forward each --add-dir path
108    // verbatim (claude resolves and reads them). Pure pass-through.
109    for d in &config.add_dir {
110        cmd = cmd.add_dir(d.clone());
111    }
112    // MCP servers for this run: forward each --mcp-config path verbatim
113    // (claude reads the file), then the strict flag. Pure pass-through.
114    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
123/// Apply permission policy.
124///
125/// The default behavior is "readonly": claude can use Read, Glob,
126/// and Grep but nothing else. To open more up, layer additions:
127///
128/// - `--readonly` -- explicit form of the default; no-op.
129/// - `--writable` -- preset that adds Edit + Write.
130/// - `--permission-mode MODE` -- pass a specific permission mode to
131///   claude (`plan`, `dontAsk`, `auto`, `acceptEdits`, `bypassPermissions`,
132///   `default`).
133///   Overrides the shortcut flags for the mode itself, but the
134///   allowlist (`--allow-tool` / `--writable` preset) still applies.
135/// - `--allow-tool` / profile `allow_tool` -- add specific tools or
136///   patterns (e.g. `"Bash(git status)"`).
137/// - `--deny-tool` / profile `deny_tool` -- block patterns. Not applied
138///   under `--full-auto`, which bypasses all checks before the allow/deny
139///   lists are built.
140/// - `--full-auto` -- bypass everything (overrides above).
141pub 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    // Apply the permission mode if set. FullAuto returned above, so the mode
147    // only applies on the non-bypass path; it composes with the ReadOnly /
148    // Writable posture (the allow list below still applies).
149    if let Some(mode) = config.permission_mode {
150        cmd = cmd.permission_mode(mode);
151    }
152
153    // Always-on safe defaults. ReadOnly is the default posture; either way
154    // these three are in the allow list.
155    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
178/// The built-in advisory roba injects into the agent's system prompt by
179/// default. It states roba's execution truth: the spawned agent is a
180/// single, non-interactive `claude -p` turn, not a persistent harness, so
181/// there are no cross-turn background-completion notifications. On by
182/// default; suppressed with `--no-agent-notice`, replaced with
183/// `--agent-notice` / the `agent_notice` config key. See issue #302.
184pub 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
195/// Resolve the agent-notice text to inject, honoring the disable flag and
196/// the content override. Returns `None` when the notice is suppressed
197/// (`--no-agent-notice`) or the resolved content is empty
198/// (`agent_notice = ""` -- a config-level disable).
199fn 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
214/// Compose the final `--append-system-prompt` value: the user's own
215/// append text (if any) combined with the built-in agent notice (if
216/// enabled).
217///
218/// claude-wrapper's `append_system_prompt` is a SETTER -- a second call
219/// replaces the first -- so the two pieces are joined here (blank line
220/// between) and applied once. The notice never clobbers a user's
221/// `--append-system-prompt`, and vice versa. Returns `None` when neither
222/// piece is present.
223pub 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
237/// Derive a display name from the resolved prompt for `claude
238/// --name`. Shows up in the `claude --resume` picker, the terminal
239/// title, and the prompt box. Without it, sessions roba creates
240/// are effectively invisible to the picker.
241///
242/// Shape: `roba: <first 40 chars of the first non-empty line>`. The
243/// `roba:` prefix makes sessions distinguishable from interactive
244/// Claude Code sessions in the same project's history.
245pub 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/// Outcome of resolving a user-supplied `-c <value>` against the known
265/// session ids of a project. See [`resolve_session_prefix`].
266#[derive(Debug, PartialEq, Eq)]
267pub enum Resolution {
268    /// Exactly one session id matched (a full id, or a unique prefix);
269    /// holds the full id to hand to claude's `--resume`.
270    Unique(String),
271    /// More than one known session id shares the prefix; holds the
272    /// candidates so the caller can list them in an error.
273    Ambiguous(Vec<String>),
274    /// No known session id has `input` as a prefix. The caller passes the
275    /// value through to claude UNCHANGED -- preserving claude's own
276    /// session-TITLE resume (roba sets `--name roba...`) and any full id
277    /// that belongs to a different project.
278    NoMatch,
279}
280
281/// Resolve `input` as a git-style session-id prefix against the
282/// project's known `available_ids`.
283///
284/// - unique (case-insensitive) prefix match -> `Unique(full)`;
285/// - a full UUID present in the list -> `Unique(itself)` (it is the only
286///   id with that 36-char prefix, since all ids share a length);
287/// - more than one prefix match -> `Ambiguous(candidates)`;
288/// - no prefix match (or empty input) -> `NoMatch`.
289///
290/// UUID hex is case-insensitive, so matching is too. A non-hex input (a
291/// session TITLE) won't prefix any UUID and falls through as `NoMatch`.
292///
293/// Deliberately lenient, in contrast to `--session-id`'s strict UUID
294/// validation (`parse_session_id`, #284): `--session-id` ASSIGNS a new
295/// id and must be a well-formed UUID; `-c <prefix>` RESUMES and treats
296/// the value as a convenience handle, so a non-matching value is passed
297/// on rather than rejected.
298pub 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        // A value that prefixes nothing (a different id / a title) is a
356        // pass-through, not an error.
357        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        // claude's session-TITLE resume passes a non-hex string; it must
376        // never prefix a UUID and so falls through unchanged.
377        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        // The exact form roba prints in its footer (`session ef7de917`).
387        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        // `--include-partial-messages` is streaming-only (claude rejects it on
447        // the non-streaming path). apply_session is display-flag-free -- Config
448        // carries no streaming/display flag -- so it must NEVER forward it. The
449        // streaming pipeline (run_streaming) sets it on the stream/trace path.
450        // This is the structural guarantee that the non-streaming path, which
451        // goes through apply_session only, can never receive it.
452        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    // -- agent notice (#302) -----------------------------------------------
463
464    #[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        // End-to-end: the notice reaches the QueryCommand on the default path.
532        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    // -- anonymous-worktree continue/resume guard (#328) ------------------
540
541    #[test]
542    fn anon_worktree_defeats_continue_most_recent() {
543        // Continue-most-recent + an anonymous worktree -> true.
544        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        // Resume-specific + an anonymous worktree -> true.
555        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        // Continue + a NAMED worktree (stable dir) -> false.
566        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        // Anonymous worktree with a fresh session (no continue/resume) -> false.
577        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        // Continue with no worktree -> false.
588        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        // A --session-id (WithId, a NEW session) is not a continue/resume, so
598        // even with an anonymous worktree the guard must NOT fire.
599        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        // 40 chars of CJK; truncation must use char boundaries, not byte
610        let prompt = "あ".repeat(50);
611        let name = derive_session_name(&prompt);
612        // "roba: " + 40 "あ" + "…"
613        let body = name.trim_start_matches("roba: ").trim_end_matches('…');
614        assert_eq!(body.chars().count(), 40);
615    }
616}