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