Skip to main content

agent_abstraction/
request.rs

1//! Describing a run before it happens.
2
3use std::path::{Path, PathBuf};
4use std::time::Duration;
5
6use crate::agent::{
7    Agent, Continue, EnvPolicy, Format, MAX_COMMAND_LINE, Permission, Plan, STDIN_THRESHOLD,
8};
9use crate::error::Result;
10use crate::session::{Phase, SessionStore};
11
12/// A run, described but not yet started.
13///
14/// Built fluently and then handed to [`crate::run`] or [`crate::stream`]:
15///
16/// ```no_run
17/// use agent_abstraction::{Agent, Permission, Request};
18///
19/// let request = Request::new(Agent::Claude, "summarize this repo")
20///     .model("sonnet")
21///     .permission(Permission::ReadOnly);
22/// ```
23#[derive(Debug, Clone)]
24pub struct Request {
25    pub(crate) agent: Agent,
26    pub(crate) bin: Option<String>,
27    pub(crate) prompt: String,
28    pub(crate) system: Option<String>,
29    pub(crate) model: Option<String>,
30    pub(crate) effort: Option<String>,
31    /// Whether the model may spend reasoning tokens before answering. `None`
32    /// leaves the agent's own default in place; `Some(false)` turns it off. See
33    /// [`Request::thinking`].
34    pub(crate) thinking: Option<bool>,
35    pub(crate) duplex: bool,
36    pub(crate) approvals: bool,
37    pub(crate) permission: Permission,
38    pub(crate) format: Option<Format>,
39    pub(crate) cont: Continue,
40    pub(crate) cwd: Option<PathBuf>,
41    /// Additional working roots the agent may write beside `cwd`.
42    pub(crate) extra_dirs: Vec<PathBuf>,
43    pub(crate) env: Vec<(String, String)>,
44    pub(crate) extra_args: Vec<String>,
45    pub(crate) env_policy: EnvPolicy,
46    pub(crate) schema: Option<String>,
47    /// Set by the runner for agents that read the schema from a file.
48    pub(crate) schema_file: Option<String>,
49    pub(crate) timeout: Option<Duration>,
50    /// Set when [`Request::session`] resolved a named session, so the runner
51    /// knows to write the binding back.
52    pub(crate) binding: Option<Binding>,
53    /// Set by [`Request::command`]: the prompt is a slash command, so the
54    /// capability check refuses agents that have no command vocabulary.
55    pub(crate) is_command: bool,
56}
57
58/// A named session this run is attached to.
59#[derive(Debug, Clone)]
60pub(crate) struct Binding {
61    pub(crate) store: SessionStore,
62    pub(crate) project: PathBuf,
63    pub(crate) name: String,
64    pub(crate) phase: Phase,
65    pub(crate) fork: bool,
66}
67
68impl Request {
69    /// A request for `agent` with `prompt`.
70    ///
71    /// Defaults are deliberately conservative: [`Permission::ReadOnly`],
72    /// [`EnvPolicy::Minimal`], and the agent's structured output format. Widen
73    /// them explicitly.
74    pub fn new(agent: Agent, prompt: impl Into<String>) -> Self {
75        Self {
76            agent,
77            bin: None,
78            prompt: prompt.into(),
79            system: None,
80            model: None,
81            effort: None,
82            thinking: None,
83            duplex: false,
84            approvals: false,
85            permission: Permission::ReadOnly,
86            format: None,
87            cont: Continue::New,
88            cwd: None,
89            extra_dirs: Vec::new(),
90            env: Vec::new(),
91            extra_args: Vec::new(),
92            env_policy: EnvPolicy::Minimal,
93            schema: None,
94            schema_file: None,
95            timeout: None,
96            binding: None,
97            is_command: false,
98        }
99    }
100
101    /// A run that carries a slash command instead of a prompt.
102    ///
103    /// The agent's own verbs, addressed as values: `/compact` summarises a
104    /// conversation that has grown too long to think in, `/clear` discards it.
105    /// See [`crate::Command`].
106    ///
107    /// Pair it with [`Request::session`] or [`Request::resume`]. A command with
108    /// no conversation behind it has nothing to act on: `/compact` on a fresh
109    /// session is refused, and says so.
110    ///
111    /// # A command is a turn, not an interruption
112    ///
113    /// Deliberately a constructor rather than something [`crate::Run::send`]
114    /// delivers mid-turn. Verified against claude 2.1.212: a command injected
115    /// into a running turn emits its own `result` record *after* the turn's,
116    /// which overwrites the outcome — the answer's text becomes the
117    /// compaction's empty string and the turn's usage becomes the compaction's
118    /// zeroes. As its own run the same command produces one clean terminal.
119    ///
120    /// # Reading the result
121    ///
122    /// The outcome's text is empty and `num_turns` is zero, because a
123    /// compaction generates no answer. Neither is a failure, and neither is a
124    /// refusal: [`crate::Event::Compaction`] carries whether it worked, so this
125    /// wants [`crate::stream`] rather than [`crate::run`].
126    ///
127    /// Claude only. No other agent has a command vocabulary, so both refuse
128    /// before spawning.
129    #[must_use]
130    pub fn command(agent: Agent, command: &crate::Command) -> Self {
131        let mut request = Self::new(agent, command.wire());
132        request.is_command = true;
133        request
134    }
135
136    /// Override the binary. Defaults to the agent's own name on `PATH`.
137    #[must_use]
138    pub fn bin(mut self, bin: impl Into<String>) -> Self {
139        self.bin = Some(bin.into());
140        self
141    }
142
143    /// A system prompt. Delivered by flag where the agent has one and prepended
144    /// to the prompt where it does not. It is never dropped.
145    #[must_use]
146    pub fn system(mut self, system: impl Into<String>) -> Self {
147        self.system = Some(system.into());
148        self
149    }
150
151    /// Pin the model. Passed through verbatim; this crate does not validate
152    /// model names, so an unknown one surfaces as the agent's own error.
153    #[must_use]
154    pub fn model(mut self, model: impl Into<String>) -> Self {
155        self.model = Some(model.into());
156        self
157    }
158
159    /// Set the reasoning effort level.
160    ///
161    /// Passed through verbatim, exactly like [`Request::model`] and for the same
162    /// reason: the accepted set belongs to the provider, differs between agents,
163    /// and has already grown once. [`crate::Model::efforts`] lists what each
164    /// model is known to take, and nothing here validates against it.
165    ///
166    /// Delivered as `--effort` on Claude and Copilot, and as
167    /// `-c model_reasoning_effort=<level>` on Codex, which has no flag for it.
168    #[must_use]
169    pub fn effort(mut self, effort: impl Into<String>) -> Self {
170        self.effort = Some(effort.into());
171        self
172    }
173
174    /// Turn the model's reasoning ("thinking") on or off for this run.
175    ///
176    /// Left unset the agent keeps its own default, which for Claude is adaptive
177    /// thinking. `thinking(false)` disables it; `thinking(true)` is the same as
178    /// leaving it unset and exists so a caller driven by a UI toggle can pass
179    /// the switch through without branching.
180    ///
181    /// # Only Claude has a lever here
182    ///
183    /// Delivered as `MAX_THINKING_TOKENS=0` in the child's environment, which is
184    /// exactly the switch the `claude` CLI reads to decide whether to send a
185    /// `thinking` block to the API (verified against claude 2.1.212: the gate is
186    /// `MAX_THINKING_TOKENS > 0`, so `0` omits the block). It rides the
187    /// environment rather than an argument because the CLI exposes no flag for
188    /// it, and it wins over [`EnvPolicy`] the same way an explicit
189    /// [`Request::env`] does.
190    ///
191    /// Codex and Copilot have no equivalent off switch, so `thinking(false)` is
192    /// a no-op for them rather than a silent lie. Their reasoning is steered by
193    /// [`Request::effort`] instead.
194    #[must_use]
195    pub fn thinking(mut self, enabled: bool) -> Self {
196        self.thinking = Some(enabled);
197        self
198    }
199
200    /// Keep the input channel open for the turn, so the caller can send more.
201    ///
202    /// Without this a run takes one prompt and that is the whole conversation.
203    /// With it, [`crate::Run::send`] delivers another message while the agent is
204    /// still working, which is what lets a chat UI accept a correction the
205    /// moment a user types it rather than making them wait for the turn to end.
206    ///
207    /// The agent takes the message at its next step boundary, not mid-token.
208    /// Verified against claude 2.1.212 and codex-cli 0.145.0.
209    ///
210    /// Claude and Codex support this. Codex switches from `exec` to app-server
211    /// for the interactive turn. Copilot is [`crate::Error::Unsupported`].
212    #[must_use]
213    pub fn interactive(mut self) -> Self {
214        self.duplex = true;
215        self
216    }
217
218    /// Route gated tool calls to the caller for a decision, instead of letting
219    /// the posture answer them.
220    ///
221    /// Every [`Permission`] resolves the approval question up front, which is
222    /// what lets a headless run finish unattended. This asks instead: a gated
223    /// call arrives as [`crate::Event::ApprovalRequest`] and the run waits,
224    /// mid-turn, until [`crate::Run::respond`] answers it.
225    ///
226    /// Two constraints, both raised before spawning rather than met as a hang:
227    /// this needs [`crate::stream`], since [`crate::run`] yields no events for
228    /// anyone to answer. Claude and Codex expose approval callbacks; Copilot
229    /// does not and is [`crate::Error::Unsupported`].
230    ///
231    /// [`Permission`] still applies to everything the agent does not ask about.
232    /// Agents may allow read-only commands without asking, so the absence of a
233    /// question is not proof that nothing ran.
234    #[must_use]
235    pub fn approvals(mut self) -> Self {
236        self.approvals = true;
237        self
238    }
239
240    /// Set the permission posture.
241    #[must_use]
242    pub fn permission(mut self, permission: Permission) -> Self {
243        self.permission = permission;
244        self
245    }
246
247    /// Pin the output format. Left unset, a run picks the agent's structured
248    /// format, which is also the one that carries a session id.
249    #[must_use]
250    pub fn format(mut self, format: Format) -> Self {
251        self.format = Some(format);
252        self
253    }
254
255    /// The working directory the agent runs in.
256    #[must_use]
257    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
258        self.cwd = Some(cwd.into());
259        self
260    }
261
262    /// Add another working root beside [`Request::cwd`].
263    ///
264    /// Claude and `codex exec` receive `--add-dir`. Interactive Codex runs use
265    /// the same path as an app-server runtime root and workspace-write root, so
266    /// the access described here survives transport changes without a caller
267    /// assembling provider-specific arguments.
268    #[must_use]
269    pub fn add_dir(mut self, dir: impl Into<PathBuf>) -> Self {
270        self.extra_dirs.push(dir.into());
271        self
272    }
273
274    /// Set an environment variable for the child. Repeatable.
275    #[must_use]
276    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
277        self.env.push((key.into(), value.into()));
278        self
279    }
280
281    /// Choose which of the host's environment variables reach the agent.
282    ///
283    /// Defaults to [`EnvPolicy::Minimal`], which passes through only what the
284    /// selected agent needs. Reach for [`EnvPolicy::Inherit`] when the host
285    /// holds nothing the agent should not see, or when something this crate
286    /// does not know about has to reach the CLI.
287    ///
288    /// ```no_run
289    /// # use agent_abstraction::{Agent, EnvPolicy, Request};
290    /// let request = Request::new(Agent::Claude, "review this")
291    ///     .env_policy(EnvPolicy::Inherit);
292    /// ```
293    #[must_use]
294    pub fn env_policy(mut self, policy: EnvPolicy) -> Self {
295        self.env_policy = policy;
296        self
297    }
298
299    /// Kill the run if it has not finished within `timeout`.
300    #[must_use]
301    pub fn timeout(mut self, timeout: Duration) -> Self {
302        self.timeout = Some(timeout);
303        self
304    }
305
306    /// Append raw arguments after everything this crate builds.
307    ///
308    /// The escape hatch for agent-specific flags with no unified spelling.
309    ///
310    /// **This voids the crate's guarantees.** Arguments land after the generated
311    /// ones, so they can contradict [`Request::permission`], redirect the output
312    /// format the parser expects, or point the run at a different session.
313    /// Codex's `-c key=value` in particular can rewrite sandbox and approval
314    /// policy for the invocation. Nothing here is validated, and a security
315    /// review of the permission posture means little without also reviewing
316    /// whatever is passed here.
317    ///
318    /// Arguments are passed straight to the binary without a shell.
319    #[must_use]
320    pub fn unchecked_args<I, S>(mut self, args: I) -> Self
321    where
322        I: IntoIterator<Item = S>,
323        S: Into<String>,
324    {
325        self.extra_args.extend(args.into_iter().map(Into::into));
326        self
327    }
328
329    /// Constrain the answer to a JSON Schema.
330    ///
331    /// The agent is asked to return a value conforming to `schema`, which
332    /// [`Outcome::structured`] then carries already parsed. Useful when the
333    /// answer is data rather than prose: a set of review findings, an
334    /// extraction, a classification. Reading it beats parsing prose, which is
335    /// a guess about formatting the model never promised.
336    ///
337    /// The two CLIs that support this take it differently, and the difference
338    /// is hidden: Claude accepts the schema inline, Codex reads it from a file
339    /// this crate writes for the run and removes afterwards. **Copilot 1.0.78
340    /// has no schema support**, so asking is [`crate::Error::Unsupported`]
341    /// rather than a prose answer presented as data.
342    ///
343    /// The schema is passed through unvalidated; a malformed one surfaces as
344    /// the agent's own error.
345    ///
346    /// # Write the schema strictly
347    ///
348    /// Codex sends it to `OpenAI`'s structured-output API, which rejects anything
349    /// permissive. Every object needs `"additionalProperties": false` and every
350    /// property listed in `required`, or the request fails with a 400 before
351    /// the model runs:
352    ///
353    /// ```text
354    /// 'additionalProperties' is required to be supplied and to be false
355    /// ```
356    ///
357    /// Claude is more forgiving, so a schema that works there can still fail on
358    /// Codex. Writing to the stricter rule keeps one schema usable for both.
359    ///
360    /// [`Outcome::structured`]: crate::Outcome::structured
361    #[must_use]
362    pub fn schema(mut self, schema: impl Into<String>) -> Self {
363        self.schema = Some(schema.into());
364        self
365    }
366
367    /// Continue an earlier conversation by its native id, bypassing the session
368    /// store. Prefer [`Request::session`] unless you are tracking ids yourself.
369    #[must_use]
370    pub fn resume(mut self, id: impl Into<String>) -> Self {
371        self.cont = Continue::Resume(id.into());
372        self
373    }
374
375    /// Start a **new** conversation under an id you choose, rather than one the
376    /// agent picks.
377    ///
378    /// Useful when a host already has its own identifier for a thread and wants
379    /// the agent's session to match it, with no mapping table in between. The
380    /// id is known before the process starts, so the association survives a run
381    /// that dies mid-turn.
382    ///
383    /// Only Claude and Copilot accept an assigned id
384    /// ([`SessionSupport::Minted`]). Codex reveals its `thread_id` only in its
385    /// own output, so this is [`crate::Error::Unsupported`] for it, raised when
386    /// the argv is built rather than silently starting an unrelated session.
387    ///
388    /// Both CLIs require a valid UUID here; this crate passes the string through
389    /// without checking, so a non-UUID surfaces as the agent's own error.
390    ///
391    /// [`SessionSupport::Minted`]: crate::SessionSupport::Minted
392    #[must_use]
393    pub fn session_id(mut self, id: impl Into<String>) -> Self {
394        self.cont = Continue::NewWith(id.into());
395        self
396    }
397
398    /// Attach this run to a caller-owned session name.
399    ///
400    /// The store decides whether this turn creates, continues, or forks, and the
401    /// binding is written back once the run yields an id. `fork` branches a new
402    /// conversation off the stored one instead of appending to it.
403    ///
404    /// The store is cloned into the request so the run can write the binding
405    /// back without borrowing it. That clone is a [`PathBuf`], not the sessions
406    /// themselves: records are read and written on demand and never held in
407    /// memory, so this stays cheap however many sessions exist.
408    ///
409    /// # Errors
410    /// [`crate::Error::SessionConflict`] if the name belongs to another agent,
411    /// or [`crate::Error::Unsupported`] if this agent cannot fork or has no
412    /// session id at all.
413    pub fn session(
414        mut self,
415        store: &SessionStore,
416        project: impl AsRef<Path>,
417        name: impl Into<String>,
418        fork: bool,
419    ) -> Result<Self> {
420        let project = project.as_ref().to_path_buf();
421        let name = name.into();
422        let (phase, cont) = store.plan(self.agent, &project, &name, fork)?;
423        self.cont = cont;
424        self.binding = Some(Binding {
425            store: store.clone(),
426            project,
427            name,
428            phase,
429            fork,
430        });
431        // A named session needs an id back. The default format carries one, so
432        // this only has to refuse a format the caller pinned that cannot:
433        // otherwise the run would succeed and then silently fail to bind.
434        //
435        // Deliberately no longer *sets* the format. Doing so overrode the
436        // caller's streaming intent, which is how a named session, the case a
437        // chat UI always uses, ended up unable to stream.
438        if let Some(format) = self.format
439            && !self.agent.format_carries_session(format)
440        {
441            return Err(crate::Error::Unsupported {
442                agent: self.agent,
443                what: "a named session under an output format that carries no session id",
444            });
445        }
446        Ok(self)
447    }
448
449    /// Roughly how many bytes of command line this request needs.
450    ///
451    /// Only the caller-supplied text is counted; the flags themselves are a
452    /// bounded handful of short literals. Used to decide whether the prompt has
453    /// to move to stdin.
454    fn argv_weight(&self) -> usize {
455        self.prompt.len()
456            + self.system.as_ref().map_or(0, String::len)
457            + self.extra_args.iter().map(String::len).sum::<usize>()
458            // Claude's schema rides the command line too.
459            + self.schema.as_ref().map_or(0, String::len)
460    }
461
462    /// The format this request will actually use.
463    #[must_use]
464    pub fn effective_format(&self) -> Format {
465        self.format.unwrap_or_default()
466    }
467
468    /// Whether this turn opens, continues, or branches its named session.
469    /// `None` when the request is not attached to one.
470    ///
471    /// Known before the run starts, so a UI can label the turn up front.
472    #[must_use]
473    pub fn session_phase(&self) -> Option<Phase> {
474        self.binding.as_ref().map(|b| b.phase)
475    }
476
477    /// Freeze the request into the [`Plan`] an argv is built from.
478    ///
479    /// Crate-internal: `Plan` is how the crate works, not what it promises, and
480    /// a caller that wants to see the command line should use
481    /// [`Request::argv`].
482    #[must_use]
483    pub(crate) fn plan(&self) -> Plan {
484        Plan {
485            bin: self
486                .bin
487                .clone()
488                .unwrap_or_else(|| self.agent.bin().to_string()),
489            prompt: self.prompt.clone(),
490            system: self.system.clone(),
491            model: self.model.clone(),
492            effort: self.effort.clone(),
493            thinking: self.thinking,
494            duplex: self.duplex || self.approvals,
495            approvals: self.approvals,
496            permission: self.permission,
497            format: self.effective_format(),
498            cont: self.cont.clone(),
499            extra_dirs: self
500                .extra_dirs
501                .iter()
502                .map(|path| path.to_string_lossy().into_owned())
503                .collect(),
504            // Measure the whole command line, not just the prompt: for Codex
505            // and Copilot the system text is prepended to it, and for Claude the
506            // system prompt rides its own argument. A small prompt with a large
507            // system prompt would otherwise still hit E2BIG.
508            stdin_prompt: self.argv_weight() >= STDIN_THRESHOLD,
509            schema: self.schema.clone(),
510            schema_file: self.schema_file.clone(),
511            is_command: self.is_command,
512        }
513    }
514
515    /// The full command line, for logging or for showing a user exactly what
516    /// will run before they approve it.
517    ///
518    /// # Errors
519    /// [`crate::Error::Unsupported`] if the agent cannot honour this request.
520    pub fn argv(&self) -> Result<Vec<String>> {
521        Ok(self
522            .typed_argv()?
523            .into_iter()
524            .map(|arg| arg.value)
525            .collect())
526    }
527
528    /// The command line with per-argument sensitivity, the single source both
529    /// the executable and the redacted forms are derived from.
530    pub(crate) fn typed_argv(&self) -> Result<Vec<crate::agent::Arg>> {
531        use crate::agent::{Arg, Sensitivity};
532
533        let plan = self.plan();
534        let mut argv = self.agent.typed_argv(&plan)?;
535        // Raw arguments have no known shape, so they are assumed to carry
536        // secrets rather than assumed not to.
537        argv.extend(self.extra_args.iter().map(|value| Arg {
538            value: value.clone(),
539            sensitivity: Sensitivity::Unchecked,
540        }));
541
542        // Moving the prompt to stdin does not move anything else: Claude keeps
543        // the system prompt on its own argument, and raw arguments are always on
544        // the line, so a small prompt with a large system prompt still
545        // overflows. Name the culprit rather than letting the OS answer E2BIG.
546        let total: usize = argv.iter().map(|a| a.value.len()).sum();
547        if total > MAX_COMMAND_LINE {
548            let system = self.system.as_ref().map_or(0, String::len);
549            let extra: usize = self.extra_args.iter().map(String::len).sum();
550            let prompt = if plan.stdin_prompt {
551                0
552            } else {
553                self.prompt.len()
554            };
555            let (what, size) = if system >= extra && system >= prompt {
556                ("the system prompt", system)
557            } else if extra >= prompt {
558                ("the unchecked arguments", extra)
559            } else {
560                ("the prompt", prompt)
561            };
562            return Err(crate::Error::CommandLineTooLarge {
563                agent: self.agent,
564                what,
565                size,
566                limit: MAX_COMMAND_LINE,
567            });
568        }
569        Ok(argv)
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    /// The defaults are the safe posture, so a caller who configures nothing
578    /// does not get the permissive one by accident.
579    #[test]
580    fn defaults_are_read_only_isolated_and_structured() {
581        let request = Request::new(Agent::Claude, "hi");
582        assert_eq!(request.permission, Permission::ReadOnly);
583        assert_eq!(
584            request.env_policy,
585            EnvPolicy::Minimal,
586            "full environment inheritance must be an explicit decision"
587        );
588        assert_eq!(
589            request.effective_format(),
590            Format::Stream,
591            "the default must be watchable: Json reports nothing until the turn ends"
592        );
593        let argv = request.argv().unwrap();
594        assert!(argv.contains(&"--disallowedTools".to_string()));
595    }
596
597    #[test]
598    fn extra_args_land_after_everything_the_crate_builds() {
599        let argv = Request::new(Agent::Claude, "hi")
600            .unchecked_args(["--add-dir", "/tmp/extra"])
601            .argv()
602            .unwrap();
603        assert_eq!(argv[argv.len() - 2..], ["--add-dir", "/tmp/extra"]);
604    }
605
606    #[test]
607    fn a_large_prompt_moves_to_stdin() {
608        let big = "x".repeat(STDIN_THRESHOLD + 1);
609        let plan = Request::new(Agent::Claude, big.clone()).plan();
610        assert!(plan.stdin_prompt);
611        let argv = Request::new(Agent::Claude, big).argv().unwrap();
612        assert!(
613            !argv.iter().any(|a| a.len() > STDIN_THRESHOLD),
614            "a large prompt must not ride the argv"
615        );
616    }
617
618    /// Moving the prompt to stdin does not move the system prompt, so a small
619    /// prompt with a huge system prompt still overflows the command line. The
620    /// OS would answer `E2BIG` naming nothing; this names the culprit.
621    #[test]
622    fn an_oversized_system_prompt_is_reported_rather_than_left_to_e2big() {
623        let err = Request::new(Agent::Claude, "tiny")
624            .system("s".repeat(MAX_COMMAND_LINE + 1))
625            .argv()
626            .unwrap_err();
627        let crate::Error::CommandLineTooLarge { what, .. } = err else {
628            panic!("expected CommandLineTooLarge, got {err:?}")
629        };
630        assert_eq!(what, "the system prompt");
631    }
632
633    #[test]
634    fn oversized_unchecked_arguments_are_named_too() {
635        let err = Request::new(Agent::Claude, "tiny")
636            .unchecked_args([format!("--x={}", "y".repeat(MAX_COMMAND_LINE))])
637            .argv()
638            .unwrap_err();
639        assert!(matches!(
640            err,
641            crate::Error::CommandLineTooLarge {
642                what: "the unchecked arguments",
643                ..
644            }
645        ));
646    }
647
648    #[test]
649    fn a_small_prompt_stays_on_the_argv() {
650        assert!(!Request::new(Agent::Claude, "hi").plan().stdin_prompt);
651    }
652
653    #[test]
654    fn a_named_session_selects_a_format_that_carries_an_id() {
655        let dir = std::env::temp_dir().join(format!("aa-req-{}", std::process::id()));
656        let store = SessionStore::open(&dir);
657        let request = Request::new(Agent::Claude, "hi")
658            .session(&store, "/proj", "chat", false)
659            .unwrap();
660        assert_eq!(
661            request.effective_format(),
662            Format::Stream,
663            "the default must be watchable: Json reports nothing until the turn ends"
664        );
665
666        // An explicit format is respected over the automatic upgrade.
667        let pinned = Request::new(Agent::Claude, "hi")
668            .format(Format::Stream)
669            .session(&store, "/proj", "chat2", false)
670            .unwrap();
671        assert_eq!(pinned.effective_format(), Format::Stream);
672        std::fs::remove_dir_all(&dir).ok();
673    }
674
675    #[test]
676    fn resume_bypasses_the_store() {
677        let argv = Request::new(Agent::Claude, "hi")
678            .resume("sess-9")
679            .argv()
680            .unwrap();
681        let at = argv.iter().position(|a| a == "--resume").unwrap();
682        assert_eq!(argv[at + 1], "sess-9");
683    }
684}