basis/run.rs
1//! One prompt against one workspace: the smallest complete thing basis does.
2//!
3//! This is the P1 acceptance surface from `docs/ARCHITECTURE.md` §6 —
4//! arbitrary prompts on arbitrary repos, in-process and as a subprocess. The
5//! binary is a thin shell over [`run`]; a Rust host calls it directly.
6//!
7//! Nothing here knows what the prompt is for. The mission arrives as data: the
8//! prompt itself, the workspace's own context files, and configuration.
9//!
10//! [`run`] answers the whole question — build a runtime, resolve a model, send
11//! the prompt — for the case where one prompt is the whole job. Everything in
12//! this module is a wrapper around [`Workspace`]: it opens
13//! one, mints a single run from it, and drops it when the run ends. A host
14//! sending more than one prompt at a repository should open the workspace
15//! itself and keep it, which is what the split of ADR-0010 is for.
16//!
17//! A host that already owns a mentra runtime skips to [`prepare_with_session`]
18//! and keeps its own.
19
20mod bounds;
21mod output;
22mod prepared;
23mod sink;
24mod turn;
25mod usage;
26
27use std::{
28 path::{Path, PathBuf},
29 sync::Arc,
30};
31
32use mentra::Session;
33
34use crate::{
35 approval::{AllowAll, Approver},
36 context::{ContextConfig, WorkspaceContext},
37 event::RunOutcome,
38 templates::TemplatesConfig,
39 workspace::{DEFAULT_SESSION_NAME, RunSpec, Workspace, WorkspaceBuilder, load_templates},
40};
41
42pub use bounds::Bounds;
43pub use output::{
44 OutputAttempt, OutputAttemptReport, OutputDecision, OutputFailure, OutputReport,
45 OutputReservation, OutputSpec,
46};
47pub use prepared::BindingSource;
48pub use prepared::{
49 AgentEventTapGuard, Compacted, LoadedSkill, PreparedRun, PromptPart, RunContext,
50};
51pub use sink::{
52 CollectingSink, EventFanIn, EventSink, FnSink, MergedEvents, NullSink, TaggedEvent, TaggedSink,
53};
54pub use turn::TurnOptions;
55pub use usage::RunUsage;
56
57/// Recoverability classification for a retained [`RunFailure`].
58pub use mentra::error::ErrorCategory as RunFailureCategory;
59/// Mentra's original typed terminal failure retained by [`RunReport`].
60pub use mentra::error::RuntimeError as RunFailure;
61
62/// The signal a caller trips to stop a turn.
63///
64/// Re-exported rather than restated, and the reason is the one thing basis cannot
65/// wrap: a token is an *identity*, not a value. The turn holds one half and the
66/// caller the other, and a basis-owned copy would have to forward the trip to
67/// mentra's — a second object that can disagree with the first about whether
68/// the stop button was pressed. So this is a deliberate leak, like
69/// [`ModelSelector`](mentra::ModelSelector) and
70/// [`BuiltinProvider`](mentra::BuiltinProvider) on
71/// [`RuntimeBuilder`](crate::RuntimeBuilder).
72///
73/// Re-exporting it is what makes the leak cheap. A host embedding `basis`
74/// should not have to add mentra to its own manifest — and pin the same
75/// version — to name a type basis's own API asks it for; a skew there fails to
76/// compile with no hint that two crates disagree about one struct. Hence the
77/// rule: every mentra type basis's surface makes a caller *name*, basis re-exports.
78///
79/// Two of them go on a turn and they mean different things —
80/// [`TurnOptions::cancel`] abandons it, [`TurnOptions::stop`] ends it
81/// gracefully.
82pub use mentra::runtime::CancellationToken;
83
84/// What a turn tells its tools about who it works for — set on
85/// [`TurnOptions::locals`], read back with
86/// [`ToolContext::run_local`](crate::tools::ToolContext::run_local)
87/// (ADR-0044). Re-exported under the rule stated on [`CancellationToken`].
88pub use mentra::runtime::RunLocals;
89
90/// How much provider-generated reasoning summary to request.
91///
92/// Re-exported because [`ReasoningOptions::summary`] makes a caller name it;
93/// like the other complete reasoning types below, a Basis caller should not
94/// need a separately version-pinned Mentra dependency to construct it.
95pub use mentra::provider_core::ReasoningSummary;
96
97/// The per-turn steering seam, whole: the trait a host implements and every
98/// type its signatures make that host *name*.
99///
100/// Re-exported rather than wrapped, under the rule stated on
101/// [`CancellationToken`] — a mentra type basis's surface asks a caller to
102/// write is a name basis re-exports, or the caller pins mentra in their own
103/// manifest to spell it. And like the token, a strategy is an *identity*
104/// rather than a value: [`TurnOptions::with_round_strategy`] hands mentra the
105/// caller's own `Arc`, so a basis-owned mirror of [`RoundDecision`] would be a
106/// translation layer with opinions of its own — the opposite of a seam.
107///
108/// The set is exactly what implementing costs. [`RoundStrategy`] is the trait
109/// (spelled with [`async_trait`](crate::async_trait), which basis already
110/// re-exports); [`RoundContext`] and [`RoundBoundary`] are its question;
111/// [`RoundDecision`], [`RoundAdjustment`], [`ReasoningChange`] and
112/// [`RoundToolResult`] are its vocabulary for answering. The last three are
113/// here because the vocabulary's own signatures demand them:
114/// [`RoundDecision::inject`] takes [`ContentBlock`]s,
115/// [`RoundAdjustment::with_model`] a [`ModelInfo`], and
116/// [`ReasoningChange::Set`] a [`ReasoningOptions`] — whose `effort` field an
117/// [`Effort`] converts into, so switching effort mid-run never names mentra's
118/// own level enum.
119pub use mentra::{
120 ContentBlock, ModelInfo, ReasoningChange, ReasoningOptions, RoundAdjustment, RoundBoundary,
121 RoundContext, RoundDecision, RoundStrategy, RoundToolResult,
122};
123
124/// Mentra's complete provider-neutral agent event, forwarded unchanged by
125/// [`PreparedRun::register_agent_event_tap`].
126///
127/// Re-exported under the same rule as [`CancellationToken`]: this public
128/// callback asks a Basis host to name the type, so the host should not need a
129/// separately version-pinned Mentra dependency just to spell its signature.
130pub use mentra::agent::AgentEvent;
131
132/// How hard the model should think before answering.
133///
134/// basis's own enum rather than a re-export, for the reason [`Event`](crate::Event) and
135/// [`TurnOptions`] are: the surface basis promises should not move when mentra's
136/// does. Provider adapters translate this semantic level to their own wire
137/// format. A provider or model that does not offer the requested level returns
138/// an error rather than silently lowering it.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
140#[serde(rename_all = "lowercase")]
141#[non_exhaustive]
142pub enum Effort {
143 Low,
144 Medium,
145 High,
146 XHigh,
147 Max,
148}
149
150impl From<Effort> for mentra::provider::ReasoningEffort {
151 fn from(effort: Effort) -> Self {
152 match effort {
153 Effort::Low => Self::Low,
154 Effort::Medium => Self::Medium,
155 Effort::High => Self::High,
156 Effort::XHigh => Self::XHigh,
157 Effort::Max => Self::Max,
158 }
159 }
160}
161
162impl Effort {
163 /// The stable string id basis writes for this level.
164 pub const fn type_tag(self) -> &'static str {
165 match self {
166 Self::Low => "low",
167 Self::Medium => "medium",
168 Self::High => "high",
169 Self::XHigh => "xhigh",
170 Self::Max => "max",
171 }
172 }
173}
174
175/// The level a session is set to, read back as basis names it.
176///
177/// The direction [`PreparedRun::effort`](crate::PreparedRun::effort) needs, and
178/// it is a `TryFrom` because mentra's enum is `#[non_exhaustive]` too: a level
179/// added upstream that basis has no name for is not a level basis can report,
180/// and answering `Low` — or `None`, which means *no level requested* — would
181/// both be claims about a session that are simply untrue. The error carries the
182/// mentra value so a caller that wants to render something can.
183impl TryFrom<mentra::provider::ReasoningEffort> for Effort {
184 type Error = mentra::provider::ReasoningEffort;
185
186 fn try_from(effort: mentra::provider::ReasoningEffort) -> Result<Self, Self::Error> {
187 use mentra::provider::ReasoningEffort as Upstream;
188
189 match effort {
190 Upstream::Low => Ok(Self::Low),
191 Upstream::Medium => Ok(Self::Medium),
192 Upstream::High => Ok(Self::High),
193 Upstream::XHigh => Ok(Self::XHigh),
194 Upstream::Max => Ok(Self::Max),
195 unknown => Err(unknown),
196 }
197 }
198}
199
200/// A bound that ended a run before its work did.
201///
202/// Separate from [`RunOutcome`] because the two answer different questions.
203/// "The model ran out of the time you gave it" and "the provider refused the
204/// request" call for different reactions, and a caller — the CLI's exit code,
205/// or a script driving many runs — should not have to read an error message to
206/// tell them apart (ADR-0015). The outcome says whether an answer arrived; this
207/// says whether an allowance is what ended the run, and the two are
208/// independent. [`Deadline`](Self::Deadline) and [`ToolBudget`](Self::ToolBudget)
209/// always arrive alongside [`RunOutcome::Error`], because no final message
210/// does; [`TokenBudget`](Self::TokenBudget) can arrive on a run that answered.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
212#[serde(rename_all = "snake_case")]
213#[non_exhaustive]
214pub enum Bound {
215 /// [`RunSpec::with_deadline`] — the run took longer than it was given.
216 Deadline,
217 /// [`RunSpec::with_tool_budget`] — the run made all the calls it had.
218 ToolBudget,
219 /// [`RunSpec::with_token_budget`], or a [`BudgetPool`](crate::BudgetPool)
220 /// the run drew dry — it spent its allowance and was refused another round.
221 ///
222 /// The odd one out, and worth knowing why before branching on it. This
223 /// bound is *graceful*: mentra ends the run at a round boundary keeping
224 /// everything committed so far, exactly as if the model had finished. So a
225 /// run can report [`RunOutcome::Ok`] with an ordinary answer *and* this
226 /// bound, which is the honest description of "you got an answer, and the
227 /// allowance is why there is not more of one". Whether an answer arrives
228 /// comes down to what the last committed message was: prose, and the turn
229 /// succeeds; a tool result, and it fails owing a final message it never
230 /// got.
231 ///
232 /// Reportable at all only because mentra records the decision at the
233 /// boundary it makes it
234 /// ([`RunOptions::ended_early`](mentra::runtime::RunOptions::ended_early)).
235 /// Comparing usage against the budget afterwards would answer a different
236 /// question — what is true now, rather than what the runner decided on —
237 /// and a pooled run can cross the line without being the run that was
238 /// stopped by it.
239 TokenBudget,
240}
241
242impl Bound {
243 /// The stable string id basis writes for this bound.
244 pub const fn type_tag(self) -> &'static str {
245 match self {
246 Self::Deadline => "deadline",
247 Self::ToolBudget => "tool_budget",
248 Self::TokenBudget => "token_budget",
249 }
250 }
251
252 /// Reads a stable string id written by [`Self::type_tag`].
253 pub fn from_type_tag(tag: &str) -> Option<Self> {
254 match tag {
255 "deadline" => Some(Self::Deadline),
256 "tool_budget" => Some(Self::ToolBudget),
257 "token_budget" => Some(Self::TokenBudget),
258 _ => None,
259 }
260 }
261}
262
263/// What a completed run produced, alongside the sink it wrote to.
264#[derive(Debug)]
265pub struct RunReport<S> {
266 pub session_id: String,
267 pub model: String,
268 pub provider: String,
269 /// The assistant's final message, absent when the run failed — and absent
270 /// on a typed turn, where the answer is
271 /// [`OutputReport::value`] rather than prose.
272 pub final_message: Option<String>,
273 pub outcome: RunOutcome,
274 /// Original typed runtime failure, before [`RunOutcome`] display/wire
275 /// projection. `None` on success and on Basis-owned output-shape mismatch.
276 ///
277 /// Also `None` on a failed typed turn ([`PreparedRun::output`]), where the
278 /// same error is the [`OutputFailure::error`] the report arrives beside: a
279 /// `RunFailure` is not `Clone` and cannot be in both places, and the error
280 /// is the half a caller reaching for `?` gets. The validated path
281 /// ([`PreparedRun::output_parts_validated_with_options`]) returns `Ok`, so
282 /// there this field is the only home and keeps it (ADR-0024 §4).
283 pub failure: Option<RunFailure>,
284 /// Which bound ended the run, when one did rather than the work.
285 ///
286 /// Neither field implies the other: a bounded run usually failed for want
287 /// of a final message, but see [`Bound::TokenBudget`], which a run that
288 /// answered can carry.
289 pub stopped_by: Option<Bound>,
290 /// What the run reported spending. Present whether it succeeded or not: a
291 /// turn that failed on its fourth round still spent the first three.
292 pub usage: RunUsage,
293 pub sink: S,
294}
295
296impl<S> RunReport<S> {
297 pub fn succeeded(&self) -> bool {
298 matches!(self.outcome, RunOutcome::Ok)
299 }
300}
301
302pub use crate::error::RunError;
303
304/// Runs one prompt to completion, streaming events into `sink`.
305///
306/// Consequential calls are approved by [`AllowAll`], which is
307/// what a headless run needs: there is nobody to ask, and a question nothing
308/// answers is a hang. It asserts nothing about the run being confined — with
309/// commands on by default (ADR-0013) an unattended run carries its user's full
310/// authority, so an *attended* one is usually better served by
311/// [`run_with_approver`], and anything that needs a real boundary gets it from
312/// the OS.
313///
314/// A setup failure — no credential, unreachable model, unreadable workspace —
315/// is an `Err`. A failure *during* the turn is reported as
316/// [`RunOutcome::Error`] on an otherwise complete stream, because by then the
317/// events already emitted are worth keeping.
318///
319/// The session is dropped when this returns, and so is the workspace opened to
320/// hold it. For a conversation, open the [`Workspace`] yourself and keep the
321/// run it mints — [`send_with_options`](PreparedRun::send_with_options) is what
322/// makes it one; for many
323/// conversations, keep the workspace. Anything beyond a path and a prompt —
324/// a model, an endpoint, a bound — is the same shape one call earlier:
325/// `Workspace::builder(path)` and a [`RunSpec`].
326pub async fn run<S: EventSink>(
327 workspace: impl Into<PathBuf>,
328 prompt: impl Into<String>,
329 sink: S,
330) -> Result<RunReport<S>, RunError> {
331 run_with_approver(workspace, prompt, sink, AllowAll).await
332}
333
334/// Runs one prompt, putting every consequential call to `approver`.
335///
336/// The approver is the whole of basis's approval story (ADR-0010):
337/// [`DenyAll`](crate::approval::DenyAll) for a run that may change nothing,
338/// the binary's terminal prompter for a person at a TTY, or a host's own — one
339/// that allows edits and denies the network, or asks a team over Slack. Note
340/// the contract it inherits: an approver that cannot answer must deny.
341pub async fn run_with_approver<S: EventSink, A: Approver>(
342 workspace: impl Into<PathBuf>,
343 prompt: impl Into<String>,
344 sink: S,
345 approver: A,
346) -> Result<RunReport<S>, RunError> {
347 let prompt = prompt.into();
348 if prompt.trim().is_empty() {
349 return Err(RunError::EmptyPrompt);
350 }
351
352 let mut run = mint_carrying_workspace(Workspace::builder(workspace.into()), |workspace| {
353 workspace.prepare(prompt)
354 })
355 .await?;
356
357 run.execute_with_approver(sink, approver).await
358}
359
360/// Opens the builder and mints one run that carries the workspace.
361///
362/// The one resolution path for every free function above, and the load-bearing
363/// half is the carry: these functions hand back a [`PreparedRun`] and nothing
364/// else, so the run must be what keeps the workspace alive until the run ends
365/// — the module's own promise. A workspace dropped when this returns would
366/// take its hook registrations and MCP connections with it *before the first
367/// turn is driven*: dropping a live hook registration is what deregisters it,
368/// so every `.basis/hooks.json` hook would be silently bypassed, and the
369/// minted roster would offer `mcp__*` tools whose servers were already torn
370/// down. See [`PreparedRun::with_workspace`].
371async fn mint_carrying_workspace(
372 builder: WorkspaceBuilder,
373 mint: impl FnOnce(&Workspace) -> Result<PreparedRun, RunError>,
374) -> Result<PreparedRun, RunError> {
375 let workspace = Arc::new(builder.open().await?);
376 let prepared = mint(&workspace)?;
377
378 Ok(prepared.with_workspace(workspace))
379}
380
381/// Prepares a run against a session the caller already built, so a host with
382/// its own runtime — custom tools, its own store, a provider basis does not
383/// know — still gets basis's context discovery and event stream.
384///
385/// The inputs are exactly what this path can honor, and every field of `spec`
386/// is honored. The prompt may be empty, because once a session outlives a
387/// turn a conversation with nothing said yet is a real state — it is what
388/// ACP's `session/new` opens — so the check belongs where a prompt is
389/// actually sent, which is [`PreparedRun::execute`] and [`PreparedRun::send`].
390/// The bounds become the run's configured limits; an `effort` is applied to
391/// the session in hand; a `session_name` renames it — unless it is the
392/// default, which is left alone, because the caller named the session at mint
393/// and asking for the default and saying nothing are the same request.
394/// `context` says where discovery looks; templates are discovered with the
395/// default configuration, there being no caller that has ever wanted
396/// otherwise on this path.
397///
398/// This is the one path that does not go through
399/// [`Workspace`], because there is no runtime for basis to
400/// build: the caller brought one. It still discovers what it can without
401/// touching that runtime — skills and MCP registration stay the caller's, who
402/// owns the runtime they would register on.
403///
404/// **`#[doc(hidden)]`, not private.** This existed because `Workspace` could
405/// not yet be handed a runtime, a provider, a tool roster, or a round
406/// strategy — every knob a bring-your-own-runtime caller actually wanted was
407/// missing from the assembler, so bypassing it was the only way to get one.
408/// The assembler is open now: [`WorkspaceBuilder::with_runtime`] and
409/// [`with_runtime_builder`](WorkspaceBuilder::with_runtime_builder) accept a
410/// caller's own [`Runtime`](crate::Runtime) or recipe (ADR-0018, including a
411/// [`RuntimeBuilder::with_provider_instance`](crate::RuntimeBuilder::with_provider_instance)
412/// the host constructed itself), [`WorkspaceBuilder::with_tool_roster`] states
413/// the model's roster (D3), and [`TurnOptions::with_round_strategy`] reaches a
414/// live turn. Every caller with a real host to write against should reach for
415/// those instead — the discovery this function does by hand
416/// (`WorkspaceContext::discover_with`, default-only templates, no skills, no
417/// MCP) is exactly what `WorkspaceBuilder::open` already does more completely,
418/// once a workspace exists to open. What remains behind this name is the test
419/// harness this crate's own suite drives sessions through — `basis-cli`'s
420/// bridge tests among them — where a scripted [`Session`] needs basis's event
421/// stream and context discovery without a real runtime underneath it, and the
422/// signature this wave found already fits every caller that still needs it.
423///
424/// **A caller-built runtime with no `ToolAuthorizer` enforces nothing.** basis
425/// installs its [`ApprovalGate`](crate::approval::ApprovalGate) only on
426/// runtimes it builds; this path takes the session as given. Since mentra
427/// 0.26 a session with no authorizer allows every call *before* remembered
428/// rules are read — so a rule seeded through
429/// `run.session().permission_handle()`, a deny included, is silently ignored
430/// here unless the caller put an authorizer on its own runtime
431/// (`with_tool_authorizer`) or on the run
432/// ([`PreparedRun::with_tool_authorizer`](PreparedRun::with_tool_authorizer)).
433/// On 0.25 the rule store was consulted even with no authorizer; that ordering
434/// is gone, and this seam inherits mentra's.
435///
436/// A run handed to an adapter may not stay that way: `basis-acp` installs its
437/// own mode gate on every session it opens, so a source that reaches ACP
438/// through this path gets approval enforcement whether or not its runtime
439/// carries an authorizer — and a client that never answers a permission
440/// request is then a turn that never ends.
441///
442/// **No workspace's hooks apply here either, whatever `workspace` says.** The
443/// path is discovered against for context and templates; it does not enrol the
444/// session in any open [`Workspace`]'s guards. A workspace registers its
445/// `.basis/hooks.json` chain for its own tool audience, and a session created
446/// outside `Workspace::prepare`/`resume` has no audience — so a deny hook
447/// guarding that directory is not consulted for these calls, and neither is
448/// that workspace's `RuntimePolicy`. What *does* apply is every
449/// [`RuntimeBuilder::with_interceptor`](crate::RuntimeBuilder::with_interceptor)
450/// guard on the runtime the session was created on, which is global by
451/// construction — see [`Workspace`]'s own docs for the whole of this boundary.
452#[doc(hidden)]
453pub fn prepare_with_session(
454 session: Session,
455 workspace: &Path,
456 spec: impl Into<RunSpec>,
457 context: &ContextConfig,
458 provider: impl Into<String>,
459 model: impl Into<String>,
460) -> Result<PreparedRun, RunError> {
461 let spec = spec.into();
462 let mut session = session;
463 if spec.session_name != DEFAULT_SESSION_NAME {
464 session.set_name(spec.session_name.clone())?;
465 }
466
467 // The one resolution, for the same reason
468 // [`WorkspaceBuilder::open`](crate::WorkspaceBuilder::open) has one: this
469 // is the other path that reports a workspace, and everything below names
470 // it through this value. Resolving inside discovery and leaving the
471 // caller's spelling to the rest would put a header's `workspace` and its
472 // `templates_dirs` on two different directories for any relative or
473 // symlinked path — the same split, in the one place it survived.
474 let workspace = crate::context::resolve_workspace(workspace)?;
475
476 let discovered = WorkspaceContext::discover_with(&workspace, context)?;
477 // Unlike skills, templates are registered on nothing — so basis can discover
478 // them here without touching a runtime it does not own.
479 let (templates_dirs, templates) = load_templates(&workspace, &TemplatesConfig::default())?;
480
481 let mut prepared = PreparedRun::new(
482 session,
483 RunContext {
484 workspace,
485 prompt: spec.prompt.clone(),
486 provider: provider.into(),
487 model: model.into(),
488 context: discovered,
489 // The caller owns the runtime, so it owns skill and binding
490 // registration too.
491 skills_dirs: Vec::new(),
492 skills: Vec::new(),
493 templates_dirs,
494 templates,
495 remote_agents: Vec::new(),
496 },
497 )
498 .with_bounds(spec.turn_options());
499
500 if let Some(effort) = spec.effort {
501 prepared.set_effort(Some(effort))?;
502 }
503
504 Ok(prepared)
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::context::ContextError;
511
512 #[test]
513 fn every_lan_effort_maps_to_the_same_provider_level() {
514 use mentra::provider::ReasoningEffort;
515
516 for (effort, expected) in [
517 (Effort::Low, ReasoningEffort::Low),
518 (Effort::Medium, ReasoningEffort::Medium),
519 (Effort::High, ReasoningEffort::High),
520 (Effort::XHigh, ReasoningEffort::XHigh),
521 (Effort::Max, ReasoningEffort::Max),
522 ] {
523 assert_eq!(ReasoningEffort::from(effort), expected);
524 }
525 }
526
527 #[test]
528 fn stable_run_ids_match_the_wire_and_bound_reader() {
529 for effort in [
530 Effort::Low,
531 Effort::Medium,
532 Effort::High,
533 Effort::XHigh,
534 Effort::Max,
535 ] {
536 let written = serde_json::to_value(effort).expect("serializes");
537 assert_eq!(written.as_str().expect("string"), effort.type_tag());
538 }
539
540 for bound in [Bound::Deadline, Bound::ToolBudget, Bound::TokenBudget] {
541 let written = serde_json::to_value(bound).expect("serializes");
542 assert_eq!(written.as_str().expect("string"), bound.type_tag());
543 assert_eq!(Bound::from_type_tag(bound.type_tag()), Some(bound));
544 }
545 assert_eq!(Bound::from_type_tag("newer_bound"), None);
546 }
547
548 #[tokio::test]
549 async fn an_empty_prompt_is_rejected_before_any_provider_work() {
550 let error = run("/definitely/not/a/real/path", " \n ", NullSink)
551 .await
552 .expect_err("rejected");
553
554 // Reaching provider resolution or workspace validation would prove the
555 // check ran too late.
556 assert!(matches!(error, RunError::EmptyPrompt));
557 }
558
559 #[tokio::test]
560 async fn a_missing_workspace_fails_before_a_provider_is_needed() {
561 let error = run("/definitely/not/a/real/path", "hello", NullSink)
562 .await
563 .expect_err("rejected");
564
565 assert!(matches!(
566 error,
567 RunError::Context(ContextError::WorkspaceMissing { .. })
568 ));
569 }
570}