Give the coder runtime real tiers, a local lane, metering, and revocation

acb981ab2d8c · AtlantisPleb · · parent 03bbdf9353b8

Give the coder runtime real tiers, a local lane, metering, and revocation

The lanes were five model ids that had been invented, and the local lane
was an enum variant with nothing behind it. `GET /api/v1/models` serves
`gemini-3.7-flash`, `ox-alpha` and `gpt-5.6-luna`, and nothing else; the
tier table now maps the names a reader types onto those, and a name
outside it is checked against the live catalog before a thread opens
rather than falling through to the default. `--lane bogus` used to run
ox-alpha silently and now earns a refusal naming both the tiers and what
the deployment serves.

`POST /api/v1/threads` takes `model` alongside `lane`, so a tier pins the
lane it names and `auto` names none. The grant is still what reports
which model answered.

The local lane now exists: `--lane local` or `--lane ollama:<model>`
resolves against `GET /api/tags`, streams `POST /api/chat` as
newline-delimited JSON, and runs the same tool loop. It never touches
openagents.com, so it answers with the proxy unreachable, and it holds no
grant because none is minted for it.

Metering: `delta.reasoning` and the proxy's final `usage` chunk are
parsed, reasoning is kept off the transcript, and the counts are summed
across a turn's steps. Ollama's `prompt_eval_count`/`eval_count` land the
same way. `oa coder --headless` prints the model and the usage.

Lifecycle: one thread serves the whole session instead of one per turn,
and `close()` sends `DELETE /api/v1/threads/{id}` and reads the grant's
spend back. A best-effort revocation on drop covers the paths with no
place to await one.

A turn that cannot reach a model still fails loudly: `oa: <reason>` on
stderr and exit 2, with no fallback model, no synthesized reply, and no
invented grant.

Tests are 19 new ones against real sockets. Streaming is proved with a
clock — the server holds the rest of the stream open and the assertion is
that a chunk reached the caller at least 500ms before the turn returned,
which a batched reply cannot satisfy. Refusals are proved to be `Err`
carrying the server's own words, on both lanes.

Refs OpenAgentsInc/openagents#69, OpenAgentsInc/openagents#83

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/runtime.rs
  • added crates/openagents-cli/tests/runtime_test.rs

Diff

3 files changed, +2043 -144

crates/openagents-cli/src/cli.rs modified +20 -1

@@ -780,8 +780,27 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

780 780
                    print!("{}", chunk);
781 781
                    use std::io::Write;
782 782
                    let _ = std::io::stdout().flush();
783
                }).await.map_err(|e| e.to_string())?;
783
                }).await.map_err(|e| e.to_string());
784
                // The thread is revoked whether the turn worked or not: a
785
                // failed turn still opened one, and one left open holds its
786
                // grant's remaining budget.
787
                let revoked = runtime.close().await;
788
                // A turn that could not reach a model is a failure, and says
789
                // so in the shape every other refusal here uses.
790
                let result = match result {
791
                    Ok(result) => result,
792
                    Err(error) => fail(&error),
793
                };
784 794
                println!("\n\nTurn result:\n{}", result);
795
                if let Some(model) = &runtime.last_model {
796
                    println!("Model: {model}");
797
                }
798
                if runtime.last_usage.reported() {
799
                    println!("Usage: {}", runtime.last_usage.line());
800
                }
801
                if let Err(error) = revoked {
802
                    eprintln!("oa: the thread was not revoked: {error}");
803
                }
785 804
            } else {
786 805
                crate::interactive::run_tui(coder, token).await?;
787 806
            }
crates/openagents-cli/src/runtime.rs modified +1147 -143

@@ -1,5 +1,34 @@

1
//! Live OpenAgents inference proxy client & streaming multi-turn loop
2
//! Replicates coder-thread.ts behavior over POST /api/v1/threads and POST /api/inference/proxy
1
//! The coder execution layer: tiers, threads, grants, and the streaming turn.
2
//!
3
//! Two lanes reach a model, and they share nothing but this file's message
4
//! list:
5
//!
6
//! - The **thread lane** opens `POST /api/v1/threads`, takes the grant that
7
//!   comes back, and streams `POST /api/inference/proxy` with the grant's
8
//!   bearer token. The thread is revoked with `DELETE /api/v1/threads/{id}`.
9
//! - The **local lane** talks to an Ollama server on this machine and never
10
//!   touches openagents.com at all, so it answers with the proxy unreachable.
11
//!
12
//! ## A turn that cannot reach a model fails
13
//!
14
//! Every path out of [`CoderRuntimeSession::execute_turn`] is either the
15
//! model's own words or an `Err`. There is no fallback model, no synthesized
16
//! reply, and no invented grant. Two of those existed here and both reached
17
//! readers: `create_thread` answered a refusal by returning a grant it made up
18
//! with the caller's own PAT inside it, and the proxy arm answered a rejected
19
//! request with the sentence `Completed autonomous reasoning turn (offline
20
//! fallback).` and exit 0. Neither comes back.
21
//!
22
//! ## Model ids are the server's, not this file's
23
//!
24
//! The deployment publishes its catalog at `GET /api/v1/models` and refuses
25
//! anything outside it. A previous version of this file carried five model ids
26
//! it had invented — `gemini-3.7-pro`, `claude-3-7-sonnet`, `codex-preview`
27
//! among them — and sent them as the thread's `lane`, which the server also
28
//! refuses. The tier table below maps the names a reader types onto ids the
29
//! catalog actually served when it was written, and a name outside that table
30
//! is checked against the live catalog before a thread is opened rather than
31
//! guessed at.
3 32
4 33
use crate::tools::{HarnessToolRegistry, ToolCall, ToolDefinition};
5 34
use eventsource_stream::Eventsource;

@@ -8,50 +37,138 @@ use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};

8 37
use serde::{Deserialize, Serialize};
9 38
use std::time::Duration;
10 39
40
type Failure = Box<dyn std::error::Error + Send + Sync>;
41
11 42
pub const THREAD_LANE_NOTICE: &str =
12 43
    "You answer through the OpenAgents inference proxy, on a thread opened for this session. \
13 44
    Every round of tool calls re-sends the whole conversation to a metered model, so batch \
14 45
    independent commands into one call and keep large dumps out of the transcript.";
