1use std::{collections::HashSet, path::PathBuf, process::Stdio, sync::Arc, time::Duration};
2
3use serde_json::{Value, json};
4use tokio::{
5 io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader},
6 process::{Child, Command},
7 sync::{mpsc, watch},
8};
9
10use crate::{
11 AgentEvent, AgentRequest, CompletedTurn, DynamicToolCall, Error, ErrorKind, Result, TokenUsage,
12 ToolResult,
13};
14
15pub const DEFAULT_CODEX_EXECUTABLE: &str = "codex-safe";
17
18const TOOL_OUTPUT_TOKEN_LIMIT: usize = 180_000;
19
20const MAX_INPUT_CHARACTERS: usize = 1_048_576;
21const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct CodexConfig {
26 pub executable: String,
28 pub working_directory: PathBuf,
30 pub base_instruction: String,
32 pub model_catalog: Option<PathBuf>,
34}
35
36impl Default for CodexConfig {
37 fn default() -> Self {
38 Self {
39 executable: DEFAULT_CODEX_EXECUTABLE.into(),
40 working_directory: std::env::temp_dir(),
41 base_instruction: String::new(),
42 model_catalog: None,
43 }
44 }
45}
46
47#[derive(Clone, Debug)]
49pub struct Codex {
50 config: Arc<CodexConfig>,
51}
52
53impl Codex {
54 pub async fn open(config: CodexConfig) -> Result<Self> {
56 validate_config(&config)?;
57 validate_chatgpt_login(&config.executable).await?;
58 Ok(Self {
59 config: Arc::new(config),
60 })
61 }
62
63 pub async fn start_turn(&self, request: AgentRequest) -> Result<AgentTurn> {
65 validate_request(&request)?;
66 let child = spawn_app_server(&self.config)?;
67 let (event_sender, events) = mpsc::channel(32);
68 let (tool_results, result_receiver) = mpsc::channel(8);
69 let (cancel, cancelled) = watch::channel(false);
70 let config = self.config.clone();
71 tokio::spawn(async move {
72 let result = tokio::select! {
73 _ = cancellation(cancelled) => Err(Error::new(ErrorKind::Cancelled, "Codex turn was cancelled")),
74 result = tokio::time::timeout(request.timeout, run_protocol(child, &config, request, &event_sender, result_receiver)) => {
75 match result {
76 Ok(result) => result,
77 Err(_) => Err(Error::new(ErrorKind::Timeout, "Codex turn timed out")),
78 }
79 }
80 };
81 if let Err(error) = result {
82 let _ = event_sender.send(Err(error)).await;
83 }
84 });
85 Ok(AgentTurn {
86 events,
87 tool_results,
88 cancel,
89 })
90 }
91}
92
93pub struct AgentTurn {
95 events: mpsc::Receiver<Result<AgentEvent>>,
96 tool_results: mpsc::Sender<(String, ToolResult)>,
97 cancel: watch::Sender<bool>,
98}
99
100impl AgentTurn {
101 pub async fn next_event(&mut self) -> Option<Result<AgentEvent>> {
103 self.events.recv().await
104 }
105
106 pub async fn respond(&self, call_id: impl Into<String>, result: ToolResult) -> Result<()> {
108 self.tool_results
109 .send((call_id.into(), result))
110 .await
111 .map_err(|_| Error::new(ErrorKind::Cancelled, "Codex turn is no longer running"))
112 }
113
114 pub fn cancel(&self) {
116 let _ = self.cancel.send(true);
117 }
118}
119
120impl Drop for AgentTurn {
121 fn drop(&mut self) {
122 let _ = self.cancel.send(true);
123 }
124}
125
126async fn cancellation(mut cancelled: watch::Receiver<bool>) {
127 if *cancelled.borrow() {
128 return;
129 }
130 while cancelled.changed().await.is_ok() {
131 if *cancelled.borrow() {
132 return;
133 }
134 }
135}
136
137fn validate_config(config: &CodexConfig) -> Result<()> {
138 if config.executable.trim().is_empty() {
139 return Err(Error::new(
140 ErrorKind::InvalidInput,
141 "Codex executable must not be empty",
142 ));
143 }
144 if config.base_instruction.chars().count() > MAX_INPUT_CHARACTERS {
145 return Err(Error::new(
146 ErrorKind::InvalidInput,
147 "Codex base instruction is too large",
148 ));
149 }
150 Ok(())
151}
152
153fn validate_request(request: &AgentRequest) -> Result<()> {
154 if request.input.trim().is_empty() {
155 return Err(Error::new(
156 ErrorKind::InvalidInput,
157 "Codex turn input must not be empty",
158 ));
159 }
160 if request.input.chars().count() > MAX_INPUT_CHARACTERS {
161 return Err(Error::new(
162 ErrorKind::InvalidInput,
163 format!("Codex turn input exceeds {MAX_INPUT_CHARACTERS} characters"),
164 ));
165 }
166 if request.model.trim().is_empty() {
167 return Err(Error::new(
168 ErrorKind::InvalidInput,
169 "Codex model must not be empty",
170 ));
171 }
172 if request.timeout.is_zero() {
173 return Err(Error::new(
174 ErrorKind::InvalidInput,
175 "Codex timeout must be greater than zero",
176 ));
177 }
178 let mut names = HashSet::new();
179 for tool in &request.tools {
180 if tool.name.trim().is_empty() || tool.description.trim().is_empty() {
181 return Err(Error::new(
182 ErrorKind::InvalidInput,
183 "dynamic tool names and descriptions must not be empty",
184 ));
185 }
186 if !tool.input_schema.is_object() {
187 return Err(Error::new(
188 ErrorKind::InvalidInput,
189 format!(
190 "dynamic tool '{}' must use an object JSON Schema",
191 tool.name
192 ),
193 ));
194 }
195 if !names.insert(tool.name.as_str()) {
196 return Err(Error::new(
197 ErrorKind::InvalidInput,
198 format!("dynamic tool '{}' is duplicated", tool.name),
199 ));
200 }
201 }
202 Ok(())
203}
204
205async fn validate_chatgpt_login(executable: &str) -> Result<()> {
206 let output = tokio::time::timeout(
207 STARTUP_TIMEOUT,
208 Command::new(executable)
209 .args(["login", "status"])
210 .env_remove("OPENAI_API_KEY")
211 .env_remove("CODEX_API_KEY")
212 .output(),
213 )
214 .await
215 .map_err(|_| Error::new(ErrorKind::Unavailable, "Codex login check timed out"))?
216 .map_err(|_| {
217 Error::new(
218 ErrorKind::Unavailable,
219 format!("Codex sandbox launcher '{executable}' could not be started"),
220 )
221 })?;
222 let status = format!(
223 "{}\n{}",
224 String::from_utf8_lossy(&output.stdout),
225 String::from_utf8_lossy(&output.stderr)
226 );
227 if !output.status.success() || !status.to_ascii_lowercase().contains("chatgpt") {
228 return Err(Error::new(
229 ErrorKind::Authentication,
230 format!("'{executable}' must be logged in with ChatGPT"),
231 ));
232 }
233 Ok(())
234}
235
236fn spawn_app_server(config: &CodexConfig) -> Result<Child> {
237 app_server_command(config).spawn().map_err(|_| {
238 Error::new(
239 ErrorKind::Unavailable,
240 format!(
241 "Codex sandbox launcher '{}' could not start app-server",
242 config.executable
243 ),
244 )
245 })
246}
247
248fn app_server_command(config: &CodexConfig) -> Command {
249 let mut command = Command::new(&config.executable);
250 command
251 .arg("-c")
252 .arg("web_search=\"disabled\"")
253 .arg("-c")
254 .arg("mcp_servers={}")
255 .arg("-c")
256 .arg("features.shell_tool=false")
257 .arg("-c")
258 .arg("features.apps=false")
259 .arg("-c")
260 .arg("features.browser_use=false")
261 .arg("-c")
262 .arg("features.computer_use=false")
263 .arg("-c")
264 .arg("features.goals=false")
265 .arg("-c")
266 .arg("features.hooks=false")
267 .arg("-c")
268 .arg("features.image_generation=false")
269 .arg("-c")
270 .arg("features.multi_agent=false")
271 .arg("-c")
272 .arg("features.plugins=false")
273 .arg("-c")
274 .arg("features.tool_suggest=false")
275 .arg("-c")
276 .arg("features.remote_plugin=false")
277 .arg("-c")
278 .arg("model_auto_compact_token_limit=9223372036854775807")
279 .arg("-c")
280 .arg(format!("tool_output_token_limit={TOOL_OUTPUT_TOKEN_LIMIT}"));
281 if let Some(path) = &config.model_catalog {
282 command.arg("-c").arg(format!(
283 "model_catalog_json={}",
284 serde_json::to_string(path.to_string_lossy().as_ref())
285 .expect("serializing a path string cannot fail")
286 ));
287 }
288 command
289 .arg("app-server")
290 .arg("--stdio")
291 .current_dir(&config.working_directory)
292 .stdin(Stdio::piped())
293 .stdout(Stdio::piped())
294 .stderr(Stdio::null())
295 .env_remove("OPENAI_API_KEY")
296 .env_remove("CODEX_API_KEY")
297 .kill_on_drop(true);
298 command
299}
300
301async fn run_protocol(
302 mut child: Child,
303 config: &CodexConfig,
304 request: AgentRequest,
305 events: &mpsc::Sender<Result<AgentEvent>>,
306 mut tool_results: mpsc::Receiver<(String, ToolResult)>,
307) -> Result<()> {
308 let stdin = child.stdin.take().ok_or_else(|| {
309 Error::new(
310 ErrorKind::Unavailable,
311 "Codex app-server standard input is unavailable",
312 )
313 })?;
314 let stdout = child.stdout.take().ok_or_else(|| {
315 Error::new(
316 ErrorKind::Unavailable,
317 "Codex app-server standard output is unavailable",
318 )
319 })?;
320 let mut writer = stdin;
321 let mut lines = BufReader::new(stdout).lines();
322
323 write_record(
324 &mut writer,
325 events,
326 json!({
327 "id":1,
328 "method":"initialize",
329 "params":{
330 "clientInfo":{"name":"kcode-codex-runtime-v2","version":env!("CARGO_PKG_VERSION")},
331 "capabilities":{"experimentalApi":true}
332 }
333 }),
334 )
335 .await?;
336 let initialized = read_response(&mut lines, 1).await?;
337 require_result(&initialized, "initialize")?;
338 write_record(
339 &mut writer,
340 events,
341 json!({"method":"initialized","params":{}}),
342 )
343 .await?;
344
345 let thread_method;
346 let thread_params;
347 if let Some(thread_id) = request.previous_thread_id.as_deref() {
348 thread_method = "thread/resume";
349 thread_params = json!({
350 "threadId":thread_id,
351 "model":request.model,
352 "cwd":config.working_directory,
353 "approvalPolicy":"never",
354 "sandbox":"read-only",
355 "baseInstructions":config.base_instruction,
356 });
357 } else {
358 thread_method = "thread/start";
359 let tools = request
360 .tools
361 .iter()
362 .map(|tool| {
363 json!({
364 "type":"function",
365 "name":tool.name,
366 "description":tool.description,
367 "inputSchema":tool.input_schema,
368 })
369 })
370 .collect::<Vec<_>>();
371 thread_params = json!({
372 "model":request.model,
373 "cwd":config.working_directory,
374 "approvalPolicy":"never",
375 "sandbox":"read-only",
376 "baseInstructions":config.base_instruction,
377 "developerInstructions":"",
378 "dynamicTools":tools,
379 "ephemeral":request.ephemeral,
380 "environments":[],
381 });
382 }
383 write_record(
384 &mut writer,
385 events,
386 json!({"id":2,"method":thread_method,"params":thread_params}),
387 )
388 .await?;
389 let thread_response = read_response(&mut lines, 2).await?;
390 let thread_result = require_result(&thread_response, thread_method)?;
391 let thread_id = thread_result
392 .pointer("/thread/id")
393 .and_then(Value::as_str)
394 .ok_or_else(|| Error::new(ErrorKind::Protocol, "Codex omitted its thread ID"))?
395 .to_owned();
396
397 write_record(
398 &mut writer,
399 events,
400 json!({
401 "id":3,
402 "method":"turn/start",
403 "params":{
404 "threadId":thread_id,
405 "input":[{"type":"text","text":request.input}],
406 "effort":request.reasoning_effort.as_str(),
407 "approvalPolicy":"never",
408 }
409 }),
410 )
411 .await?;
412 let turn_response = read_response(&mut lines, 3).await?;
413 let turn_result = require_result(&turn_response, "turn/start")?;
414 let turn_id = turn_result
415 .pointer("/turn/id")
416 .and_then(Value::as_str)
417 .ok_or_else(|| Error::new(ErrorKind::Protocol, "Codex omitted its turn ID"))?
418 .to_owned();
419
420 let mut answer = String::new();
421 let mut usage = None;
422 while let Some(line) = lines.next_line().await.map_err(|_| {
423 Error::new(
424 ErrorKind::Unavailable,
425 "Codex app-server output could not be read",
426 )
427 })? {
428 let message: Value = serde_json::from_str(&line)
429 .map_err(|_| Error::new(ErrorKind::Protocol, "Codex emitted invalid JSONL"))?;
430 match message.get("method").and_then(Value::as_str) {
431 Some("item/tool/call") => {
432 let id = message.get("id").cloned().ok_or_else(|| {
433 Error::new(
434 ErrorKind::Protocol,
435 "Codex tool request omitted its JSON-RPC ID",
436 )
437 })?;
438 let params = message.get("params").ok_or_else(|| {
439 Error::new(ErrorKind::Protocol, "Codex tool request omitted params")
440 })?;
441 let call_id = required_string(params, "callId", "Codex tool request")?;
442 let call = DynamicToolCall {
443 call_id: call_id.clone(),
444 tool: required_string(params, "tool", "Codex tool request")?,
445 arguments: params.get("arguments").cloned().unwrap_or(Value::Null),
446 };
447 events
448 .send(Ok(AgentEvent::ToolCall(call)))
449 .await
450 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))?;
451 let (response_call_id, result) = tool_results.recv().await.ok_or_else(|| {
452 Error::new(ErrorKind::Cancelled, "tool result channel closed")
453 })?;
454 if response_call_id != call_id {
455 return Err(Error::new(
456 ErrorKind::InvalidInput,
457 format!(
458 "tool result call ID '{response_call_id}' does not match pending call '{call_id}'"
459 ),
460 ));
461 }
462 write_record(
463 &mut writer,
464 events,
465 json!({
466 "id":id,
467 "result":{
468 "success":result.success,
469 "contentItems":[{"type":"inputText","text":result.text}],
470 }
471 }),
472 )
473 .await?;
474 }
475 Some("item/completed") => {
476 if let Some(item) = message.pointer("/params/item")
477 && item.get("type").and_then(Value::as_str) == Some("agentMessage")
478 && item.get("phase").and_then(Value::as_str) != Some("commentary")
479 && let Some(text) = item.get("text").and_then(Value::as_str)
480 {
481 answer = text.to_owned();
482 }
483 }
484 Some("thread/tokenUsage/updated") => {
485 usage = parse_usage(message.pointer("/params/tokenUsage"));
486 }
487 Some("turn/completed") => {
488 if message.pointer("/params/turn/id").and_then(Value::as_str)
489 != Some(turn_id.as_str())
490 {
491 continue;
492 }
493 let status = message
494 .pointer("/params/turn/status")
495 .and_then(Value::as_str)
496 .unwrap_or("failed");
497 if status != "completed" {
498 let detail = message
499 .pointer("/params/turn/error/message")
500 .and_then(Value::as_str)
501 .unwrap_or("Codex turn did not complete");
502 return Err(Error::new(ErrorKind::Protocol, detail));
503 }
504 events
505 .send(Ok(AgentEvent::Completed(CompletedTurn {
506 thread_id,
507 turn_id,
508 answer,
509 usage,
510 })))
511 .await
512 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))?;
513 let _ = child.kill().await;
514 return Ok(());
515 }
516 _ => {
517 if message.get("id").is_some() && message.get("method").is_some() {
518 let id = message.get("id").cloned().unwrap_or(Value::Null);
519 write_record(
520 &mut writer,
521 events,
522 json!({"id":id,"error":{"code":-32601,"message":"Method is not supported by this client"}}),
523 )
524 .await?;
525 }
526 }
527 }
528 }
529 Err(Error::new(
530 ErrorKind::Unavailable,
531 "Codex app-server closed before completing the turn",
532 ))
533}
534
535async fn write_record<W: AsyncWrite + Unpin>(
536 writer: &mut W,
537 events: &mpsc::Sender<Result<AgentEvent>>,
538 value: Value,
539) -> Result<()> {
540 let mut exact = serde_json::to_string(&value).map_err(|_| {
541 Error::new(
542 ErrorKind::InvalidInput,
543 "Codex request could not be encoded",
544 )
545 })?;
546 exact.push('\n');
547 writer.write_all(exact.as_bytes()).await.map_err(|_| {
548 Error::new(
549 ErrorKind::Unavailable,
550 "Codex app-server closed its standard input",
551 )
552 })?;
553 writer.flush().await.map_err(|_| {
554 Error::new(
555 ErrorKind::Unavailable,
556 "Codex app-server input could not be flushed",
557 )
558 })?;
559 events
560 .send(Ok(AgentEvent::ProviderInput(exact)))
561 .await
562 .map_err(|_| Error::new(ErrorKind::Cancelled, "turn event receiver closed"))
563}
564
565async fn read_response<R: tokio::io::AsyncBufRead + Unpin>(
566 lines: &mut tokio::io::Lines<R>,
567 expected_id: u64,
568) -> Result<Value> {
569 while let Some(line) = lines.next_line().await.map_err(|_| {
570 Error::new(
571 ErrorKind::Unavailable,
572 "Codex app-server output could not be read",
573 )
574 })? {
575 let message: Value = serde_json::from_str(&line)
576 .map_err(|_| Error::new(ErrorKind::Protocol, "Codex emitted invalid JSONL"))?;
577 if message.get("id").and_then(Value::as_u64) == Some(expected_id) {
578 return Ok(message);
579 }
580 }
581 Err(Error::new(
582 ErrorKind::Unavailable,
583 "Codex app-server closed during startup",
584 ))
585}
586
587fn require_result<'a>(message: &'a Value, method: &str) -> Result<&'a Value> {
588 if let Some(error) = message.get("error") {
589 let detail = error
590 .get("message")
591 .and_then(Value::as_str)
592 .unwrap_or("unknown protocol error");
593 return Err(Error::new(
594 ErrorKind::Protocol,
595 format!("Codex {method} failed: {detail}"),
596 ));
597 }
598 message.get("result").ok_or_else(|| {
599 Error::new(
600 ErrorKind::Protocol,
601 format!("Codex {method} response omitted result"),
602 )
603 })
604}
605
606fn required_string(value: &Value, key: &str, label: &str) -> Result<String> {
607 value
608 .get(key)
609 .and_then(Value::as_str)
610 .map(str::to_owned)
611 .ok_or_else(|| Error::new(ErrorKind::Protocol, format!("{label} omitted {key}")))
612}
613
614fn parse_usage(value: Option<&Value>) -> Option<TokenUsage> {
615 let value = value?;
616 let total = value.get("total")?;
617 let last = value.get("last");
618 Some(TokenUsage {
619 input_tokens: nonnegative(total.get("inputTokens")),
620 output_tokens: nonnegative(total.get("outputTokens")),
621 cached_input_tokens: nonnegative(total.get("cachedInputTokens")),
622 reasoning_output_tokens: nonnegative(total.get("reasoningOutputTokens")),
623 last_input_tokens: last.map(|value| nonnegative(value.get("inputTokens"))),
624 last_output_tokens: last.map(|value| nonnegative(value.get("outputTokens"))),
625 })
626}
627
628fn nonnegative(value: Option<&Value>) -> u64 {
629 value.and_then(Value::as_i64).unwrap_or_default().max(0) as u64
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::DynamicTool;
636
637 #[test]
638 fn validates_tool_names_and_schema() {
639 let mut request = AgentRequest::new("hello", "model");
640 request.tools = vec![
641 DynamicTool::new("call", "Call it", json!({"type":"object"})),
642 DynamicTool::new("call", "Call it again", json!({"type":"object"})),
643 ];
644 assert_eq!(
645 validate_request(&request).unwrap_err().kind(),
646 ErrorKind::InvalidInput
647 );
648 }
649
650 #[test]
651 fn parses_cumulative_and_last_usage() {
652 let usage = parse_usage(Some(&json!({
653 "total":{"inputTokens":20,"outputTokens":7,"cachedInputTokens":4,"reasoningOutputTokens":2},
654 "last":{"inputTokens":8,"outputTokens":3,"cachedInputTokens":1,"reasoningOutputTokens":1}
655 })))
656 .unwrap();
657 assert_eq!(usage.input_tokens, 20);
658 assert_eq!(usage.last_output_tokens, Some(3));
659 }
660
661 #[test]
662 fn app_server_overrides_tool_output_token_limit() {
663 let command = app_server_command(&CodexConfig::default());
664 let arguments = command
665 .as_std()
666 .get_args()
667 .map(|argument| argument.to_string_lossy().into_owned())
668 .collect::<Vec<_>>();
669
670 assert!(arguments.windows(2).any(|arguments| {
671 arguments[0] == "-c"
672 && arguments[1] == format!("tool_output_token_limit={TOOL_OUTPUT_TOKEN_LIMIT}")
673 }));
674 }
675
676 #[cfg(unix)]
677 #[tokio::test]
678 async fn completes_a_native_dynamic_tool_turn() {
679 use std::os::unix::fs::PermissionsExt;
680
681 let path = std::env::temp_dir().join(format!(
682 "kcode-codex-runtime-v2-test-{}",
683 std::process::id()
684 ));
685 std::fs::write(
686 &path,
687 r##"#!/bin/sh
688if [ "$1" = "login" ]; then
689 echo "Logged in using ChatGPT"
690 exit 0
691fi
692read initialize
693echo '{"id":1,"result":{"userAgent":"test","platformFamily":"unix","platformOs":"linux","codexHome":"/tmp"}}'
694read initialized
695read thread_start
696echo '{"id":2,"result":{"thread":{"id":"thread-1"}}}'
697read turn_start
698echo '{"id":3,"result":{"turn":{"id":"turn-1"}}}'
699echo '{"id":77,"method":"item/tool/call","params":{"threadId":"thread-1","turnId":"turn-1","callId":"call-1","tool":"call_ktool","arguments":{"name":"LoadNode","arguments":{"identifier":3}}}}'
700read tool_result
701echo '{"method":"item/completed","params":{"threadId":"thread-1","turnId":"turn-1","completedAtMs":1,"item":{"id":"message-1","type":"agentMessage","phase":"final_answer","text":"Finished."}}}'
702echo '{"method":"thread/tokenUsage/updated","params":{"threadId":"thread-1","turnId":"turn-1","tokenUsage":{"total":{"inputTokens":12,"outputTokens":4,"cachedInputTokens":2,"reasoningOutputTokens":1,"totalTokens":16},"last":{"inputTokens":12,"outputTokens":4,"cachedInputTokens":2,"reasoningOutputTokens":1,"totalTokens":16}}}}'
703echo '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}'
704"##,
705 )
706 .unwrap();
707 let mut permissions = std::fs::metadata(&path).unwrap().permissions();
708 permissions.set_mode(0o700);
709 std::fs::set_permissions(&path, permissions).unwrap();
710
711 let config = CodexConfig {
712 executable: path.to_string_lossy().into_owned(),
713 ..CodexConfig::default()
714 };
715 let codex = Codex::open(config).await.unwrap();
716 let mut request = AgentRequest::new("Exact input", "test-model");
717 request.tools.push(DynamicTool::new(
718 "call_ktool",
719 "Call one tool",
720 json!({"type":"object"}),
721 ));
722 let mut turn = codex.start_turn(request).await.unwrap();
723 let mut provider_inputs = String::new();
724 let completed = loop {
725 match turn.next_event().await.unwrap().unwrap() {
726 AgentEvent::ProviderInput(exact) => provider_inputs.push_str(&exact),
727 AgentEvent::ToolCall(call) => {
728 assert_eq!(call.arguments["name"], "LoadNode");
729 turn.respond(call.call_id, ToolResult::success("loaded"))
730 .await
731 .unwrap();
732 }
733 AgentEvent::Completed(completed) => break completed,
734 }
735 };
736 assert_eq!(completed.answer, "Finished.");
737 assert_eq!(completed.usage.unwrap().input_tokens, 12);
738 assert!(provider_inputs.contains("\"method\":\"turn/start\""));
739 assert!(provider_inputs.contains("\"success\":true"));
740 std::fs::remove_file(path).unwrap();
741 }
742}