Skip to main content

mermaid_cli/providers/
ctx.rs

1//! Per-call context passed to providers and tool executors.
2//!
3//! The two structs below are the single point where per-turn
4//! cancellation + progress reporting + session identity meet a
5//! specific provider call. Everything a model or tool adapter needs
6//! to participate in structured concurrency is here.
7//!
8//! - `StreamContext` is handed to a `ModelProvider::chat()`. It
9//!   carries the cancellation token for the turn and a bounded mpsc
10//!   sink for streaming events. The adapter `select!`s on
11//!   `token.cancelled()` inside its read loop and awaits
12//!   `sink.send(event)` — if the main loop is drowning, the `await`
13//!   applies natural backpressure and the provider's TCP buffer fills
14//!   instead of the channel growing unbounded.
15//!
16//! - `ExecContext` is handed to a `ToolExecutor::execute()`. Same
17//!   token (so Ctrl+C cancels tools too) plus a progress sink and
18//!   identifiers so the reducer can match results to the call that
19//!   produced them.
20
21use std::path::PathBuf;
22use std::sync::Arc;
23
24use tokio::sync::mpsc;
25use tokio_util::sync::CancellationToken;
26
27use crate::domain::{ToolCallId, TurnId};
28use crate::models::tool_call::ToolCall as ModelToolCall;
29use crate::models::{ChatMessage, ReasoningChunk, TokenUsage};
30
31/// What a `ModelProvider::chat()` receives.
32#[derive(Debug)]
33pub struct StreamContext {
34    pub token: CancellationToken,
35    pub sink: mpsc::Sender<StreamEvent>,
36    pub turn: TurnId,
37}
38
39impl StreamContext {
40    pub fn new(token: CancellationToken, sink: mpsc::Sender<StreamEvent>, turn: TurnId) -> Self {
41        Self { token, sink, turn }
42    }
43}
44
45/// One event emitted during a streaming model call. Adapters MUST
46/// emit exactly one `Done` at the end of a successful stream. `Text`
47/// and `Reasoning` may interleave. `ToolCall` events typically arrive
48/// near the end but the contract is "before `Done`".
49#[derive(Debug, Clone)]
50pub enum StreamEvent {
51    Text(String),
52    Reasoning(ReasoningChunk),
53    ToolCall(ModelToolCall),
54    /// Optional — some providers emit a signature mid-stream
55    /// (Anthropic). Adapters that only have it at the end can attach
56    /// it to `Done` instead.
57    ThinkingSignature(String),
58    /// Stream complete. Carries final token usage (None if unknown)
59    /// and any terminal thinking signature.
60    Done {
61        usage: Option<TokenUsage>,
62        thinking_signature: Option<String>,
63    },
64}
65
66/// Final response returned by `ModelProvider::chat()` after the
67/// stream drains. Carries what the reducer can't derive from the
68/// stream events themselves — token usage and the opaque thinking
69/// signature needed for Anthropic extended-thinking continuation.
70#[derive(Debug, Clone)]
71pub struct FinalResponse {
72    pub usage: Option<TokenUsage>,
73    pub thinking_signature: Option<String>,
74    pub tool_calls: Vec<ModelToolCall>,
75}
76
77/// What a `ToolExecutor::execute()` receives.
78#[derive(Debug)]
79pub struct ExecContext {
80    pub token: CancellationToken,
81    pub progress: mpsc::Sender<ProgressEvent>,
82    pub call_id: ToolCallId,
83    pub turn: TurnId,
84    pub workdir: PathBuf,
85    /// Parent session's `app::Config`. Needed by `SubagentTool` so the
86    /// child reducer uses the same Ollama host, reasoning prefs, MCP
87    /// servers, etc. Other tools don't consult it — keeping it as a
88    /// typed field (rather than a global) means the dependency is
89    /// explicit in the signature.
90    pub config: Arc<crate::app::Config>,
91    /// Parent session's active model id (e.g. `"anthropic/claude-opus-4-7"`).
92    /// Subagents inherit this so they hit the same provider.
93    pub model_id: String,
94    /// Durable daemon task that owns this tool call, when execution was
95    /// launched through the runtime task queue.
96    pub task_id: Option<String>,
97}
98
99impl ExecContext {
100    #[allow(clippy::too_many_arguments)]
101    pub fn new(
102        token: CancellationToken,
103        progress: mpsc::Sender<ProgressEvent>,
104        call_id: ToolCallId,
105        turn: TurnId,
106        workdir: PathBuf,
107        config: Arc<crate::app::Config>,
108        model_id: String,
109        task_id: Option<String>,
110    ) -> Self {
111        Self {
112            token,
113            progress,
114            call_id,
115            turn,
116            workdir,
117            config,
118            model_id,
119            task_id,
120        }
121    }
122}
123
124/// Tool-side progress event. The reducer already knows `ToolStarted`
125/// and `ToolFinished`; this carries everything in between (streaming
126/// subprocess output, long-running download status, multimodal
127/// artifacts like inline screenshots, and nested activity from
128/// subagents).
129#[derive(Debug, Clone)]
130pub enum ProgressEvent {
131    /// Partial stdout/stderr chunk.
132    Output(String),
133    /// Arbitrary status string for display.
134    Status(String),
135    /// Byte-count progress for long downloads/transfers. `total` is
136    /// None when the producer doesn't know the final size.
137    Bytes { done: u64, total: Option<u64> },
138    /// Binary artifact produced mid-execution (screenshot preview,
139    /// generated file, etc.). MIME string determines routing in the
140    /// reducer — `image/*` attaches inline to the active assistant
141    /// message; anything else lands on the status line as a label.
142    Artifact {
143        mime: String,
144        data: Vec<u8>,
145        caption: Option<String>,
146    },
147    /// A child subagent just started or finished a tool call. Carries
148    /// the CHILD's call identity + tool name + phase so the parent UI
149    /// can surface it without needing to recurse into the child's
150    /// event vocabulary.
151    SubagentToolCall {
152        child_call_id: ToolCallId,
153        tool_name: String,
154        phase: SubagentPhase,
155    },
156    /// A chunk of assistant text produced by a child subagent. Mostly
157    /// UI flavor — lets the parent status line show what the sub is
158    /// "saying" in real time.
159    SubagentText(String),
160}
161
162/// Phase a subagent tool-call is in, from the parent's perspective.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum SubagentPhase {
165    Started,
166    Finished,
167    Errored,
168}
169
170/// Narrow shim from the reducer's `ChatRequest` to the adapter-facing
171/// messages. Providers often want to mutate the last assistant
172/// message (e.g. Anthropic cache_control injection); this helper
173/// clones the slice as owned so the provider can do that without
174/// fighting the borrow checker.
175pub fn clone_messages(msgs: &[ChatMessage]) -> Vec<ChatMessage> {
176    msgs.to_vec()
177}
178
179/// Builder that lets tests construct a pair of `StreamContext` +
180/// receiver without needing a runtime. Used by provider unit tests
181/// and by integration harnesses in C9.
182pub fn test_stream_context(turn: TurnId) -> (StreamContext, mpsc::Receiver<StreamEvent>) {
183    let token = CancellationToken::new();
184    let (tx, rx) = mpsc::channel(64);
185    (StreamContext::new(token, tx, turn), rx)
186}
187
188/// Builder counterpart for `ExecContext`. Uses a `Config` pinned to
189/// `SafetyMode::FullAccess` (the production default is now `Ask`) so tool
190/// unit tests exercise the tool's own behavior rather than the approval
191/// gate. Tests that specifically exercise policy gating should construct
192/// `ExecContext::new` directly with their chosen safety mode.
193pub fn test_exec_context(
194    turn: TurnId,
195    call_id: ToolCallId,
196    workdir: PathBuf,
197) -> (ExecContext, mpsc::Receiver<ProgressEvent>) {
198    let token = CancellationToken::new();
199    let (tx, rx) = mpsc::channel(64);
200    let mut config = crate::app::Config::default();
201    config.safety.mode = crate::runtime::SafetyMode::FullAccess;
202    let config = Arc::new(config);
203    (
204        ExecContext::new(
205            token,
206            tx,
207            call_id,
208            turn,
209            workdir,
210            config,
211            String::new(),
212            None,
213        ),
214        rx,
215    )
216}
217
218/// Convenience: build a Send+Sync sharable sink for tests.
219pub fn arc_sink(tx: mpsc::Sender<StreamEvent>) -> Arc<mpsc::Sender<StreamEvent>> {
220    Arc::new(tx)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use std::path::PathBuf;
227
228    #[tokio::test]
229    async fn stream_context_carries_token_and_turn() {
230        let (ctx, _rx) = test_stream_context(TurnId(5));
231        assert_eq!(ctx.turn, TurnId(5));
232        assert!(!ctx.token.is_cancelled());
233    }
234
235    #[tokio::test]
236    async fn exec_context_propagates_cancel_signal() {
237        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
238        let token = ctx.token.clone();
239        tokio::spawn(async move {
240            token.cancel();
241        });
242        // Wait until cancelled.
243        ctx.token.cancelled().await;
244        assert!(ctx.token.is_cancelled());
245    }
246
247    #[tokio::test]
248    async fn progress_event_round_trips_through_channel() {
249        let (ctx, mut rx) = test_exec_context(TurnId(1), ToolCallId(2), PathBuf::from("/tmp"));
250        ctx.progress
251            .send(ProgressEvent::Status("halfway".to_string()))
252            .await
253            .expect("send");
254        match rx.recv().await.expect("recv") {
255            ProgressEvent::Status(s) => assert_eq!(s, "halfway"),
256            _ => panic!("wrong variant"),
257        }
258    }
259}