15 46
16
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17
pub enum Lane {
18
    OxAlpha,
19
    GeminiFlash,
20
    GeminiPro,
21
    ClaudeCode,
22
    Codex,
23
    Ollama(String),
47
pub const LOCAL_LANE_NOTICE: &str =
48
    "You answer from a model running on this machine through Ollama. Nothing in this \
49
    conversation leaves the machine and nothing is metered, but the context window is a \
50
    fraction of a hosted model's, so keep large dumps out of the transcript.";
51
52
/// Where an Ollama server listens unless `OPENAGENTS_OLLAMA_HOST` says otherwise.
53
pub const OLLAMA_HOST: &str = "http://127.0.0.1:11434";
54
55
/// How many rounds of tool calls one turn may take before it has to answer.
56
///
57
/// A backstop against a model that loops, not a budget.
58
const MAX_TOOL_STEPS: usize = 30;
59
60
/// The tier names a reader may type, and the catalog id each one opens on.
61
///
62
/// A tier is the unit `--lane` deals in: `flash` is the fast lane whatever
63
/// model is behind it this month, so renaming a vendor model is a one-line
64
/// change here and nothing else in the crate holds a vendor string. The ids on
65
/// the right were served by `GET /api/v1/models` when this was written; the
66
/// server is the authority and refuses any that stop being true.
67
pub const TIERS: &[(&str, &str)] = &[
68
    ("flash", "gemini-3.7-flash"),
69
    ("pro", "gpt-5.6-luna"),
70
    ("ox-alpha", "ox-alpha"),
71
];
72
73
/// What `--lane` takes, for a refusal that leaves the reader somewhere to go.
74
pub fn admitted_lanes() -> String {
75
    let tiers = TIERS
76
        .iter()
77
        .map(|(name, id)| format!("{name} ({id})"))
78
        .collect::<Vec<_>>()
79
        .join(", ");
80
    format!(
81
        "auto (the deployment's own default), {tiers}, \
82
         local or ollama:<model> for a model on this machine"
83
    )
24 84
}
25 85
26
impl Default for Lane {
27
    fn default() -> Self {
28
        Lane::OxAlpha
29
    }
86
/// Which lane a turn runs on.
87
///
88
/// [`Lane::from_str`] is total on purpose: an unrecognised name becomes
89
/// [`Lane::Named`] and is checked against the live catalog at the top of the
90
/// turn, so `--lane bogus` is refused by name with the list of what this
91
/// deployment serves. It used to fall through to `_ => Lane::OxAlpha`, which
92
/// ran the default lane while the reader believed they had chosen another.
93
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
94
pub enum Lane {
95
    /// No model named at thread open. The deployment's own default answers.
96
    Auto,
97
    /// The `ox-alpha` tier.
98
    #[default]
99
    OxAlpha,
100
    /// The fast tier.
101
    Flash,
102
    /// The strong tier.
103
    Pro,
104
    /// A model id named directly, checked against `GET /api/v1/models`.
105
    Named(String),
106
    /// Ollama on this machine. An empty string means "whatever is installed".
107
    Local(String),
30 108
}
31 109
32 110
impl Lane {
111
    /// Named `from_str` rather than `FromStr::from_str` because it cannot
112
    /// fail: a name nothing admits is carried to the turn, where the catalog
113
    /// settles it and the refusal can name what this deployment serves.
114
    #[allow(clippy::should_implement_trait)]
33 115
    pub fn from_str(s: &str) -> Self {
34
        match s.to_lowercase().as_str() {
35
            "ox-alpha" | "ox" => Lane::OxAlpha,
36
            "gemini" | "gemini-flash" => Lane::GeminiFlash,
37
            "gemini-pro" => Lane::GeminiPro,
38
            "claude" => Lane::ClaudeCode,
39
            "codex" => Lane::Codex,
116
        match s.trim().to_lowercase().as_str() {
117
            "" | "auto" => Lane::Auto,
118
            "ox-alpha" | "ox" | "openagents" => Lane::OxAlpha,
119
            "flash" | "coder-flash" | "gemini" | "gemini-flash" | "gemini-3.7-flash" => Lane::Flash,
120
            "pro" | "coder-pro" | "gpt-5.6-luna" | "luna" => Lane::Pro,
121
            "local" | "ollama" => Lane::Local(String::new()),
40 122
            other if other.starts_with("ollama:") => {
41
                Lane::Ollama(other.trim_start_matches("ollama:").to_string())
123
                Lane::Local(other.trim_start_matches("ollama:").trim().to_string())
42 124
            }
43
            _ => Lane::OxAlpha,
125
            other => Lane::Named(other.to_string()),
126
        }
127
    }
128
129
    /// The catalog id to send at thread open, or `None` to let the server pick.
130
    pub fn model_id(&self) -> Option<&str> {
131
        match self {
132
            Lane::Auto => None,
133
            Lane::OxAlpha => Some("ox-alpha"),
134
            Lane::Flash => Some(TIERS[0].1),
135
            Lane::Pro => Some(TIERS[1].1),
136
            Lane::Named(id) => Some(id.as_str()),
137
            // The local lane names its model to Ollama, never to the server.
138
            Lane::Local(_) => None,
139
        }
140
    }
141
142
    /// Whether this lane answers from this machine.
143
    pub fn is_local(&self) -> bool {
144
        matches!(self, Lane::Local(_))
145
    }
146
147
    /// The tier this lane belongs to: `auto`, `flash`, `pro`, or `local`.
148
    ///
149
    /// A model id no tier pins has no tier, which is a different answer from
150
    /// "auto" and is worth keeping separate — a reader who named a model
151
    /// directly did not ask for a tier.
152
    pub fn tier(&self) -> Option<&'static str> {
153
        match self {
154
            Lane::Auto => Some("auto"),
155
            Lane::Flash => Some("flash"),
156
            Lane::Pro => Some("pro"),
157
            Lane::Local(_) => Some("local"),
158
            Lane::OxAlpha | Lane::Named(_) => None,
44 159
        }
45 160
    }
46 161
47
    pub fn model_name(&self) -> &str {
162
    /// The name for this lane on a status line.
163
    pub fn label(&self) -> String {
48 164
        match self {
49
            Lane::OxAlpha => "ox-alpha",
50
            Lane::GeminiFlash => "gemini-3.7-flash",
51
            Lane::GeminiPro => "gemini-3.7-pro",
52
            Lane::ClaudeCode => "claude-3-7-sonnet",
53
            Lane::Codex => "codex-preview",
54
            Lane::Ollama(m) => m.as_str(),
165
            Lane::Auto => "Coder Auto".to_string(),
166
            Lane::Flash => "Coder Flash".to_string(),
167
            Lane::Pro => "Coder Pro".to_string(),
168
            Lane::Local(model) if model.is_empty() => "Coder Local".to_string(),
169
            Lane::Local(model) => format!("Coder Local ({model})"),
170
            Lane::OxAlpha => "Coder (ox-alpha)".to_string(),
171
            Lane::Named(id) => format!("Coder ({id})"),
55 172
        }
56 173
    }
57 174
}

@@ -66,6 +183,48 @@ fn snippet(body: &str) -> String {

66 183
    format!("{head}…")
67 184
}
68 185
186
/// One model as `GET /api/v1/models` publishes it.
187
#[derive(Debug, Clone, PartialEq, Eq)]
188
pub struct ServedModel {
189
    pub id: String,
190
    /// Served here *and* its provider credential configured.
191
    pub available: bool,
192
    pub default: bool,
193
}
194
195
/// What one turn spent, as the server reported it.
196
///
197
/// Reported rather than estimated. The proxy sends a final chunk carrying
198
/// `usage` and Ollama sends its counts on the `done` line; both land here, and
199
/// a lane that reports nothing leaves this zero rather than guessing.
200
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
201
pub struct TurnUsage {
202
    pub prompt_tokens: u64,
203
    pub completion_tokens: u64,
204
    pub total_tokens: u64,
205
}
206
207
impl TurnUsage {
208
    fn add(&mut self, other: TurnUsage) {
209
        self.prompt_tokens += other.prompt_tokens;
210
        self.completion_tokens += other.completion_tokens;
211
        self.total_tokens += other.total_tokens;
212
    }
213
214
    pub fn reported(&self) -> bool {
215
        self.total_tokens > 0 || self.prompt_tokens > 0 || self.completion_tokens > 0
216
    }
217
218
    /// One line for a transcript or a status bar.
219
    pub fn line(&self) -> String {
220
        format!(
221
            "{} prompt + {} completion = {} tokens",
222
            self.prompt_tokens, self.completion_tokens, self.total_tokens
223
        )
224
    }
225
}
226
227
/// A grant the server issued. Never constructed from anything else.
69 228
#[derive(Debug, Clone, Serialize, Deserialize)]
70 229
pub struct InferenceGrant {
71 230
    pub thread_id: String,

@@ -87,41 +246,84 @@ pub struct ChatMessage {

87 246
88 247
pub struct CoderRuntimeSession {
89 248
    pub lane: Lane,
90
    /// The grant the last turn opened, so a caller can report the model that
91
    /// actually answered rather than the one it would have asked for.
249
    /// The grant this session opened, reused across its turns.
250
    ///
251
    /// `None` on the local lane, always: there is no grant to hold, and
252
    /// putting a made-up one here is the bug this file exists to keep dead.
92 253
    pub last_grant: Option<InferenceGrant>,
254
    /// The model that actually answered the last turn.
255
    ///
256
    /// From the grant on the thread lane, and from the resolved Ollama name on
257
    /// the local lane. A caller reporting what answered should read this
258
    /// rather than the lane, which is only what was asked for.
259
    pub last_model: Option<String>,
260
    /// What the last turn spent, summed over its steps.
261
    pub last_usage: TurnUsage,
262
    /// The reasoning the last turn emitted, if the model emits any.
263
    ///
264
    /// Kept off the content callback deliberately: `delta.reasoning` and
265
    /// `delta.content` interleave on the wire, and appending both to the
266
    /// transcript would put the model's scratch work in the middle of its
267
    /// answer. It is parsed, summed and kept here so a caller that wants to
268
    /// show it can, and the transcript stays the answer.
269
    pub last_reasoning: String,
93 270
    pub api_base: String,
94 271
    pub user_token: Option<String>,
272
    pub ollama_host: String,
95 273
    pub http: reqwest::Client,
96 274
    pub tools: HarnessToolRegistry,
97 275
    pub messages: Vec<ChatMessage>,
276
    /// The thread to revoke when the session closes.
277
    thread_id: Option<String>,
98 278
}
99 279
100 280
impl CoderRuntimeSession {
101
    pub fn new(lane: Lane, api_base: Option<String>, user_token: Option<String>, tools: HarnessToolRegistry) -> Self {
281
    pub fn new(
282
        lane: Lane,
283
        api_base: Option<String>,
284
        user_token: Option<String>,
285
        tools: HarnessToolRegistry,
286
    ) -> Self {
102 287
        Self {
103 288
            lane,
104 289
            last_grant: None,
290
            last_model: None,
291
            last_usage: TurnUsage::default(),
292
            last_reasoning: String::new(),
105 293
            // `OPENAGENTS_API_BASE` points the session at another host. A test
106 294
            // that has to prove the streaming path end to end needs somewhere
107 295
            // to point it that is not production, and an operator on staging
108 296
            // needs the same switch.
109 297
            api_base: api_base
110
                .or_else(|| std::env::var("OPENAGENTS_API_BASE").ok().filter(|v| !v.trim().is_empty()))
298
                .or_else(|| {
299
                    std::env::var("OPENAGENTS_API_BASE")
300
                        .ok()
301
                        .filter(|v| !v.trim().is_empty())
302
                })
111 303
                .unwrap_or_else(|| "https://openagents.com/api/v1".to_string()),
112 304
            user_token,
305
            ollama_host: std::env::var("OPENAGENTS_OLLAMA_HOST")
306
                .ok()
307
                .filter(|v| !v.trim().is_empty())
308
                .unwrap_or_else(|| OLLAMA_HOST.to_string()),
113 309
            http: reqwest::Client::builder()
114 310
                .timeout(Duration::from_secs(300))
115 311
                .build()
116 312
                .unwrap_or_default(),
117 313
            tools,
118 314
            messages: Vec::new(),
315
            thread_id: None,
119 316
        }
120 317
    }
121 318
122 319
    pub fn build_system_prompt(&self, tool_defs: &[ToolDefinition]) -> String {
320
        let notice = if self.lane.is_local() {
321
            LOCAL_LANE_NOTICE
322
        } else {
323
            THREAD_LANE_NOTICE
324
        };
123 325
        let mut lines = vec![
124
            format!("You are `openagents coder`, a coding assistant in a terminal. {}", THREAD_LANE_NOTICE),
326
            format!("You are `openagents coder`, a coding assistant in a terminal. {notice}"),
125 327
            "".to_string(),
126 328
            "Answer very concisely unless the reader asks for a longer response.".to_string(),
127 329
            "".to_string(),

@@ -134,7 +336,10 @@ impl CoderRuntimeSession {

134 336
                say plainly when something would need a tool you do not have.".to_string()
135 337
            );
136 338
        } else {
137
            lines.push(format!("You have {} tools, and no others:", tool_defs.len()));
339
            lines.push(format!(
340
                "You have {} tools, and no others:",
341
                tool_defs.len()
342
            ));
138 343
            for t in tool_defs {
139 344
                lines.push(format!("- `{}`", t.name));
140 345
            }

@@ -150,58 +355,235 @@ impl CoderRuntimeSession {

150 355
        lines.join("\n")
151 356
    }
152 357
153
    pub async fn create_thread(&self) -> Result<InferenceGrant, Box<dyn std::error::Error + Send + Sync>> {
358
    // ───────────────────────────────────────────────────────── the catalog
359
360
    /// What this deployment serves, read from the server rather than assumed.
361
    pub async fn served_models(&self) -> Result<Vec<ServedModel>, Failure> {
362
        let url = format!("{}/models", self.api_base);
363
        let mut request = self.http.get(&url).timeout(Duration::from_secs(15));
364
        if let Some(token) = &self.user_token {
365
            request = request.bearer_auth(token);
366
        }
367
        let resp = request.send().await.map_err(|error| -> Failure {
368
            format!("{url} could not be reached: {error}").into()
369
        })?;
370
        if !resp.status().is_success() {
371
            let status = resp.status();
372
            let body = resp.text().await.unwrap_or_default();
373
            return Err(format!(
374
                "{url} refused the catalog request: {status} {}",
375
                snippet(&body)
376
            )
377
            .into());
378
        }
379
        let body: serde_json::Value = resp.json().await?;
380
        let models = body
381
            .get("models")
382
            .and_then(|v| v.as_array())
383
            .ok_or_else(|| -> Failure { format!("{url} published no model list").into() })?;
384
        Ok(models
385
            .iter()
386
            .filter_map(|model| {
387
                let id = model.get("id").and_then(|v| v.as_str())?;
388
                Some(ServedModel {
389
                    id: id.to_string(),
390
                    // Any word other than `available` is read as unavailable: a
391
                    // vocabulary this client has not seen is a reason to pick
392
                    // another model, not to assume the new word is benign.
393
                    available: model.get("availability").and_then(|v| v.as_str())
394
                        == Some("available"),
395
                    default: model.get("default").and_then(|v| v.as_bool()) == Some(true),
396
                })
397
            })
398
            .collect())
399
    }
400
401
    /// Refuse a directly-named model the catalog cannot answer with.
402
    ///
403
    /// The tiers skip this: they are checked at open, and the server's own 422
404
    /// carries the news if one is retired. Only a name this crate does not
405
    /// recognise pays for the round trip, and it pays it so that `--lane
406
    /// bogus` earns a sentence naming what it could have said instead.
407
    async fn check_named(&self, id: &str) -> Result<(), Failure> {
408
        let served = match self.served_models().await {
409
            Ok(served) => served,
410
            Err(error) => {
411
                return Err(format!(
412
                    "'{id}' is not a lane this CLI knows, and the catalog that would settle it \
413
                     could not be read: {error}. Lanes: {}.",
414
                    admitted_lanes()
415
                )
416
                .into())
417
            }
418
        };
419
        let usable: Vec<&str> = served
420
            .iter()
421
            .filter(|m| m.available)
422
            .map(|m| m.id.as_str())
423
            .collect();
424
        let alternatives = if usable.is_empty() {
425
            "This deployment has no model with a configured provider credential.".to_string()
426
        } else {
427
            format!("This deployment serves {}.", usable.join(", "))
428
        };
429
430
        match served.iter().find(|m| m.id == id) {
431
            None => Err(format!(
432
                "'{id}' is not a lane this CLI knows and no model of that name is served here. \
433
                 {alternatives} Lanes: {}.",
434
                admitted_lanes()
435
            )
436
            .into()),
437
            Some(model) if !model.available => Err(format!(
438
                "'{id}' is in the catalog but its provider is not configured on this deployment. \
439
                 {alternatives}"
440
            )
441
            .into()),
442
            Some(_) => Ok(()),
443
        }
444
    }
445
446
    // ────────────────────────────────────────────────── threads and grants
447
448
    pub async fn create_thread(&self) -> Result<InferenceGrant, Failure> {
154 449
        let url = format!("{}/threads", self.api_base);
155 450
        let mut headers = HeaderMap::new();
156 451
        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
157 452
        if let Some(tok) = &self.user_token {
158
            headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", tok))?);
453
            headers.insert(
454
                AUTHORIZATION,
455
                HeaderValue::from_str(&format!("Bearer {tok}"))?,
456
            );
457
        }
458
459
        // `lane` is the thread's execution shape and the server admits only
460
        // `thread` and `local`; this path is the proxy, which is `thread`. It
461
        // used to send a model name here and every request was refused with
462
        // `"ox-alpha" is not an admitted lane` — invisibly, because the caller
463
        // answered the refusal with a fabricated grant.
464
        //
465
        // `model` is separate and optional. Omitting it opens on the
466
        // deployment's own default, which is what `Lane::Auto` wants; naming
467
        // one pins the lane. Either way the grant that comes back is the
468
        // authority on which model answers, and that is what gets reported.
469
        let mut body = serde_json::json!({
470
            "objective": "Coding assistant session",
471
            "lane": "thread",
472
        });
473
        if let Some(model) = self.lane.model_id() {
474
            body["model"] = serde_json::json!(model);
159 475
        }
160 476
161
        let resp = self.http.post(&url)
477
        let resp = self
478
            .http
479
            .post(&url)
162 480
            .headers(headers)
163
            // `lane` is the thread's execution shape, and the server admits
164
            // only `thread` and `local` — this path is the proxy, which
165
            // is `thread`. It used to send a model name here, and every
166
            // request was refused with `"ox-alpha" is not an admitted lane`;
167
            // the refusal was invisible because the caller answered it with a
168
            // fabricated grant. A model cannot be named at thread open at all:
169
            // the endpoint publishes no model parameter and the grant the
170
            // server returns pins the model that answers.
171
            .json(&serde_json::json!({
172
                "objective": "Coding assistant session",
173
                "lane": "thread",
174
            }))
481
            .json(&body)
175 482
            .send()
176 483
            .await?;
177 484
178
        if resp.status().is_success() {
179
            let body: serde_json::Value = resp.json().await?;
180
            let thread = body.get("thread").cloned().unwrap_or(serde_json::json!({}));
181
            let grant = body.get("grant").cloned().unwrap_or(serde_json::json!({}));
182
183
            let thread_id = thread.get("id").and_then(|v| v.as_str()).unwrap_or("th_active").to_string();
184
            let token = grant.get("token").and_then(|v| v.as_str()).unwrap_or("").to_string();
185
            let grant_url = grant.get("url").and_then(|v| v.as_str()).unwrap_or("https://openagents.com/api/inference/proxy").to_string();
186
            let model = grant.get("model").and_then(|v| v.as_str()).unwrap_or(self.lane.model_name()).to_string();
187
188
            Ok(InferenceGrant {
189
                thread_id,
190
                token,
191
                proxy_url: grant_url,
192
                model,
193
            })
194
        } else {
485
        if !resp.status().is_success() {
195 486
            // This used to invent a grant with a placeholder token and carry
196 487
            // on, so a refused request reached the reader as a completed turn.
197
            // Say what happened instead; the caller puts it on the transcript.
488
            let status = resp.status();
489
            let text = resp.text().await.unwrap_or_default();
490
            return Err(format!(
491
                "{url} refused the thread request: {status} {}",
492
                snippet(&text)
493
            )
494
            .into());
495
        }
496
497
        let body: serde_json::Value = resp.json().await?;
498
        let thread = body.get("thread").cloned().unwrap_or(serde_json::json!({}));
499
        let grant = body.get("grant").cloned().unwrap_or(serde_json::json!({}));
500
501
        let thread_id = thread
502
            .get("id")
503
            .and_then(|v| v.as_str())
504
            .ok_or_else(|| -> Failure {
505
                format!("{url} accepted the thread but published no id").into()
506
            })?
507
            .to_string();
508
        let token = grant
509
            .get("token")
510
            .and_then(|v| v.as_str())
511
            .filter(|t| !t.is_empty())
512
            .ok_or_else(|| -> Failure {
513
                format!(
514
                    "{url} opened thread {thread_id} but minted no inference grant, \
515
                     so there is no token to call the proxy with"
516
                )
517
                .into()
518
            })?
519
            .to_string();
520
        let proxy_url = grant
521
            .get("url")
522
            .and_then(|v| v.as_str())
523
            .ok_or_else(|| -> Failure {
524
                format!("the grant on thread {thread_id} names no proxy url").into()
525
            })?
526
            .to_string();
527
        let model = grant
528
            .get("model")
529
            .and_then(|v| v.as_str())
530
            .ok_or_else(|| -> Failure {
531
                format!("the grant on thread {thread_id} names no model").into()
532
            })?
533
            .to_string();
534
535
        Ok(InferenceGrant {
536
            thread_id,
537
            token,
538
            proxy_url,
539
            model,
540
        })
541
    }
542
543
    /// Revoke this session's thread.
544
    ///
545
    /// A thread left open holds its grant's remaining budget. `DELETE
546
    /// /api/v1/threads/{id}` closes both and returns the grant's spend, which
547
    /// is why the reply is worth reading rather than discarding.
548
    pub async fn close(&mut self) -> Result<Option<TurnUsage>, Failure> {
549
        let Some(thread_id) = self.thread_id.take() else {
550
            return Ok(None);
551
        };
552
        self.last_grant = None;
553
        let url = format!("{}/threads/{thread_id}", self.api_base);
554
        let mut request = self.http.delete(&url).timeout(Duration::from_secs(30));
555
        if let Some(token) = &self.user_token {
556
            request = request.bearer_auth(token);
557
        }
558
        let resp = request.send().await.map_err(|error| -> Failure {
559
            format!("{url} could not be reached: {error}").into()
560
        })?;
561
        if !resp.status().is_success() {
198 562
            let status = resp.status();
199 563
            let body = resp.text().await.unwrap_or_default();
200
            Err(format!("{} refused the thread request: {} {}", url, status, snippet(&body)).into())
564
            return Err(
565
                format!("{url} refused the revocation: {status} {}", snippet(&body)).into(),
566
            );
201 567
        }
568
        let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
569
        let spent = body.get("grant").and_then(|g| g.get("spent"));
570
        Ok(spent.map(|spent| TurnUsage {
571
            prompt_tokens: 0,
572
            completion_tokens: 0,
573
            total_tokens: spent
574
                .get("total_tokens")
575
                .and_then(|v| v.as_u64())
576
                .unwrap_or(0),
577
        }))
202 578
    }
203 579
204
    pub async fn execute_turn<F>(&mut self, prompt: &str, mut chunk_callback: F) -> Result<String, Box<dyn std::error::Error + Send + Sync>>
580
    // ─────────────────────────────────────────────────────────── the turn
581
582
    pub async fn execute_turn<F>(
583
        &mut self,
584
        prompt: &str,
585
        chunk_callback: F,
586
    ) -> Result<String, Failure>
205 587
    where
206 588
        F: FnMut(&str) + Send + 'static,
207 589
    {

@@ -223,15 +605,45 @@ impl CoderRuntimeSession {

223 605
            tool_call_id: None,
224 606
        });
225 607
226
        let grant = self.create_thread().await?;
227
        self.last_grant = Some(grant.clone());
608
        self.last_usage = TurnUsage::default();
609
        self.last_reasoning.clear();
228 610
229
        let mut max_steps = 30;
230
        let mut final_answer = String::new();
611
        if self.lane.is_local() {
612
            self.run_local_turn(&tool_defs, chunk_callback).await
613
        } else {
614
            self.run_thread_turn(&tool_defs, chunk_callback).await
615
        }
616
    }
231 617
232
        while max_steps > 0 {
233
            max_steps -= 1;
618
    async fn run_thread_turn<F>(
619
        &mut self,
620
        tool_defs: &[ToolDefinition],
621
        mut chunk_callback: F,
622
    ) -> Result<String, Failure>
623
    where
624
        F: FnMut(&str) + Send + 'static,
625
    {
626
        if let Lane::Named(id) = self.lane.clone() {
627
            self.check_named(&id).await?;
628
        }
629
630
        // One thread per session, reused across its turns. Opening a fresh one
631
        // per turn threw away the conversation's own budget and left a trail of
632
        // open threads nothing ever revoked.
633
        let grant = match &self.last_grant {
634
            Some(grant) => grant.clone(),
635
            None => {
636
                let grant = self.create_thread().await?;
637
                self.thread_id = Some(grant.thread_id.clone());
638
                self.last_grant = Some(grant.clone());
639
                grant
640
            }
641
        };
642
        self.last_model = Some(grant.model.clone());
643
644
        let mut final_answer = String::new();
234 645
646
        for _ in 0..MAX_TOOL_STEPS {
235 647
            let req_body = serde_json::json!({
236 648
                "model": grant.model,
237 649
                "messages": self.messages,

@@ -248,9 +660,14 @@ impl CoderRuntimeSession {

248 660
249 661
            let mut headers = HeaderMap::new();
250 662
            headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
251
            headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {}", grant.token))?);
663
            headers.insert(
664
                AUTHORIZATION,
665
                HeaderValue::from_str(&format!("Bearer {}", grant.token))?,
666
            );
252 667
253
            let resp = self.http.post(&grant.proxy_url)
668
            let resp = self
669
                .http
670
                .post(&grant.proxy_url)
254 671
                .headers(headers)
255 672
                .json(&req_body)
256 673
                .send()

@@ -266,104 +683,691 @@ impl CoderRuntimeSession {

266 683
                    let status = r.status();
267 684
                    let body = r.text().await.unwrap_or_default();
268 685
                    return Err(format!(
269
                        "{} refused the turn: {} {}",
686
                        "{} refused the turn: {status} {}",
270 687
                        grant.proxy_url,
271
                        status,
272 688
                        snippet(&body)
273 689
                    )
274 690
                    .into());
275 691
                }
276 692
                Err(error) => {
277
                    return Err(
278
                        format!("{} could not be reached: {}", grant.proxy_url, error).into()
279
                    )
693
                    return Err(format!("{} could not be reached: {error}", grant.proxy_url).into())
280 694
                }
281 695
            };
282 696
283 697
            let mut stream = resp.bytes_stream().eventsource();
284
            let mut turn_content = String::new();
285
            let mut tool_calls_map: std::collections::BTreeMap<usize, (String, String, String)> = std::collections::BTreeMap::new();
698
            let mut step = StepAccumulator::default();
286 699
287 700
            while let Some(event) = stream.next().await {
288 701
                let event = match event {
289
                    Ok(ev) => ev,
290
                    Err(_) => break,
702
                    Ok(event) => event,
703
                    Err(error) => {
704
                        return Err(format!(
705
                            "the reply from {} stopped mid-stream: {error}",
706
                            grant.proxy_url
707
                        )
708
                        .into())
709
                    }
291 710
                };
292 711
                if event.data == "[DONE]" {
293 712
                    break;
294 713
                }
295
                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&event.data) {
296
                    if let Some(choices) = json.get("choices").and_then(|v| v.as_array()) {
297
                        if let Some(choice) = choices.get(0) {
298
                            if let Some(delta) = choice.get("delta") {
299
                                if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
300
                                    chunk_callback(content);
301
                                    turn_content.push_str(content);
302
                                }
303
                                if let Some(t_calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
304
                                    for tc in t_calls {
305
                                        let index = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
306
                                        let entry = tool_calls_map.entry(index).or_insert((String::new(), String::new(), String::new()));
307
                                        if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
308
                                            entry.0.push_str(id);
309
                                        }
310
                                        if let Some(f) = tc.get("function") {
311
                                            if let Some(name) = f.get("name").and_then(|v| v.as_str()) {
312
                                                entry.1.push_str(name);
313
                                            }
314
                                            if let Some(args) = f.get("arguments").and_then(|v| v.as_str()) {
315
                                                entry.2.push_str(args);
316
                                            }
317
                                        }
318
                                    }
319
                                }
320
                            }
321
                        }
322
                    }
323
                }
714
                let Ok(json) = serde_json::from_str::<serde_json::Value>(&event.data) else {
715
                    continue;
716
                };
717
                step.absorb_openai(&json, &mut chunk_callback);
324 718
            }
325 719
326
            if tool_calls_map.is_empty() {
327
                final_answer = turn_content;
720
            self.last_usage.add(step.usage);
721
            self.last_reasoning.push_str(&step.reasoning);
722
723
            if step.tool_calls.is_empty() {
724
                final_answer = step.content;
328 725
                break;
329 726
            }
727
            self.run_tools(step).await;
728
        }
330 729
331
            let mut recorded_tool_calls = Vec::new();
332
            for (_, (id, name, args_str)) in &tool_calls_map {
333
                recorded_tool_calls.push(serde_json::json!({
730
        Ok(final_answer)
731
    }
732
733
    /// Record the assistant's tool calls, run them, and put the results back.
734
    async fn run_tools(&mut self, step: StepAccumulator) {
735
        let recorded: Vec<serde_json::Value> = step
736
            .tool_calls
737
            .values()
738
            .map(|(id, name, args)| {
739
                serde_json::json!({
334 740
                    "id": id,
741
                    "type": "function",
742
                    "function": { "name": name, "arguments": args }
743
                })
744
            })
745
            .collect();
746
747
        self.messages.push(ChatMessage {
748
            role: "assistant".to_string(),
749
            content: if step.content.is_empty() {
750
                None
751
            } else {
752
                Some(step.content)
753
            },
754
            tool_calls: Some(recorded),
755
            tool_call_id: None,
756
        });
757
758
        for (id, name, args_str) in step.tool_calls.into_values() {
759
            let arguments: serde_json::Value =
760
                serde_json::from_str(&args_str).unwrap_or(serde_json::json!({}));
761
            let call = ToolCall {
762
                id: id.clone(),
763
                name: name.clone(),
764
                arguments,
765
            };
766
            let result = self.tools.execute_tool(&call).await;
767
            self.messages.push(ChatMessage {
768
                role: "tool".to_string(),
769
                content: Some(result.output),
770
                tool_calls: None,
771
                tool_call_id: Some(id),
772
            });
773
        }
774
    }
775
776
    // ────────────────────────────────────────────────────── the local lane
777
778
    /// The Ollama models installed here, most recently modified first.
779
    pub async fn installed_local_models(&self) -> Result<Vec<String>, Failure> {
780
        let url = format!("{}/api/tags", self.ollama_host.trim_end_matches('/'));
781
        let resp = self
782
            .http
783
            .get(&url)
784
            .timeout(Duration::from_secs(5))
785
            .send()
786
            .await
787
            .map_err(|error| -> Failure {
788
                format!(
789
                    "no Ollama server answered at {}: {error}. \
790
                     Start one, or choose a hosted lane.",
791
                    self.ollama_host
792
                )
793
                .into()
794
            })?;
795
        if !resp.status().is_success() {
796
            return Err(format!("{url} refused the model list: {}", resp.status()).into());
797
        }
798
        let body: serde_json::Value = resp.json().await?;
799
        let mut models: Vec<(String, String)> = body
800
            .get("models")
801
            .and_then(|v| v.as_array())
802
            .map(|models| {
803
                models
804
                    .iter()
805
                    .filter_map(|m| {
806
                        let name = m.get("name").and_then(|v| v.as_str())?;
807
                        let modified = m
808
                            .get("modified_at")
809
                            .and_then(|v| v.as_str())
810
                            .unwrap_or_default();
811
                        Some((name.to_string(), modified.to_string()))
812
                    })
813
                    .collect()
814
            })
815
            .unwrap_or_default();
816
        models.sort_by(|left, right| right.1.cmp(&left.1));
817
        Ok(models.into_iter().map(|(name, _)| name).collect())
818
    }
819
820
    /// The installed model a name means.
821
    ///
822
    /// Exact first, so a full name is never reinterpreted; then the family
823
    /// prefix, because a reader who pulled `qwen3.8:27b-mtp-q8_0` says
824
    /// `qwen3.8` and sending that unresolved earns `model not found` from a
825
    /// server that has the model. An empty name takes the most recently
826
    /// modified, which is the one they were last working with.
827
    async fn resolve_local_model(&self, wanted: &str) -> Result<String, Failure> {
828
        let installed = self.installed_local_models().await?;
829
        if installed.is_empty() {
830
            return Err(format!(
831
                "the Ollama server at {} has no models installed. \
832
                 Pull one with `ollama pull <model>`.",
833
                self.ollama_host
834
            )
835
            .into());
836
        }
837
        if wanted.is_empty() {
838
            return Ok(installed[0].clone());
839
        }
840
        if installed.iter().any(|m| m == wanted) {
841
            return Ok(wanted.to_string());
842
        }
843
        if let Some(family) = installed
844
            .iter()
845
            .find(|m| m.starts_with(&format!("{wanted}:")))
846
        {
847
            return Ok(family.clone());
848
        }
849
        Err(format!(
850
            "the Ollama server at {} does not have '{wanted}'. It has: {}.",
851
            self.ollama_host,
852
            installed.join(", ")
853
        )
854
        .into())
855
    }
856
857
    async fn run_local_turn<F>(
858
        &mut self,
859
        tool_defs: &[ToolDefinition],
860
        mut chunk_callback: F,
861
    ) -> Result<String, Failure>
862
    where
863
        F: FnMut(&str) + Send + 'static,
864
    {
865
        let Lane::Local(wanted) = self.lane.clone() else {
866
            return Err("run_local_turn was called off the local lane".into());
867
        };
868
        let model = self.resolve_local_model(&wanted).await?;
869
        self.last_model = Some(format!("ollama:{model}"));
870
        // No grant is minted here and none is invented: the model is on this
871
        // machine, so there is nothing for the server to authorise.
872
        self.last_grant = None;
873
874
        let url = format!("{}/api/chat", self.ollama_host.trim_end_matches('/'));
875
        let mut final_answer = String::new();
876
877
        for _ in 0..MAX_TOOL_STEPS {
878
            let req_body = serde_json::json!({
879
                "model": model,
880
                "messages": self.messages.iter().map(ollama_message).collect::<Vec<_>>(),
881
                "tools": tool_defs.iter().map(|t| serde_json::json!({
335 882
                    "type": "function",
336 883
                    "function": {
337
                        "name": name,
338
                        "arguments": args_str
884
                        "name": t.name,
885
                        "description": t.description,
886
                        "parameters": t.parameters
887
                    }
888
                })).collect::<Vec<_>>(),
889
                "stream": true
890
            });
891
892
            let resp = self.http.post(&url).json(&req_body).send().await;
893
            let resp = match resp {
894
                Ok(r) if r.status().is_success() => r,
895
                Ok(r) => {
896
                    let status = r.status();
897
                    let body = r.text().await.unwrap_or_default();
898
                    return Err(
899
                        format!("{url} refused the turn: {status} {}", snippet(&body)).into(),
900
                    );
901
                }
902
                Err(error) => return Err(format!("{url} could not be reached: {error}").into()),
903
            };
904
905
            // Ollama streams newline-delimited JSON rather than server-sent
906
            // events, so the frames are split here rather than by `Eventsource`.
907
            let mut bytes = resp.bytes_stream();
908
            let mut pending = String::new();
909
            let mut step = StepAccumulator::default();
910
911
            while let Some(chunk) = bytes.next().await {
912
                let chunk = chunk.map_err(|error| -> Failure {
913
                    format!("the reply from {url} stopped mid-stream: {error}").into()
914
                })?;
915
                pending.push_str(&String::from_utf8_lossy(&chunk));
916
                while let Some(newline) = pending.find('\n') {
917
                    let line: String = pending.drain(..=newline).collect();
918
                    let line = line.trim().to_string();
919
                    if line.is_empty() {
920
                        continue;
921
                    }
922
                    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
923
                        step.absorb_ollama(&json, &mut chunk_callback);
339 924
                    }
340
                }));
925
                }
926
            }
927
            let tail = pending.trim();
928
            if !tail.is_empty() {
929
                if let Ok(json) = serde_json::from_str::<serde_json::Value>(tail) {
930
                    step.absorb_ollama(&json, &mut chunk_callback);
931
                }
341 932
            }
342 933
343
            self.messages.push(ChatMessage {
344
                role: "assistant".to_string(),
345
                content: if turn_content.is_empty() { None } else { Some(turn_content) },
346
                tool_calls: Some(recorded_tool_calls),
347
                tool_call_id: None,
934
            self.last_usage.add(step.usage);
935
            self.last_reasoning.push_str(&step.reasoning);
936
937
            if step.tool_calls.is_empty() {
938
                final_answer = step.content;
939
                break;
940
            }
941
            self.run_tools(step).await;
942
        }
943
944
        Ok(final_answer)
945
    }
946
}
947
948
/// A thread left open holds its grant's remaining budget, and the interactive
949
/// session has no place to await a revocation on its way out. This is the
950
/// backstop: best effort, on whatever runtime is still up. `close` is the path
951
/// that can be awaited and proven, and it clears the id so this does not fire
952
/// twice.
953
impl Drop for CoderRuntimeSession {
954
    fn drop(&mut self) {
955
        let Some(thread_id) = self.thread_id.take() else {
956
            return;
957
        };
958
        let Ok(handle) = tokio::runtime::Handle::try_current() else {
959
            return;
960
        };
961
        let url = format!("{}/threads/{thread_id}", self.api_base);
962
        let token = self.user_token.clone();
963
        let http = self.http.clone();
964
        handle.spawn(async move {
965
            let mut request = http.delete(&url).timeout(Duration::from_secs(10));
966
            if let Some(token) = token {
967
                request = request.bearer_auth(token);
968
            }
969
            let _ = request.send().await;
970
        });
971
    }
972
}
973
974
/// What one call to a model produced, before the caller decides what it means.
975
#[derive(Default)]
976
struct StepAccumulator {
977
    content: String,
978
    reasoning: String,
979
    usage: TurnUsage,
980
    /// Keyed by the wire's `index` so fragments land in call order.
981
    tool_calls: std::collections::BTreeMap<usize, (String, String, String)>,
982
}
983
984
impl StepAccumulator {
985
    /// One OpenAI-shaped streaming chunk.
986
    ///
987
    /// `content`, `reasoning` and `tool_calls` interleave inside one `delta`,
988
    /// and `usage` arrives on a chunk of its own with an empty `choices` array
989
    /// — which is why usage is read before the choices are.
990
    fn absorb_openai<F: FnMut(&str)>(&mut self, json: &serde_json::Value, on_chunk: &mut F) {
991
        if let Some(usage) = json.get("usage") {
992
            self.usage.add(TurnUsage {
993
                prompt_tokens: field(usage, "prompt_tokens"),
994
                completion_tokens: field(usage, "completion_tokens"),
995
                total_tokens: field(usage, "total_tokens"),
996
            });
997
        }
998
999
        let Some(delta) = json
1000
            .get("choices")
1001
            .and_then(|v| v.as_array())
1002
            .and_then(|choices| choices.first())
1003
            .and_then(|choice| choice.get("delta"))
1004
        else {
1005
            return;
1006
        };
1007
1008
        if let Some(reasoning) = delta.get("reasoning").and_then(|v| v.as_str()) {
1009
            self.reasoning.push_str(reasoning);
1010
        }
1011
        if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
1012
            if !content.is_empty() {
1013
                on_chunk(content);
1014
                self.content.push_str(content);
1015
            }
1016
        }
1017
        if let Some(calls) = delta.get("tool_calls").and_then(|v| v.as_array()) {
1018
            for call in calls {
1019
                let index = call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
1020
                let entry = self.tool_calls.entry(index).or_default();
1021
                if let Some(id) = call.get("id").and_then(|v| v.as_str()) {
1022
                    entry.0.push_str(id);
1023
                }
1024
                if let Some(function) = call.get("function") {
1025
                    if let Some(name) = function.get("name").and_then(|v| v.as_str()) {
1026
                        entry.1.push_str(name);
1027
                    }
1028
                    if let Some(args) = function.get("arguments").and_then(|v| v.as_str()) {
1029
                        entry.2.push_str(args);
1030
                    }
1031
                }
1032
            }
1033
        }
1034
    }
1035
1036
    /// One Ollama-shaped streaming line.
1037
    ///
1038
    /// The counts arrive on the `done` line, the model's scratch work under
1039
    /// `thinking`, and a tool call whole rather than in fragments — with its
1040
    /// arguments as an object, where the proxy sends a string.
1041
    fn absorb_ollama<F: FnMut(&str)>(&mut self, json: &serde_json::Value, on_chunk: &mut F) {
1042
        if json.get("done").and_then(|v| v.as_bool()) == Some(true) {
1043
            self.usage.add(TurnUsage {
1044
                prompt_tokens: field(json, "prompt_eval_count"),
1045
                completion_tokens: field(json, "eval_count"),
1046
                total_tokens: field(json, "prompt_eval_count") + field(json, "eval_count"),
348 1047
            });
1048
        }
349 1049
350
            for (_, (id, name, args_str)) in tool_calls_map {
351
                let parsed_args: serde_json::Value = serde_json::from_str(&args_str).unwrap_or(serde_json::json!({}));
352
                let call = ToolCall {
353
                    id: id.clone(),
354
                    name: name.clone(),
355
                    arguments: parsed_args,
1050
        let Some(message) = json.get("message") else {
1051
            return;
1052
        };
1053
        if let Some(thinking) = message.get("thinking").and_then(|v| v.as_str()) {
1054
            self.reasoning.push_str(thinking);
1055
        }
1056
        if let Some(content) = message.get("content").and_then(|v| v.as_str()) {
1057
            if !content.is_empty() {
1058
                on_chunk(content);
1059
                self.content.push_str(content);
1060
            }
1061
        }
1062
        if let Some(calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
1063
            for call in calls {
1064
                let Some(function) = call.get("function") else {
1065
                    continue;
1066
                };
1067
                let index = self.tool_calls.len();
1068
                let name = function
1069
                    .get("name")
1070
                    .and_then(|v| v.as_str())
1071
                    .unwrap_or_default()
1072
                    .to_string();
1073
                let arguments = match function.get("arguments") {
1074
                    Some(serde_json::Value::String(raw)) => raw.clone(),
1075
                    Some(value) => value.to_string(),
1076
                    None => "{}".to_string(),
356 1077
                };
357
                let result = self.tools.execute_tool(&call).await;
358
                self.messages.push(ChatMessage {
359
                    role: "tool".to_string(),
360
                    content: Some(result.output),
361
                    tool_calls: None,
362
                    tool_call_id: Some(id),
363
                });
1078
                // Ollama mints no call id and the tool result has to name the
1079
                // call it answers, so one is made from the position. This is a
1080
                // local correlation key, not a server-issued identifier.
1081
                self.tool_calls
1082
                    .insert(index, (format!("local_{index}_{name}"), name, arguments));
364 1083
            }
365 1084
        }
1085
    }
1086
}
366 1087
367
        Ok(final_answer)
1088
fn field(value: &serde_json::Value, key: &str) -> u64 {
1089
    value.get(key).and_then(|v| v.as_u64()).unwrap_or(0)
1090
}
1091
1092
/// One message in the shape Ollama's chat API takes back.
1093
///
1094
/// The differences from the proxy's shape are small and all load-bearing:
1095
/// `arguments` is an object rather than a string, and a tool result is named by
1096
/// `tool_name` rather than by a call id Ollama never issued.
1097
fn ollama_message(message: &ChatMessage) -> serde_json::Value {
1098
    let mut out = serde_json::json!({
1099
        "role": message.role,
1100
        "content": message.content.clone().unwrap_or_default(),
1101
    });
1102
    if let Some(calls) = &message.tool_calls {
1103
        out["tool_calls"] = serde_json::Value::Array(
1104
            calls
1105
                .iter()
1106
                .map(|call| {
1107
                    let function = call
1108
                        .get("function")
1109
                        .cloned()
1110
                        .unwrap_or(serde_json::json!({}));
1111
                    let name = function
1112
                        .get("name")
1113
                        .cloned()
1114
                        .unwrap_or(serde_json::json!(""));
1115
                    let arguments = match function.get("arguments") {
1116
                        Some(serde_json::Value::String(raw)) => {
1117
                            serde_json::from_str(raw).unwrap_or(serde_json::json!({}))
1118
                        }
1119
                        Some(value) => value.clone(),
1120
                        None => serde_json::json!({}),
1121
                    };
1122
                    serde_json::json!({ "function": { "name": name, "arguments": arguments } })
1123
                })
1124
                .collect(),
1125
        );
1126
    }
1127
    if message.role == "tool" {
1128
        if let Some(id) = &message.tool_call_id {
1129
            // `local_<index>_<name>` — the name is what Ollama matches on.
1130
            let name = id.splitn(3, '_').nth(2).unwrap_or(id);
1131
            out["tool_name"] = serde_json::json!(name);
1132
        }
1133
    }
1134
    out
1135
}
1136
1137
#[cfg(test)]
1138
mod tests {
1139
    use super::*;
1140
1141
    #[test]
1142
    fn tier_names_map_onto_catalog_ids() {
1143
        assert_eq!(Lane::from_str("flash").model_id(), Some("gemini-3.7-flash"));
1144
        assert_eq!(Lane::from_str("pro").model_id(), Some("gpt-5.6-luna"));
1145
        assert_eq!(Lane::from_str("ox-alpha").model_id(), Some("ox-alpha"));
1146
        assert_eq!(Lane::from_str("auto").model_id(), None);
1147
    }
1148
1149
    /// The invented ids are gone. Every id this file can send is one the
1150
    /// deployment's catalog listed: `gemini-3.7-flash`, `ox-alpha`,
1151
    /// `gpt-5.6-luna`. `gemini-3.7-pro`, `claude-3-7-sonnet` and
1152
    /// `codex-preview` were never served by anything.
1153
    #[test]
1154
    fn no_lane_sends_a_model_id_that_was_made_up() {
1155
        const SERVED: [&str; 3] = ["gemini-3.7-flash", "ox-alpha", "gpt-5.6-luna"];
1156
        for (name, _) in TIERS {
1157
            let lane = Lane::from_str(name);
1158
            let id = lane.model_id().expect("a tier pins a model");
1159
            assert!(
1160
                SERVED.contains(&id),
1161
                "tier '{name}' opens on '{id}', which the catalog did not list"
1162
            );
1163
        }
1164
    }
1165
1166
    #[test]
1167
    fn an_unknown_name_is_carried_as_named_rather_than_becoming_the_default() {
1168
        assert_eq!(Lane::from_str("bogus"), Lane::Named("bogus".to_string()));
1169
        assert_eq!(Lane::from_str("claude"), Lane::Named("claude".to_string()));
1170
        assert_ne!(Lane::from_str("bogus"), Lane::OxAlpha);
1171
    }
1172
1173
    #[test]
1174
    fn the_local_lane_is_parsed_from_both_spellings() {
1175
        assert_eq!(Lane::from_str("local"), Lane::Local(String::new()));
1176
        assert_eq!(
1177
            Lane::from_str("ollama:qwen3:0.6b"),
1178
            Lane::Local("qwen3:0.6b".to_string())
1179
        );
1180
        assert!(Lane::from_str("ollama:qwen3:0.6b").is_local());
1181
        assert!(!Lane::from_str("flash").is_local());
1182
        // The local lane names no catalog model, because no grant carries it.
1183
        assert_eq!(Lane::from_str("ollama:qwen3").model_id(), None);
1184
    }
1185
1186
    #[test]
1187
    fn every_tier_has_a_name_and_a_label() {
1188
        assert_eq!(Lane::from_str("auto").tier(), Some("auto"));
1189
        assert_eq!(Lane::from_str("flash").tier(), Some("flash"));
1190
        assert_eq!(Lane::from_str("pro").tier(), Some("pro"));
1191
        assert_eq!(Lane::from_str("local").tier(), Some("local"));
1192
        assert_eq!(Lane::from_str("bogus").tier(), None);
1193
        assert_eq!(Lane::from_str("flash").label(), "Coder Flash");
1194
        assert_eq!(
1195
            Lane::from_str("ollama:qwen3:0.6b").label(),
1196
            "Coder Local (qwen3:0.6b)"
1197
        );
1198
    }
1199
1200
    #[test]
1201
    fn the_admitted_lane_sentence_names_every_way_in() {
1202
        let sentence = admitted_lanes();
1203
        for fragment in ["auto", "flash", "pro", "ox-alpha", "ollama:<model>"] {
1204
            assert!(
1205
                sentence.contains(fragment),
1206
                "'{fragment}' missing from: {sentence}"
1207
            );
1208
        }
1209
    }
1210
1211
    #[test]
1212
    fn usage_sums_across_the_steps_of_a_turn() {
1213
        let mut usage = TurnUsage::default();
1214
        assert!(!usage.reported());
1215
        usage.add(TurnUsage {
1216
            prompt_tokens: 99,
1217
            completion_tokens: 17,
1218
            total_tokens: 116,
1219
        });
1220
        usage.add(TurnUsage {
1221
            prompt_tokens: 140,
1222
            completion_tokens: 8,
1223
            total_tokens: 148,
1224
        });
1225
        assert!(usage.reported());
1226
        assert_eq!(usage.line(), "239 prompt + 25 completion = 264 tokens");
1227
    }
1228
1229
    /// The exact frames a live `ox-alpha` turn sent, in order: reasoning
1230
    /// first, then the answer, then usage on a chunk with no choices at all.
1231
    #[test]
1232
    fn a_real_proxy_stream_yields_the_answer_the_reasoning_and_the_counts() {
1233
        let frames = [
1234
            r#"{"choices":[{"delta":{"reasoning":"The user wants "},"index":0}],"model":"ox-alpha"}"#,
1235
            r#"{"choices":[{"delta":{"reasoning":"PONG."},"index":0}],"model":"ox-alpha"}"#,
1236
            r#"{"choices":[{"delta":{"content":"PONG"},"index":0}],"model":"ox-alpha"}"#,
1237
            r#"{"choices":[{"delta":{},"finish_reason":"stop","index":0}],"model":"ox-alpha"}"#,
1238
            r#"{"choices":[],"model":"ox-alpha","usage":{"completion_tokens":17,"prompt_tokens":99,"total_tokens":116}}"#,
1239
        ];
1240
        let mut step = StepAccumulator::default();
1241
        let mut streamed = String::new();
1242
        {
1243
            let mut sink = |chunk: &str| streamed.push_str(chunk);
1244
            for frame in frames {
1245
                step.absorb_openai(&serde_json::from_str(frame).unwrap(), &mut sink);
1246
            }
1247
        }
1248
1249
        assert_eq!(step.content, "PONG");
1250
        // Reasoning is parsed and kept, and stays off the transcript.
1251
        assert_eq!(step.reasoning, "The user wants PONG.");
1252
        assert_eq!(streamed, "PONG", "reasoning leaked into the answer");
1253
        assert_eq!(step.usage.total_tokens, 116);
1254
        assert_eq!(step.usage.prompt_tokens, 99);
1255
        assert_eq!(step.usage.completion_tokens, 17);
1256
    }
1257
1258
    #[test]
1259
    fn tool_call_fragments_are_joined_by_their_index() {
1260
        let frames = [
1261
            r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"read_","arguments":"{\"path\":"}}]}}]}"#,
1262
            r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"file","arguments":"\"a.txt\"}"}}]}}]}"#,
1263
            r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"ls","arguments":"{}"}}]}}]}"#,
