Skip to main content

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}