Skip to main content

kcode_codex_runtime_v2/
runtime.rs

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