1264
        ];
1265
        let mut step = StepAccumulator::default();
1266
        let mut sink = |_: &str| {};
1267
        for frame in frames {
1268
            step.absorb_openai(&serde_json::from_str(frame).unwrap(), &mut sink);
1269
        }
1270
        let calls: Vec<_> = step.tool_calls.into_values().collect();
1271
        assert_eq!(calls.len(), 2);
1272
        assert_eq!(calls[0].0, "call_a");
1273
        assert_eq!(calls[0].1, "read_file");
1274
        assert_eq!(calls[0].2, r#"{"path":"a.txt"}"#);
1275
        assert_eq!(calls[1].1, "ls");
1276
    }
1277
1278
    /// The exact frames a live `qwen3:0.6b` turn sent.
1279
    #[test]
1280
    fn a_real_ollama_stream_yields_the_answer_and_the_counts() {
1281
        let frames = [
1282
            r#"{"model":"qwen3:0.6b","message":{"role":"assistant","thinking":"short"},"done":false}"#,
1283
            r#"{"model":"qwen3:0.6b","message":{"role":"assistant","content":"PO"},"done":false}"#,
1284
            r#"{"model":"qwen3:0.6b","message":{"role":"assistant","content":"NG"},"done":false}"#,
1285
            r#"{"model":"qwen3:0.6b","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","prompt_eval_count":19,"eval_count":28}"#,
1286
        ];
1287
        let mut step = StepAccumulator::default();
1288
        let mut streamed = String::new();
1289
        {
1290
            let mut sink = |chunk: &str| streamed.push_str(chunk);
1291
            for frame in frames {
1292
                step.absorb_ollama(&serde_json::from_str(frame).unwrap(), &mut sink);
1293
            }
1294
        }
1295
1296
        assert_eq!(streamed, "PONG");
1297
        assert_eq!(step.content, "PONG");
1298
        assert_eq!(step.reasoning, "short");
1299
        assert_eq!(step.usage.prompt_tokens, 19);
1300
        assert_eq!(step.usage.completion_tokens, 28);
1301
        assert_eq!(step.usage.total_tokens, 47);
1302
    }
