Report context usage to the client after each model response
sigit now sends a usage_update after every model response in a turn, so an editor can show a context meter. `size` is the model's context window from the picker list. `used` is the endpoint's own count when it gives one: a streamed request now asks for it with stream_options.include_usage and reads it off the final chunk, and the JSON path reads `usage` from the response. With no count, which is always the case on-device, the history is estimated the way compaction does it. An endpoint that rejects stream_options as unknown is asked again without it, and the backend stops sending it from then on. Closes #194 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seto Elkahfi committed
Oct 9, 2026 at 00:20 UTC
b422457bda7e61a14b9ea453425b35444c2663d1
4 files changed
+352
-4
.agents/AGENTS.md
+6
@@ -144,6 +144,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
144
endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
145
the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
146
explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
147
+ `TurnResult::context_tokens` carries the endpoint's token count (prompt plus completion) up
148
+ to the loop, which sends it as a `usage_update` after each model response, with the
149
+ model's picker `context_window_tokens` as `size`; with no count (always on-device) the
150
+ history is estimated instead. A streamed request asks for the count with
151
+ `stream_options.include_usage`, and an endpoint that rejects the option is asked again
152
+ without it and never asked again (`stream_usage_refused`).
153
`TurnResult::finish` carries the endpoint's `finish_reason` up to the loop, and
154
`stop_reason_for` in `main.rs` turns it into the ACP stop reason: the tool-round cap is
155
`max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
src/backend.rs
+242
-3
@@ -19,6 +19,7 @@
19
#![cfg_attr(not(unix), allow(dead_code))]
20
21
use std::sync::Arc;
22
+use std::sync::atomic::{AtomicBool, Ordering};
23
24
use async_trait::async_trait;
25
use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
@@ -61,6 +62,10 @@ pub struct TurnResult {
62
/// Why the model stopped. Only the last round of a turn decides how the
63
/// turn is reported; a round that goes on to run tools is not the end.
64
pub finish: FinishReason,
65
+ /// The context in use once this reply is in, as the endpoint counted it:
66
+ /// prompt plus completion tokens. `None` when nothing was reported, which
67
+ /// is always the case on-device.
68
+ pub context_tokens: Option<u64>,
69
}
70
71
/// Why the model stopped generating, reduced to what the agent loop acts on.
@@ -914,6 +919,7 @@ async fn drain_onde_stream(
919
text,
920
tool_calls: Vec::new(),
921
finish,
922
+ context_tokens: None,
923
})
924
}
925
@@ -921,6 +927,7 @@ async fn drain_onde_stream(
927
fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
928
TurnResult {
929
finish: FinishReason::from_wire(Some(&result.finish_reason)),
930
+ context_tokens: None,
931
text: result.text,
932
tool_calls: result
933
.tool_calls
@@ -955,6 +962,9 @@ pub struct OpenAiBackend {
962
/// Whether `model` takes audio (see `provider::model_accepts_audio`), with
963
/// the same effect on audio already in `history`.
964
accepts_audio: bool,
965
+ /// Set once the endpoint has refused `stream_options`, so streamed
966
+ /// requests stop asking it for token usage.
967
+ stream_usage_refused: Arc<AtomicBool>,
968
}
969
970
impl OpenAiBackend {
@@ -980,6 +990,7 @@ impl OpenAiBackend {
990
model,
991
http: reqwest::Client::new(),
992
history: Mutex::new(history),
993
+ stream_usage_refused: Arc::default(),
994
}
995
}
996
@@ -1059,6 +1070,7 @@ impl OpenAiBackend {
1070
text: join_reply_text(&result.text, &retry.text),
1071
tool_calls: retry.tool_calls,
1072
finish: retry.finish,
1073
+ context_tokens: retry.context_tokens,
1074
})
1075
}
1076
@@ -1091,6 +1103,12 @@ impl OpenAiBackend {
1103
"messages": messages,
1104
"stream": streaming,
1105
});
1106
+ // A stream reports no token usage unless asked to. It comes in a last
1107
+ // chunk with no choices, which feeds the client's context meter.
1108
+ let ask_for_usage = streaming && !self.stream_usage_refused.load(Ordering::Relaxed);
1109
+ if ask_for_usage {
1110
+ body["stream_options"] = serde_json::json!({ "include_usage": true });
1111
+ }
1112
if allow_tool_calls && !tools.is_empty() {
1113
body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
1114
// OpenAI specifies `auto` as the default when tools are present,
@@ -1101,7 +1119,7 @@ impl OpenAiBackend {
1119
body["tool_choice"] = serde_json::Value::String("auto".to_string());
1120
}
1121
1104
- let response = self
1122
+ let mut response = self
1123
.http
1124
.post(&url)
1125
.bearer_auth(&self.api_key)
@@ -1112,8 +1130,30 @@ impl OpenAiBackend {
1130
1131
if !response.status().is_success() {
1132
let status = response.status();
1115
- let body = response.text().await.unwrap_or_default();
1116
- return Err(describe_api_error(status, &body));
1133
+ let error_body = response.text().await.unwrap_or_default();
1134
+ // Usage is a nicety; an endpoint that does not know the option is
1135
+ // asked again without it, and never asked for it again.
1136
+ if !(ask_for_usage && refuses_stream_options(status, &error_body)) {
1137
+ return Err(describe_api_error(status, &error_body));
1138
+ }
1139
+ log::info!("{url} refused stream_options; streaming without token usage");
1140
+ self.stream_usage_refused.store(true, Ordering::Relaxed);
1141
+ if let Some(fields) = body.as_object_mut() {
1142
+ fields.remove("stream_options");
1143
+ }
1144
+ response = self
1145
+ .http
1146
+ .post(&url)
1147
+ .bearer_auth(&self.api_key)
1148
+ .json(&body)
1149
+ .send()
1150
+ .await
1151
+ .map_err(|error| format!("request to {url} failed: {error}"))?;
1152
+ if !response.status().is_success() {
1153
+ let status = response.status();
1154
+ let error_body = response.text().await.unwrap_or_default();
1155
+ return Err(describe_api_error(status, &error_body));
1156
+ }
1157
}
1158
1159
if let Some(sink) = sink {
@@ -1189,6 +1229,7 @@ impl OpenAiBackend {
1229
.json()
1230
.await
1231
.map_err(|error| format!("response parse error: {error}"))?;
1232
+ let context_tokens = parsed.usage.as_ref().map(Usage::context_tokens);
1233
1234
let choice = parsed
1235
.choices
@@ -1243,6 +1284,7 @@ impl OpenAiBackend {
1284
text,
1285
tool_calls: Vec::new(),
1286
finish,
1287
+ context_tokens,
1288
},
1289
malformed,
1290
));
@@ -1282,6 +1324,7 @@ impl OpenAiBackend {
1324
text: cleaned,
1325
tool_calls,
1326
finish,
1327
+ context_tokens,
1328
},
1329
malformed,
1330
));
@@ -1295,6 +1338,7 @@ impl OpenAiBackend {
1338
text: cleaned,
1339
tool_calls,
1340
finish,
1341
+ context_tokens,
1342
},
1343
malformed,
1344
));
@@ -1324,6 +1368,7 @@ impl OpenAiBackend {
1368
text: extracted.text,
1369
tool_calls,
1370
finish,
1371
+ context_tokens,
1372
},
1373
malformed,
1374
));
@@ -1343,6 +1388,7 @@ impl OpenAiBackend {
1388
text,
1389
tool_calls,
1390
finish,
1391
+ context_tokens,
1392
},
1393
0,
1394
))
@@ -1381,6 +1427,8 @@ impl OpenAiBackend {
1427
let mut done = false;
1428
// Arrives on a late chunk, usually one with an empty delta.
1429
let mut finish = FinishReason::default();
1430
+ // Arrives on the last chunk, which has no choices, when asked for.
1431
+ let mut context_tokens = None;
1432
// Recovers a tool call the model wrote as literal `<tool_call>` text
1433
// instead of a structured delta. Scanning here (rather than after the
1434
// stream) keeps the tag off the UI: content goes straight to `sink` as
@@ -1435,6 +1483,9 @@ impl OpenAiBackend {
1483
Err(_) => continue,
1484
};
1485
1486
+ if let Some(usage) = &chunk.usage {
1487
+ context_tokens = Some(usage.context_tokens());
1488
+ }
1489
let Some(choice) = chunk.choices.into_iter().next() else {
1490
continue;
1491
};
@@ -1603,6 +1654,7 @@ impl OpenAiBackend {
1654
text,
1655
tool_calls,
1656
finish,
1657
+ context_tokens,
1658
},
1659
malformed,
1660
))
@@ -1781,6 +1833,8 @@ impl InferenceBackend for OpenAiBackend {
1833
history: Mutex::new(Vec::new()),
1834
accepts_images: self.accepts_images,
1835
accepts_audio: self.accepts_audio,
1836
+ // It is the same endpoint, so what it refused it refuses here.
1837
+ stream_usage_refused: Arc::clone(&self.stream_usage_refused),
1838
}))
1839
}
1840
@@ -2012,6 +2066,33 @@ fn is_probable_html(body: &str) -> bool {
2066
struct ChatCompletion {
2067
#[serde(default)]
2068
choices: Vec<CompletionChoice>,
2069
+ #[serde(default)]
2070
+ usage: Option<Usage>,
2071
+}
2072
+
2073
+/// Token usage as an OpenAI-compatible endpoint reports it.
2074
+#[derive(Debug, Deserialize)]
2075
+struct Usage {
2076
+ #[serde(default)]
2077
+ prompt_tokens: u64,
2078
+ #[serde(default)]
2079
+ completion_tokens: u64,
2080
+ #[serde(default)]
2081
+ total_tokens: Option<u64>,
2082
+}
2083
+
2084
+impl Usage {
2085
+ /// The context in use once the reply is in.
2086
+ fn context_tokens(&self) -> u64 {
2087
+ self.total_tokens
2088
+ .unwrap_or(self.prompt_tokens + self.completion_tokens)
2089
+ }
2090
+}
2091
+
2092
+/// Whether a failed request was turned down for carrying `stream_options`,
2093
+/// which some OpenAI-compatible servers predate and reject as unknown.
2094
+fn refuses_stream_options(status: reqwest::StatusCode, body: &str) -> bool {
2095
+ matches!(status.as_u16(), 400 | 422) && body.contains("stream_options")
2096
}
2097
2098
#[derive(Debug, Deserialize)]
@@ -2076,6 +2157,8 @@ struct ResponseFunction {
2157
struct StreamCompletion {
2158
#[serde(default)]
2159
choices: Vec<StreamChoice>,
2160
+ #[serde(default)]
2161
+ usage: Option<Usage>,
2162
}
2163
2164
#[derive(Debug, Deserialize)]
@@ -2999,6 +3082,162 @@ mod tests {
3082
assert!(shown.contains("stopped this reply"), "{shown}");
3083
}
3084
3085
+ /// Serves `responses` in order, one per connection, as `(status,
3086
+ /// content type, body)`, and hands back each request body.
3087
+ fn spawn_scripted_stub(
3088
+ responses: Vec<(u16, &'static str, String)>,
3089
+ ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
3090
+ use std::io::{Read, Write};
3091
+
3092
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3093
+ let addr = listener.local_addr().unwrap();
3094
+ let (sender, receiver) = std::sync::mpsc::channel();
3095
+ std::thread::spawn(move || {
3096
+ for (status, content_type, body) in responses {
3097
+ let Ok((mut stream, _)) = listener.accept() else {
3098
+ return;
3099
+ };
3100
+ let mut request = Vec::new();
3101
+ let mut chunk = [0u8; 4096];
3102
+ loop {
3103
+ let n = stream.read(&mut chunk).unwrap_or(0);
3104
+ if n == 0 {
3105
+ break;
3106
+ }
3107
+ request.extend_from_slice(&chunk[..n]);
3108
+ if let Some(headers_end) =
3109
+ request.windows(4).position(|window| window == b"\r\n\r\n")
3110
+ {
3111
+ let headers = String::from_utf8_lossy(&request[..headers_end]);
3112
+ let content_length = headers
3113
+ .lines()
3114
+ .find_map(|line| {
3115
+ line.to_ascii_lowercase()
3116
+ .strip_prefix("content-length:")
3117
+ .map(|value| value.trim().parse::<usize>().unwrap_or(0))
3118
+ })
3119
+ .unwrap_or(0);
3120
+ if request.len() >= headers_end + 4 + content_length {
3121
+ let _ = sender.send(
3122
+ String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
3123
+ );
3124
+ break;
3125
+ }
3126
+ }
3127
+ }
3128
+ let response = format!(
3129
+ "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
3130
+ content-length: {}\r\nconnection: close\r\n\r\n{}",
3131
+ body.len(),
3132
+ body
3133
+ );
3134
+ let _ = stream.write_all(response.as_bytes());
3135
+ }
3136
+ });
3137
+ (addr, receiver)
3138
+ }
3139
+
3140
+ fn sse(events: &[serde_json::Value]) -> String {
3141
+ let mut body: String = events
3142
+ .iter()
3143
+ .map(|event| format!("data: {event}\n\n"))
3144
+ .collect();
3145
+ body.push_str("data: [DONE]\n\n");
3146
+ body
3147
+ }
3148
+
3149
+ /// A stream asks for token usage and reads it off the last chunk, the
3150
+ /// one with no choices, for the client's context meter (issue #194).
3151
+ #[tokio::test]
3152
+ async fn a_stream_reports_the_usage_it_was_asked_for() {
3153
+ let (addr, requests) = spawn_scripted_stub(vec![(
3154
+ 200,
3155
+ "text/event-stream",
3156
+ sse(&[
3157
+ serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]}),
3158
+ serde_json::json!({
3159
+ "choices": [],
3160
+ "usage": {"prompt_tokens": 1200, "completion_tokens": 34, "total_tokens": 1234},
3161
+ }),
3162
+ ]),
3163
+ )]);
3164
+ let backend =
3165
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3166
+ let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3167
+
3168
+ let result = backend
3169
+ .send_message_with_tools("hello", &[], Some(&sink))
3170
+ .await
3171
+ .unwrap();
3172
+
3173
+ assert_eq!(result.text, "Hi.");
3174
+ assert_eq!(result.context_tokens, Some(1234));
3175
+ let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3176
+ assert_eq!(request["stream_options"]["include_usage"], true);
3177
+ }
3178
+
3179
+ #[tokio::test]
3180
+ async fn a_json_reply_reports_its_usage() {
3181
+ let body = serde_json::json!({
3182
+ "choices": [{"message": {"role": "assistant", "content": "Hi."}}],
3183
+ "usage": {"prompt_tokens": 90, "completion_tokens": 10},
3184
+ })
3185
+ .to_string();
3186
+ let (addr, requests) = spawn_scripted_stub(vec![(200, "application/json", body)]);
3187
+ let backend =
3188
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3189
+
3190
+ let result = backend
3191
+ .send_message_with_tools("hello", &[], None)
3192
+ .await
3193
+ .unwrap();
3194
+
3195
+ // No total given: prompt plus completion.
3196
+ assert_eq!(result.context_tokens, Some(100));
3197
+ let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3198
+ assert!(request.get("stream_options").is_none(), "{request}");
3199
+ }
3200
+
3201
+ /// An endpoint that predates `stream_options` is asked again without it,
3202
+ /// and not asked for usage again after that.
3203
+ #[tokio::test]
3204
+ async fn an_endpoint_that_refuses_stream_options_still_streams() {
3205
+ let refusal = serde_json::json!({
3206
+ "error": {"message": "Unrecognized request argument supplied: stream_options"},
3207
+ })
3208
+ .to_string();
3209
+ let reply = sse(&[serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]})]);
3210
+ let (addr, requests) = spawn_scripted_stub(vec![
3211
+ (400, "application/json", refusal),
3212
+ (200, "text/event-stream", reply.clone()),
3213
+ (200, "text/event-stream", reply),
3214
+ ]);
3215
+ let backend =
3216
+ OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3217
+ let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3218
+
3219
+ let first = backend
3220
+ .send_message_with_tools("hello", &[], Some(&sink))
3221
+ .await
3222
+ .unwrap();
3223
+ assert_eq!(first.text, "Hi.");
3224
+ assert_eq!(first.context_tokens, None);
3225
+ let second = backend
3226
+ .send_message_with_tools("again", &[], Some(&sink))
3227
+ .await
3228
+ .unwrap();
3229
+ assert_eq!(second.text, "Hi.");
3230
+
3231
+ let bodies: Vec<serde_json::Value> = requests
3232
+ .try_iter()
3233
+ .map(|body| serde_json::from_str(&body).unwrap())
3234
+ .collect();
3235
+ assert_eq!(bodies.len(), 3);
3236
+ assert!(bodies[0].get("stream_options").is_some());
3237
+ assert!(bodies[1].get("stream_options").is_none());
3238
+ assert!(bodies[2].get("stream_options").is_none());
3239
+ }
3240
+
3241
#[test]
3242
fn finish_reasons_map_from_the_wire() {
3243
for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
src/main.rs
+45
-1
@@ -93,7 +93,7 @@ use agent_client_protocol::schema::v1::{
93
SessionNotification, SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
94
SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
95
Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
96
- ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
96
+ ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate,
97
WaitForTerminalExitRequest, WriteTextFileRequest,
98
};
99
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
@@ -3405,6 +3405,7 @@ impl SiGitAgent {
3405
let max_tool_rounds = headless::max_tool_rounds_from_env();
3406
3407
let model_name = self.current_model.lock().unwrap().display_name.clone();
3408
+ let context_window = self.context_window_tokens();
3409
let earlier_images = if backend.accepts_images() {
3410
0
3411
} else {
@@ -3518,6 +3519,9 @@ impl SiGitAgent {
3519
}
3520
};
3521
3522
+ self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3523
+ .await;
3524
+
3525
let mut round = 0;
3526
// Set when the repetition guard blocked a call; names the tool.
3527
let mut stopped_repeating: Option<String> = None;
@@ -3902,6 +3906,8 @@ impl SiGitAgent {
3906
return Err(agent_client_protocol::Error::new(-32603, error));
3907
}
3908
};
3909
+ self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3910
+ .await;
3911
}
3912
3913
// ── Final text response ───────────────────────────────────────────
@@ -4013,6 +4019,44 @@ impl SiGitAgent {
4019
Ok(PromptResponse::new(stop_reason))
4020
}
4021
4022
+ /// The context window of the session's model, for `usage_update`. A model
4023
+ /// the picker does not list (a provider-supplied name, say) falls back to
4024
+ /// the compaction budget, the same as the TUI's gauge.
4025
+ fn context_window_tokens(&self) -> u64 {
4026
+ let current = self.current_model.lock().unwrap().clone();
4027
+ models::build_model_picker_items()
4028
+ .into_iter()
4029
+ .find(|item| {
4030
+ item.config.model_id == current.model_id
4031
+ || item.display_name == current.display_name
4032
+ })
4033
+ .map(|item| item.context_window_tokens)
4034
+ .unwrap_or(backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
4035
+ }
4036
+
4037
+ /// Report how much of the context window the session uses after one
4038
+ /// model response. The endpoint's own count is used when it gave one;
4039
+ /// otherwise (always on-device) the history is estimated, the way
4040
+ /// compaction does it.
4041
+ async fn send_usage(
4042
+ &self,
4043
+ cx: &ConnectionTo<Client>,
4044
+ session_id: &SessionId,
4045
+ backend: &dyn InferenceBackend,
4046
+ result: &TurnResult,
4047
+ size: u64,
4048
+ ) {
4049
+ let used = match result.context_tokens {
4050
+ Some(tokens) => tokens,
4051
+ None => backend::estimate_tokens(&backend.history_snapshot().await) as u64,
4052
+ };
4053
+ cx.send_notification(SessionNotification::new(
4054
+ session_id.clone(),
4055
+ SessionUpdate::UsageUpdate(UsageUpdate::new(used, size)),
4056
+ ))
4057
+ .ok();
4058
+ }
4059
+
4060
/// Ask the ACP client for permission to run one tool call. The request
4061
/// names the call by `tool_call_id`, the id it was announced under, so the
4062
/// client updates that one card instead of drawing a second. Presents
tests/acp_permissions.rs
+59
@@ -1225,6 +1225,65 @@ fn chunks_of_one_model_message_share_a_message_id() {
1225
let _ = std::fs::remove_dir_all(&scratch);
1226
}
1227
1228
+/// After each model response the client hears how much of the context window
1229
+/// the session uses: the endpoint's own count when it gives one, an estimate
1230
+/// when it does not (issue #194).
1231
+#[test]
1232
+fn each_model_response_reports_context_usage() {
1233
+ let endpoint = start_fake_endpoint(vec![
1234
+ // No usage on this one.
1235
+ sse_tool_call("call_1", "list_directory", r#"{"path":"."}"#),
1236
+ sse_body(&[
1237
+ json!({"choices": [{"delta": {"content": "Done."}}]}),
1238
+ json!({"choices": [], "usage": {"prompt_tokens": 1500, "completion_tokens": 21}}),
1239
+ ]),
1240
+ ]);
1241
+
1242
+ let scratch = std::env::temp_dir().join(format!("sigit_acp_usage_{}", std::process::id()));
1243
+ let config_dir = scratch.join("config");
1244
+ let cwd = scratch.join("cwd");
1245
+ std::fs::create_dir_all(&config_dir).unwrap();
1246
+ std::fs::create_dir_all(&cwd).unwrap();
1247
+
1248
+ let mut agent = spawn_agent(endpoint.port, &config_dir);
1249
+
1250
+ let id = agent.request(
1251
+ "initialize",
1252
+ json!({"protocolVersion": 1, "clientCapabilities": {}}),
1253
+ );
1254
+ agent.wait_for_response(id);
1255
+
1256
+ let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1257
+ let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1258
+ .as_str()
1259
+ .expect("session id")
1260
+ .to_string();
1261
+
1262
+ let prompt_id = agent.request(
1263
+ "session/prompt",
1264
+ json!({
1265
+ "sessionId": session_id,
1266
+ "prompt": [{"type": "text", "text": "look around"}],
1267
+ }),
1268
+ );
1269
+ let (_, updates) = agent.wait_for_response_with_updates(prompt_id);
1270
+
1271
+ let usage: Vec<&Value> = updates
1272
+ .iter()
1273
+ .filter(|update| update["sessionUpdate"] == "usage_update")
1274
+ .collect();
1275
+ assert_eq!(usage.len(), 2, "one per model response: {usage:?}");
1276
+ let estimated = usage[0]["used"].as_u64().expect("used");
1277
+ assert!(estimated > 0, "an estimate stands in for a missing count");
1278
+ assert_eq!(usage[1]["used"], 1521, "the endpoint's own count wins");
1279
+ for update in &usage {
1280
+ assert!(update["size"].as_u64().unwrap_or_default() > 0, "{update}");
1281
+ }
1282
+
1283
+ drop(agent);
1284
+ let _ = std::fs::remove_dir_all(&scratch);
1285
+}
1286
+
1287
/// A model that writes its tool call out as literal `<tool_call>` text instead
1288
/// of using the structured field must still drive the loop. Before recovery
1289
/// the tag was streamed to the client as prose and the turn ended with no tool