1303
1304
    #[test]
1305
    fn an_ollama_tool_call_arrives_whole_with_object_arguments() {
1306
        let frame = r#"{"message":{"role":"assistant","tool_calls":[{"function":{"name":"read_file","arguments":{"path":"a.txt"}}}]},"done":false}"#;
1307
        let mut step = StepAccumulator::default();
1308
        let mut sink = |_: &str| {};
1309
        step.absorb_ollama(&serde_json::from_str(frame).unwrap(), &mut sink);
1310
        let calls: Vec<_> = step.tool_calls.into_values().collect();
1311
        assert_eq!(calls.len(), 1);
1312
        assert_eq!(calls[0].1, "read_file");
1313
        let parsed: serde_json::Value = serde_json::from_str(&calls[0].2).unwrap();
1314
        assert_eq!(parsed["path"], "a.txt");
1315
    }
1316
1317
    #[test]
1318
    fn a_tool_result_goes_back_to_ollama_named_by_its_tool() {
1319
        let message = ChatMessage {
1320
            role: "tool".to_string(),
1321
            content: Some("hello".to_string()),
1322
            tool_calls: None,
1323
            tool_call_id: Some("local_0_read_file".to_string()),
1324
        };
1325
        let wire = ollama_message(&message);
1326
        assert_eq!(wire["role"], "tool");
1327
        assert_eq!(wire["content"], "hello");
1328
        assert_eq!(wire["tool_name"], "read_file");
1329
    }
1330
1331
    #[test]
1332
    fn an_assistant_tool_call_reaches_ollama_with_its_arguments_parsed() {
1333
        let message = ChatMessage {
1334
            role: "assistant".to_string(),
1335
            content: None,
1336
            tool_calls: Some(vec![serde_json::json!({
1337
                "id": "call_a",
1338
                "type": "function",
1339
                "function": { "name": "read_file", "arguments": "{\"path\":\"a.txt\"}" }
1340
            })]),
1341
            tool_call_id: None,
1342
        };
1343
        let wire = ollama_message(&message);
1344
        assert_eq!(wire["tool_calls"][0]["function"]["name"], "read_file");
1345
        assert_eq!(
1346
            wire["tool_calls"][0]["function"]["arguments"]["path"], "a.txt",
1347
            "Ollama takes arguments as an object, not as the proxy's string"
1348
        );
1349
    }
1350
1351
    /// The local lane's system prompt must not promise a metered proxy, and the
1352
    /// thread lane's must not promise that nothing leaves the machine.
1353
    #[test]
1354
    fn each_lane_tells_the_model_where_it_is_running() {
1355
        let local = CoderRuntimeSession::new(
1356
            Lane::Local("qwen3".to_string()),
1357
            Some("http://127.0.0.1:1/api/v1".to_string()),
1358
            None,
1359
            HarnessToolRegistry::new(Some(std::env::temp_dir())),
1360
        );
1361
        assert!(local.build_system_prompt(&[]).contains("on this machine"));
1362
1363
        let hosted = CoderRuntimeSession::new(
1364
            Lane::OxAlpha,
1365
            Some("http://127.0.0.1:1/api/v1".to_string()),
1366
            None,
1367
            HarnessToolRegistry::new(Some(std::env::temp_dir())),
1368
        );
1369
        assert!(hosted
1370
            .build_system_prompt(&[])
1371
            .contains("OpenAgents inference proxy"));
368 1372
    }
369 1373
}
crates/openagents-cli/tests/runtime_test.rs added +876

@@ -0,0 +1,876 @@

1
//! What the coder runtime does on the wire, proved against real sockets.
2
//!
3
//! Every test here runs the production `CoderRuntimeSession` against a server
4
//! this file starts on a real port. Nothing is mocked between the session and
5
//! the socket, so a passing test means the bytes were right.
6
//!
7
//! Two things these tests are careful about, because the versions they replace
8
//! were not:
9
//!
10
//! - **Streaming is proved with a clock.** Asserting that the reply eventually
11
//!   contains the text is satisfied by a response assembled in one block at the
12
//!   end. So the server holds the rest of the stream open, and the assertion is
13
//!   that a chunk reached the caller measurably *before* the turn returned.
14
//! - **A failure is proved to be a failure.** `assert!(result.is_ok())` passed
15
//!   precisely while this runtime answered every refusal with a made-up grant
16
//!   and a sentence about an offline fallback. These assert on `Err` and on
17
//!   what it says.
18
19
use openagents_cli::runtime::{CoderRuntimeSession, Lane, TurnUsage};
20
use openagents_cli::tools::HarnessToolRegistry;
21
use std::sync::{Arc, Mutex};
22
use std::time::{Duration, Instant};
23
use tokio::io::{AsyncReadExt, AsyncWriteExt};
24
25
// ─────────────────────────────────────────────────────────────── the server
26
27
/// What one connection should be answered with.
28
enum Reply {
29
    /// Status line, content type, and body.
30
    Body(u16, &'static str, String),
31
    /// An event stream: each frame, with an optional pause before one of them.
32
    Sse(Vec<String>, Option<(usize, Duration)>),
33
    /// Newline-delimited JSON, the shape Ollama streams.
34
    Ndjson(Vec<String>, Option<(usize, Duration)>),
35
}
36
37
/// A server that records what it was asked and answers from a script.
38
struct Stub {
39
    base: String,
40
    requests: Arc<Mutex<Vec<String>>>,
41
}
42
43
impl Stub {
44
    /// Every request this stub has taken, headers and body, most recent last.
45
    fn requests(&self) -> Vec<String> {
46
        self.requests.lock().unwrap().clone()
47
    }
48
49
    fn request_lines(&self) -> Vec<String> {
50
        self.requests()
51
            .iter()
52
            .map(|r| r.lines().next().unwrap_or_default().to_string())
53
            .collect()
54
    }
55
}
56
57
/// Start a stub whose handler picks a reply from the request text.
58
fn start<H>(handler: H) -> Stub
59
where
60
    H: Fn(&str, &str) -> Reply + Send + Sync + 'static,
61
{
62
    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
63
    listener.set_nonblocking(true).unwrap();
64
    let port = listener.local_addr().unwrap().port();
65
    let listener = tokio::net::TcpListener::from_std(listener).unwrap();
66
    let origin = format!("http://127.0.0.1:{port}");
67
    let base = format!("{origin}/api/v1");
68
    let requests = Arc::new(Mutex::new(Vec::new()));
69
70
    let seen = Arc::clone(&requests);
71
    let handler = Arc::new(handler);
72
    tokio::spawn(async move {
73
        loop {
74
            let Ok((mut socket, _)) = listener.accept().await else {
75
                return;
76
            };
77
            let seen = Arc::clone(&seen);
78
            let handler = Arc::clone(&handler);
79
            let origin = origin.clone();
80
            tokio::spawn(async move {
81
                let Some(request) = read_request(&mut socket).await else {
82
                    return;
83
                };
84
                seen.lock().unwrap().push(request.clone());
85
                match handler(&request, &origin) {
86
                    Reply::Body(status, content_type, body) => {
87
                        let head = format!(
88
                            "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
89
                             content-length: {}\r\nconnection: close\r\n\r\n",
90
                            body.len()
91
                        );
92
                        let _ = socket.write_all(head.as_bytes()).await;
93
                        let _ = socket.write_all(body.as_bytes()).await;
94
                    }
95
                    Reply::Sse(frames, pause) => {
96
                        let _ = socket
97
                            .write_all(
98
                                b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
99
                                  connection: close\r\n\r\n",
100
                            )
101
                            .await;
102
                        let _ = socket.flush().await;
103
                        for (index, frame) in frames.iter().enumerate() {
104
                            if let Some((at, delay)) = pause {
105
                                if index == at {
106
                                    tokio::time::sleep(delay).await;
107
                                }
108
                            }
109
                            let _ = socket
110
                                .write_all(format!("data: {frame}\n\n").as_bytes())
111
                                .await;
112
                            let _ = socket.flush().await;
113
                        }
114
                        let _ = socket.write_all(b"data: [DONE]\n\n").await;
115
                        let _ = socket.flush().await;
116
                    }
117
                    Reply::Ndjson(lines, pause) => {
118
                        let _ = socket
119
                            .write_all(
120
                                b"HTTP/1.1 200 OK\r\ncontent-type: application/x-ndjson\r\n\
121
                                  connection: close\r\n\r\n",
122
                            )
123
                            .await;
124
                        let _ = socket.flush().await;
125
                        for (index, line) in lines.iter().enumerate() {
126
                            if let Some((at, delay)) = pause {
127
                                if index == at {
128
                                    tokio::time::sleep(delay).await;
129
                                }
130
                            }
131
                            let _ = socket.write_all(format!("{line}\n").as_bytes()).await;
132
                            let _ = socket.flush().await;
133
                        }
134
                    }
135
                }
136
                let _ = socket.flush().await;
137
                let _ = socket.shutdown().await;
138
            });
139
        }
140
    });
141
142
    Stub { base, requests }
143
}
144
145
async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<String> {
146
    let mut request = Vec::new();
147
    let mut buffer = [0u8; 4096];
148
    loop {
149
        let read = socket.read(&mut buffer).await.ok()?;
150
        if read == 0 {
151
            break;
152
        }
153
        request.extend_from_slice(&buffer[..read]);
154
        let text = String::from_utf8_lossy(&request);
155
        if let Some(headers_end) = text.find("\r\n\r\n") {
156
            let length = text
157
                .lines()
158
                .find_map(|line| {
159
                    line.strip_prefix("content-length: ")
160
                        .or_else(|| line.strip_prefix("Content-Length: "))
161
                })
162
                .and_then(|value| value.trim().parse::<usize>().ok())
163
                .unwrap_or(0);
164
            if request.len() >= headers_end + 4 + length {
165
                break;
166
            }
167
        }
168
    }
169
    Some(String::from_utf8_lossy(&request).to_string())
170
}
171
172
fn grant_body(origin: &str, model: &str) -> String {
173
    format!(
174
        r#"{{"thread":{{"id":"th_test"}},"grant":{{"token":"sig_test","url":"{origin}/api/inference/proxy","model":"{model}"}}}}"#
175
    )
176
}
177
178
fn frame(json: serde_json::Value) -> String {
179
    json.to_string()
180
}
181
182
fn tools() -> HarnessToolRegistry {
183
    HarnessToolRegistry::new(Some(std::env::temp_dir()))
184
}
185
186
fn session(lane: Lane, base: String) -> CoderRuntimeSession {
187
    CoderRuntimeSession::new(lane, Some(base), Some("oat_test".to_string()), tools())
188
}
189
190
/// A port nothing listens on, so a connection to it is refused at once.
191
const DEAD: &str = "http://127.0.0.1:1/api/v1";
192
193
// ──────────────────────────────────────────────────────── the streaming clock
194
195
/// The reply arrives while the turn is still open, not assembled at the end.
196
///
197
/// The server sends `PO`, waits 700ms, then sends `NG`. The assertion is on
198
/// the gap between the first chunk and the return: a batched response would
199
/// deliver both at once and close that gap to nothing.
200
#[tokio::test]
201
async fn a_chunk_reaches_the_caller_before_the_turn_returns() {
202
    let stub = start(|request, origin| {
203
        if request.starts_with("POST /api/v1/threads") {
204
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
205
        }
206
        Reply::Sse(
207
            vec![
208
                frame(serde_json::json!({"choices":[{"delta":{"content":"PO"}}]})),
209
                frame(serde_json::json!({"choices":[{"delta":{"content":"NG"}}]})),
210
            ],
211
            Some((1, Duration::from_millis(700))),
212
        )
213
    });
214
215
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, Instant)>();
216
    let mut session = session(Lane::OxAlpha, stub.base.clone());
217
218
    let started = Instant::now();
219
    let answer = session
220
        .execute_turn("say pong", move |chunk| {
221
            let _ = tx.send((chunk.to_string(), Instant::now()));
222
        })
223
        .await
224
        .expect("the turn failed");
225
    let returned = Instant::now();
226
227
    let mut chunks = Vec::new();
228
    while let Ok(chunk) = rx.try_recv() {
229
        chunks.push(chunk);
230
    }
231
232
    assert_eq!(
233
        chunks
234
            .iter()
235
            .map(|(text, _)| text.as_str())
236
            .collect::<Vec<_>>(),
237
        vec!["PO", "NG"],
238
        "the reply did not arrive in pieces"
239
    );
240
    assert_eq!(answer, "PONG");
241
242
    let first = chunks[0].1;
243
    let lead = returned.duration_since(first);
244
    assert!(
245
        lead >= Duration::from_millis(500),
246
        "the first chunk landed only {lead:?} before the turn returned, so this run \
247
         does not distinguish streaming from a batched reply (turn took {:?})",
248
        returned.duration_since(started)
249
    );
250
}
251
252
// ─────────────────────────────────────────────────────────────── the metering
253
254
/// A turn reports what it spent, taken from the server's own usage chunk.
255
#[tokio::test]
256
async fn a_turn_reports_the_tokens_the_server_counted() {
257
    let stub = start(|request, origin| {
258
        if request.starts_with("POST /api/v1/threads") {
259
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
260
        }
261
        Reply::Sse(
262
            vec![
263
                frame(serde_json::json!({"choices":[{"delta":{"reasoning":"thinking"}}]})),
264
                frame(serde_json::json!({"choices":[{"delta":{"content":"PONG"}}]})),
265
                frame(serde_json::json!({
266
                    "choices": [],
267
                    "usage": {"prompt_tokens": 99, "completion_tokens": 17, "total_tokens": 116}
268
                })),
269
            ],
270
            None,
271
        )
272
    });
273
274
    let mut session = session(Lane::OxAlpha, stub.base.clone());
275
    let answer = session.execute_turn("say pong", |_| {}).await.unwrap();
276
277
    assert_eq!(answer, "PONG");
278
    assert_eq!(
279
        session.last_usage,
280
        TurnUsage {
281
            prompt_tokens: 99,
282
            completion_tokens: 17,
283
            total_tokens: 116
284
        }
285
    );
286
    assert_eq!(
287
        session.last_usage.line(),
288
        "99 prompt + 17 completion = 116 tokens"
289
    );
290
    // The reasoning was parsed and kept off the answer.
291
    assert_eq!(session.last_reasoning, "thinking");
292
}
293
294
// ────────────────────────────────────────────────────────────── the lifecycle
295
296
/// The thread the session opened is revoked, and the request is the server's.
297
#[tokio::test]
298
async fn the_session_revokes_its_thread_when_it_closes() {
299
    let stub = start(|request, origin| {
300
        if request.starts_with("POST /api/v1/threads") {
301
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
302
        }
303
        if request.starts_with("DELETE /api/v1/threads/") {
304
            return Reply::Body(
305
                200,
306
                "application/json",
307
                r#"{"grant":{"status":"revoked","spent":{"calls":1,"total_tokens":116}},
308
                    "thread":{"id":"th_test","status":"cancelled"}}"#
309
                    .to_string(),
310
            );
311
        }
312
        Reply::Sse(
313
            vec![frame(
314
                serde_json::json!({"choices":[{"delta":{"content":"ok"}}]}),
315
            )],
316
            None,
317
        )
318
    });
319
320
    let mut session = session(Lane::OxAlpha, stub.base.clone());
321
    session.execute_turn("hello", |_| {}).await.unwrap();
322
    let spent = session.close().await.expect("the revocation failed");
323
324
    let lines = stub.request_lines();
325
    assert!(
326
        lines
327
            .iter()
328
            .any(|line| line.starts_with("DELETE /api/v1/threads/th_test")),
329
        "no revocation was sent; the stub saw {lines:?}"
330
    );
331
    assert_eq!(spent.map(|usage| usage.total_tokens), Some(116));
332
333
    // The revocation carried the account's own credential, not the grant's.
334
    let delete = stub
335
        .requests()
336
        .into_iter()
337
        .find(|r| r.starts_with("DELETE"))
338
        .unwrap();
339
    assert!(delete.contains("Bearer oat_test"), "{delete}");
340
341
    // And it does not fire twice.
342
    assert!(session.close().await.unwrap().is_none());
343
    assert_eq!(
344
        stub.request_lines()
345
            .iter()
346
            .filter(|line| line.starts_with("DELETE"))
347
            .count(),
348
        1
349
    );
350
}
351
352
/// One thread serves the whole session rather than one per turn.
353
#[tokio::test]
354
async fn a_second_turn_reuses_the_first_turns_thread() {
355
    let stub = start(|request, origin| {
356
        if request.starts_with("POST /api/v1/threads") {
357
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
358
        }
359
        Reply::Sse(
360
            vec![frame(
361
                serde_json::json!({"choices":[{"delta":{"content":"ok"}}]}),
362
            )],
363
            None,
364
        )
365
    });
366
367
    let mut session = session(Lane::OxAlpha, stub.base.clone());
368
    session.execute_turn("one", |_| {}).await.unwrap();
369
    session.execute_turn("two", |_| {}).await.unwrap();
370
371
    let opens = stub
372
        .request_lines()
373
        .iter()
374
        .filter(|line| line.starts_with("POST /api/v1/threads"))
375
        .count();
376
    assert_eq!(opens, 1, "each turn opened its own thread");
377
}
378
379
// ────────────────────────────────────────────────────────────── the lane gate
380
381
/// A lane nothing admits is refused by name, with what the deployment serves.
382
///
383
/// `Lane::from_str` used to answer any unrecognised name with `Lane::OxAlpha`,
384
/// so `--lane bogus` ran the default and said nothing about it.
385
#[tokio::test]
386
async fn an_unadmitted_lane_is_refused_with_the_ones_that_work() {
387
    let stub = start(|request, _origin| {
388
        if request.starts_with("GET /api/v1/models") {
389
            return Reply::Body(
390
                200,
391
                "application/json",
392
                r#"{"default":"gemini-3.7-flash","models":[
393
                    {"id":"gemini-3.7-flash","availability":"available","default":true},
394
                    {"id":"ox-alpha","availability":"available","default":false}]}"#
395
                    .to_string(),
396
            );
397
        }
398
        Reply::Body(500, "application/json", "{}".to_string())
399
    });
400
401
    let mut session = session(Lane::from_str("bogus"), stub.base.clone());
402
    let error = session
403
        .execute_turn("hello", |_| {})
404
        .await
405
        .expect_err("an unadmitted lane ran a turn");
406
    let message = error.to_string();
407
408
    assert!(message.contains("bogus"), "{message}");
409
    assert!(message.contains("ox-alpha"), "{message}");
410
    assert!(message.contains("gemini-3.7-flash"), "{message}");
411
    for tier in ["auto", "flash", "pro", "ollama:<model>"] {
412
        assert!(
413
            message.contains(tier),
414
            "the refusal does not name {tier}: {message}"
415
        );
416
    }
417
    // And no thread was opened for a lane that cannot run.
418
    assert!(
419
        !stub
420
            .request_lines()
421
            .iter()
422
            .any(|line| line.starts_with("POST /api/v1/threads")),
423
        "a refused lane still opened a thread"
424
    );
425
}
426
427
/// A model in the catalog whose provider is not configured is refused too.
428
#[tokio::test]
429
async fn a_served_but_unconfigured_model_is_refused() {
430
    let stub = start(|request, _origin| {
431
        if request.starts_with("GET /api/v1/models") {
432
            return Reply::Body(
433
                200,
434
                "application/json",
435
                r#"{"models":[
436
                    {"id":"quiet-one","availability":"unavailable","default":false},
437
                    {"id":"ox-alpha","availability":"available","default":true}]}"#
438
                    .to_string(),
439
            );
440
        }
441
        Reply::Body(500, "application/json", "{}".to_string())
442
    });
443
444
    let mut session = session(Lane::from_str("quiet-one"), stub.base.clone());
445
    let message = session
446
        .execute_turn("hello", |_| {})
447
        .await
448
        .expect_err("an unavailable model ran a turn")
449
        .to_string();
450
    assert!(message.contains("provider is not configured"), "{message}");
451
    assert!(message.contains("ox-alpha"), "{message}");
452
}
453
454
/// A tier opens on the catalog id it names, and the grant's model is reported.
455
#[tokio::test]
456
async fn a_tier_opens_its_thread_on_the_model_it_names() {
457
    let stub = start(|request, origin| {
458
        if request.starts_with("POST /api/v1/threads") {
459
            return Reply::Body(
460
                200,
461
                "application/json",
462
                grant_body(origin, "gemini-3.7-flash"),
463
            );
464
        }
465
        Reply::Sse(
466
            vec![frame(
467
                serde_json::json!({"choices":[{"delta":{"content":"hi"}}]}),
468
            )],
469
            None,
470
        )
471
    });
472
473
    let mut session = session(Lane::from_str("flash"), stub.base.clone());
474
    session.execute_turn("hello", |_| {}).await.unwrap();
475
476
    let open = stub
477
        .requests()
478
        .into_iter()
479
        .find(|r| r.starts_with("POST /api/v1/threads"))
480
        .unwrap();
481
    assert!(open.contains(r#""model":"gemini-3.7-flash""#), "{open}");
482
    // The lane the server admits, not a model name in the lane field.
483
    assert!(open.contains(r#""lane":"thread""#), "{open}");
484
    assert_eq!(session.last_model.as_deref(), Some("gemini-3.7-flash"));
485
    assert_eq!(
486
        session.last_grant.as_ref().map(|g| g.model.as_str()),
487
        Some("gemini-3.7-flash")
488
    );
489
}
490
491
/// `auto` names no model, so the deployment's own default answers.
492
#[tokio::test]
493
async fn the_auto_lane_names_no_model_at_all() {
494
    let stub = start(|request, origin| {
495
        if request.starts_with("POST /api/v1/threads") {
496
            return Reply::Body(
497
                200,
498
                "application/json",
499
                grant_body(origin, "gemini-3.7-flash"),
500
            );
501
        }
502
        Reply::Sse(
503
            vec![frame(
504
                serde_json::json!({"choices":[{"delta":{"content":"hi"}}]}),
505
            )],
506
            None,
507
        )
508
    });
509
510
    let mut session = session(Lane::from_str("auto"), stub.base.clone());
511
    session.execute_turn("hello", |_| {}).await.unwrap();
512
513
    let open = stub
514
        .requests()
515
        .into_iter()
516
        .find(|r| r.starts_with("POST /api/v1/threads"))
517
        .unwrap();
518
    let body = open.split("\r\n\r\n").nth(1).unwrap_or_default();
519
    assert!(!body.contains("\"model\""), "auto pinned a model: {body}");
520
    // What answered is still reported, because the grant said so.
521
    assert_eq!(session.last_model.as_deref(), Some("gemini-3.7-flash"));
522
}
523
524
// ───────────────────────────────────────────────────────────── no fabrication
525
526
/// A refused thread is an error, not a grant this process invented.
527
#[tokio::test]
528
async fn a_refused_thread_ends_the_turn() {
529
    let stub = start(|_request, _origin| {
530
        Reply::Body(
531
            422,
532
            "application/json",
533
            r#"{"errors":{"model":["\"bogus\" is not an admitted model."]},"status":422}"#
534
                .to_string(),
535
        )
536
    });
537
538
    let mut session = session(Lane::OxAlpha, stub.base.clone());
539
    let message = session
540
        .execute_turn("hello", |_| {})
541
        .await
542
        .expect_err("a 422 thread request produced a completed turn")
543
        .to_string();
544
545
    assert!(message.contains("422"), "{message}");
546
    assert!(message.contains("not an admitted model"), "{message}");
547
    assert!(!message.contains("offline fallback"), "{message}");
548
    assert!(session.last_grant.is_none(), "a grant was invented anyway");
549
}
550
551
/// A thread that opens without a grant is an error too.
552
///
553
/// The local lane opens exactly such a thread — it mints no grant, because
554
/// nothing carries its model to a provider. Reaching the proxy with a token
555
/// taken from that reply is what the old code did with the caller's PAT.
556
#[tokio::test]
557
async fn a_thread_with_no_grant_ends_the_turn() {
558
    let stub = start(|_request, _origin| {
559
        Reply::Body(
560
            200,
561
            "application/json",
562
            r#"{"thread":{"id":"th_test","status":"open"}}"#.to_string(),
563
        )
564
    });
565
566
    let mut session = session(Lane::OxAlpha, stub.base.clone());
567
    let message = session
568
        .execute_turn("hello", |_| {})
569
        .await
570
        .expect_err("a thread with no grant produced a completed turn")
571
        .to_string();
572
    assert!(message.contains("minted no inference grant"), "{message}");
573
}
574
575
/// A refused proxy call is an error, with the server's own words.
576
#[tokio::test]
577
async fn a_refused_proxy_call_ends_the_turn() {
578
    let stub = start(|request, origin| {
579
        if request.starts_with("POST /api/v1/threads") {
580
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
581
        }
582
        Reply::Body(
583
            402,
584
            "application/json",
585
            r#"{"error":"the grant's budget is spent"}"#.to_string(),
586
        )
587
    });
588
589
    let mut session = session(Lane::OxAlpha, stub.base.clone());
590
    let message = session
591
        .execute_turn("hello", |_| {})
592
        .await
593
        .expect_err("a 402 from the proxy produced a completed turn")
594
        .to_string();
595
    assert!(message.contains("402"), "{message}");
596
    assert!(message.contains("budget is spent"), "{message}");
597
}
598
599
/// An unreachable proxy is an error, not an empty success.
600
#[tokio::test]
601
async fn an_unreachable_host_ends_the_turn() {
602
    let mut session = session(Lane::OxAlpha, DEAD.to_string());
603
    let message = session
604
        .execute_turn("hello", |_| {})
605
        .await
606
        .expect_err("an unreachable host produced a completed turn")
607
        .to_string();
608
    assert!(!message.is_empty());
609
    assert!(session.last_grant.is_none());
610
}
611
612
// ─────────────────────────────────────────────────────────── the tool loop
613
614
/// The model's tool call runs, and its result goes back on the next request.
615
#[tokio::test]
616
async fn a_tool_call_runs_and_its_output_returns_to_the_model() {
617
    let round = Arc::new(std::sync::atomic::AtomicUsize::new(0));
618
    let counter = Arc::clone(&round);
619
    let stub = start(move |request, origin| {
620
        if request.starts_with("POST /api/v1/threads") {
621
            return Reply::Body(200, "application/json", grant_body(origin, "ox-alpha"));
622
        }
623
        let step = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
624
        if step == 0 {
625
            let call = serde_json::json!({"choices": [{"delta": {"tool_calls": [{
626
                "index": 0,
627
                "id": "call_a",
628
                "function": {"name": "shell", "arguments": "{\"command\":\"echo marker-9f3\"}"}
629
            }]}}]});
630
            return Reply::Sse(vec![frame(call)], None);
631
        }
632
        Reply::Sse(
633
            vec![frame(
634
                serde_json::json!({"choices":[{"delta":{"content":"the marker is marker-9f3"}}]}),
635
            )],
636
            None,
637
        )
638
    });
639
640
    let mut session = session(Lane::OxAlpha, stub.base.clone());
641
    let answer = session.execute_turn("run it", |_| {}).await.unwrap();
642
    assert_eq!(answer, "the marker is marker-9f3");
643
644
    let proxy_calls: Vec<String> = stub
645
        .requests()
646
        .into_iter()
647
        .filter(|r| r.starts_with("POST /api/inference/proxy"))
648
        .collect();
649
    assert_eq!(proxy_calls.len(), 2, "the loop did not take a second step");
650
    assert!(
651
        proxy_calls[1].contains("marker-9f3") && proxy_calls[1].contains(r#""role":"tool""#),
652
        "the tool's output did not go back as a tool message:\n{}",
653
        proxy_calls[1]
654
    );
655
    assert!(
656
        proxy_calls[1].contains("call_a"),
657
        "the tool result did not name the call it answers"
658
    );
659
}
660
661
// ───────────────────────────────────────────────────────────── the local lane
662
663
fn ollama_stub() -> Stub {
664
    start(|request, _origin| {
665
        if request.starts_with("GET /api/tags") {
666
            return Reply::Body(
667
                200,
668
                "application/json",
669
                r#"{"models":[
670
                    {"name":"older:1b","modified_at":"2026-01-01T00:00:00Z"},
671
                    {"name":"qwen3:0.6b","modified_at":"2026-08-25T15:08:15Z"}]}"#
672
                    .to_string(),
673
            );
674
        }
675
        Reply::Ndjson(
676
            vec![
677
                serde_json::json!({"message":{"role":"assistant","thinking":"brief"},"done":false})
678
                    .to_string(),
679
                serde_json::json!({"message":{"role":"assistant","content":"PO"},"done":false})
680
                    .to_string(),
681
                serde_json::json!({"message":{"role":"assistant","content":"NG"},"done":false})
682
                    .to_string(),
683
                serde_json::json!({
684
                    "message": {"role":"assistant","content":""},
685
                    "done": true,
686
                    "prompt_eval_count": 19,
687
                    "eval_count": 28
688
                })
689
                .to_string(),
690
            ],
691
            Some((2, Duration::from_millis(700))),
692
        )
693
    })
694
}
695
696
/// The local lane answers with the OpenAgents host unreachable, and streams.
697
///
698
/// `api_base` points at a closed port for the whole turn: a single request to
699
/// openagents.com would fail the turn, so a pass is proof that none was made.
700
#[tokio::test]
701
async fn the_local_lane_answers_with_the_proxy_unreachable() {
702
    let ollama = ollama_stub();
703
    let mut session = session(Lane::from_str("ollama:qwen3:0.6b"), DEAD.to_string());
704
    session.ollama_host = ollama.base.trim_end_matches("/api/v1").to_string();
705
706
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, Instant)>();
707
    let answer = session
708
        .execute_turn("say pong", move |chunk| {
709
            let _ = tx.send((chunk.to_string(), Instant::now()));
710
        })
711
        .await
712
        .expect("the local turn failed");
713
    let returned = Instant::now();
714
715
    assert_eq!(answer, "PONG");
716
    assert_eq!(session.last_model.as_deref(), Some("ollama:qwen3:0.6b"));
717
    // No grant, and none invented: the model is on this machine.
718
    assert!(session.last_grant.is_none());
719
    assert_eq!(session.last_reasoning, "brief");
720
    assert_eq!(
721
        session.last_usage,
722
        TurnUsage {
723
            prompt_tokens: 19,
724
            completion_tokens: 28,
725
            total_tokens: 47
726
        }
727
    );
728
729
    let mut chunks = Vec::new();
730
    while let Ok(chunk) = rx.try_recv() {
731
        chunks.push(chunk);
732
    }
733
    assert_eq!(
734
        chunks.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
735
        vec!["PO", "NG"]
736
    );
737
    let lead = returned.duration_since(chunks[0].1);
738
    assert!(
739
        lead >= Duration::from_millis(500),
740
        "the first chunk landed only {lead:?} before the turn returned, so this run \
741
         does not distinguish streaming from a batched reply"
742
    );
743
744
    // The whole exchange was with the local server.
745
    let lines = ollama.request_lines();
746
    assert!(
747
        lines.iter().any(|l| l.starts_with("GET /api/tags")),
748
        "{lines:?}"
749
    );
750
    assert!(
751
        lines.iter().any(|l| l.starts_with("POST /api/chat")),
752
        "{lines:?}"
753
    );
754
}
755
756
/// A short name resolves to the installed model whose family it names.
757
#[tokio::test]
758
async fn a_family_name_resolves_to_the_installed_model() {
759
    let ollama = ollama_stub();
760
    let mut session = session(Lane::from_str("ollama:qwen3"), DEAD.to_string());
761
    session.ollama_host = ollama.base.trim_end_matches("/api/v1").to_string();
762
763
    session.execute_turn("hello", |_| {}).await.unwrap();
764
    assert_eq!(session.last_model.as_deref(), Some("ollama:qwen3:0.6b"));
765
766
    let chat = ollama
767
        .requests()
768
        .into_iter()
769
        .find(|r| r.starts_with("POST /api/chat"))
770
        .unwrap();
771
    assert!(chat.contains(r#""model":"qwen3:0.6b""#), "{chat}");
772
}
773
774
/// With no model named, the most recently pulled one answers.
775
#[tokio::test]
776
async fn the_bare_local_lane_takes_the_most_recent_model() {
777
    let ollama = ollama_stub();
778
    let mut session = session(Lane::from_str("local"), DEAD.to_string());
779
    session.ollama_host = ollama.base.trim_end_matches("/api/v1").to_string();
780
781
    session.execute_turn("hello", |_| {}).await.unwrap();
782
    assert_eq!(session.last_model.as_deref(), Some("ollama:qwen3:0.6b"));
783
}
784
785
/// A name no installed model matches is refused, with what is installed.
786
#[tokio::test]
787
async fn a_missing_local_model_is_refused_by_name() {
788
    let ollama = ollama_stub();
789
    let mut session = session(Lane::from_str("ollama:llama9"), DEAD.to_string());
790
    session.ollama_host = ollama.base.trim_end_matches("/api/v1").to_string();
791
792
    let message = session
793
        .execute_turn("hello", |_| {})
794
        .await
795
        .expect_err("a missing local model answered anyway")
796
        .to_string();
797
    assert!(message.contains("llama9"), "{message}");
798
    assert!(message.contains("qwen3:0.6b"), "{message}");
799
}
800
801
/// No Ollama server is a failed turn, not a fallback to the hosted lane.
802
#[tokio::test]
803
async fn no_local_server_ends_the_turn() {
804
    let mut session = session(Lane::from_str("local"), DEAD.to_string());
805
    session.ollama_host = "http://127.0.0.1:1".to_string();
806
807
    let message = session
808
        .execute_turn("hello", |_| {})
809
        .await
810
        .expect_err("a missing Ollama server produced a completed turn")
811
        .to_string();
812
    assert!(message.contains("no Ollama server answered"), "{message}");
813
    assert!(message.contains("choose a hosted lane"), "{message}");
814
}
815
816
/// A local tool call runs and returns to the local model.
817
#[tokio::test]
818
async fn the_local_lane_runs_tools_and_feeds_the_result_back() {
819
    let round = Arc::new(std::sync::atomic::AtomicUsize::new(0));
820
    let counter = Arc::clone(&round);
821
    let ollama = start(move |request, _origin| {
822
        if request.starts_with("GET /api/tags") {
823
            return Reply::Body(
824
                200,
825
                "application/json",
826
                r#"{"models":[{"name":"qwen3:0.6b","modified_at":"2026-08-25T15:08:15Z"}]}"#
827
                    .to_string(),
828
            );
829
        }
830
        let step = counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
831
        if step == 0 {
832
            return Reply::Ndjson(
833
                vec![serde_json::json!({
834
                    "message": {"role":"assistant","content":"","tool_calls":[{
835
                        "function": {"name":"shell","arguments":{"command":"echo marker-4c1"}}
836
                    }]},
837
                    "done": false
838
                })
839
                .to_string()],
840
                None,
841
            );
842
        }
843
        Reply::Ndjson(
844
            vec![serde_json::json!({
845
                "message": {"role":"assistant","content":"marker-4c1"},
846
                "done": true,
847
                "prompt_eval_count": 5,
848
                "eval_count": 3
849
            })
850
            .to_string()],
851
            None,
852
        )
853
    });
854
855
    let mut session = session(Lane::from_str("local"), DEAD.to_string());
856
    session.ollama_host = ollama.base.trim_end_matches("/api/v1").to_string();
857
    let answer = session.execute_turn("run it", |_| {}).await.unwrap();
858
    assert_eq!(answer, "marker-4c1");
859
860
    let chats: Vec<String> = ollama
861
        .requests()
862
        .into_iter()
863
        .filter(|r| r.starts_with("POST /api/chat"))
864
        .collect();
865
    assert_eq!(chats.len(), 2, "the local loop did not take a second step");
866
    assert!(
867
        chats[1].contains("marker-4c1") && chats[1].contains(r#""role":"tool""#),
868
        "the tool output did not go back to the local model:\n{}",
869
        chats[1]
870
    );
871
    assert!(
872
        chats[1].contains(r#""tool_name":"shell""#),
873
        "the tool result did not name its tool for Ollama:\n{}",
874
        chats[1]
875
    );
876
}

This page updates live while a promote is in flight · changelog