Skip to main content

sac/tools/
thread.rs

1use std::collections::BTreeMap;
2use std::process::Stdio;
3use std::sync::Arc;
4use std::time::Duration;
5
6use serde_json::Value;
7use tokio::io::{AsyncBufReadExt, BufReader};
8use tokio::process::Command;
9use tokio::sync::Mutex;
10use tokio::time::timeout;
11use tracing::Instrument;
12
13use crate::events::{decode_stderr_event, AgentEvent};
14use crate::model::ModelClient;
15use crate::process::{isolate_process_group, terminate_child_tree};
16use crate::store;
17use crate::tools::{require_str, require_string_array, ToolResult, ToolRuntime};
18use crate::types::ToolDefinition;
19
20pub const DEFAULT_THREAD_TIMEOUT_SECS: u64 = 60 * 60;
21pub const MIN_THREAD_TIMEOUT_SECS: u64 = 30 * 60;
22
23pub fn dispatch_definition() -> ToolDefinition {
24    use serde_json::json;
25    def(
26        "thread",
27        "Dispatch a named worker thread. The worker reuses its own retained history and can pull the latest retained episode from other named threads. Default timeout is configured by sac; built-in default is 3600 seconds and minimum timeout is 1800 seconds.",
28        json!({
29            "type": "object",
30            "properties": {
31                "name": { "type": "string", "description": "Thread name. Creates if new, reuses if existing." },
32                "action": { "type": "string", "description": "Task for the worker." },
33                "threads": {
34                    "type": "array",
35                    "items": { "type": "string" },
36                    "description": "Other thread names whose latest retained episodes should be loaded."
37                },
38                "timeout": { "type": "integer", "description": "Timeout in seconds for this dispatch (default 3600, minimum 1800)." }
39            },
40            "required": ["name", "action"]
41        }),
42    )
43}
44
45pub fn threads_definition() -> ToolDefinition {
46    use serde_json::json;
47    def(
48        "threads",
49        "List retained thread lanes in the current orchestrator session. This reports persisted thread history, not whether a worker process is actively running right now.",
50        json!({
51            "type": "object",
52            "properties": {}
53        }),
54    )
55}
56
57pub fn thread_read_definition() -> ToolDefinition {
58    use serde_json::json;
59    def(
60        "thread_read",
61        "Read the full retained episode history for one thread.",
62        json!({
63            "type": "object",
64            "properties": {
65                "name": { "type": "string", "description": "Thread name." }
66            },
67            "required": ["name"]
68        }),
69    )
70}
71
72pub fn thread_delete_definition() -> ToolDefinition {
73    use serde_json::json;
74    def(
75        "thread_delete",
76        "Delete one thread and all its retained episodes.",
77        json!({
78            "type": "object",
79            "properties": {
80                "name": { "type": "string", "description": "Thread name." }
81            },
82            "required": ["name"]
83        }),
84    )
85}
86
87pub async fn execute_dispatch(
88    args: Value,
89    runtime: &ToolRuntime,
90    client: &ModelClient,
91) -> ToolResult {
92    let thread_name = match require_str(&args, "name") {
93        Ok(s) => s,
94        Err(e) => return e,
95    };
96    let action = match require_str(&args, "action") {
97        Ok(s) => s,
98        Err(e) => return e,
99    };
100    let source_threads = match require_string_array(&args, "threads") {
101        Ok(v) => v,
102        Err(e) => return e,
103    };
104    let session_id = match require_session(runtime) {
105        Ok(s) => s.to_string(),
106        Err(e) => return e,
107    };
108    let timeout_secs = resolve_thread_timeout_secs(&args, runtime.thread_timeout_secs);
109
110    async {
111        if !mark_thread_active(runtime, &thread_name).await {
112            tracing::warn!(thread_name = %thread_name, "thread dispatch rejected because thread is already active");
113            return ToolResult {
114                content: format!(
115                    "Thread '{}' is already running; retry after the current dispatch completes.",
116                    thread_name
117                ),
118                is_error: true,
119            };
120        }
121
122        tracing::info!(
123            session_id = %session_id,
124            thread_name = %thread_name,
125            action_len = action.len(),
126            source_threads = ?source_threads,
127            timeout_secs,
128            backend = ?client.backend(),
129            model = %client.model,
130            base_url = %client.base_url(),
131            "dispatching managed worker thread"
132        );
133
134        runtime.event_sink.emit(AgentEvent::ThreadStarted {
135            name: thread_name.clone(),
136            action: action.clone(),
137            source_threads: source_threads.clone(),
138        });
139
140        let result = run_worker(
141            runtime,
142            client,
143            &session_id,
144            &thread_name,
145            &action,
146            &source_threads,
147            timeout_secs,
148        )
149        .await;
150        unmark_thread_active(runtime, &thread_name).await;
151
152        match result {
153            Err(e) => {
154                tracing::error!(thread_name = %thread_name, error = %e, "failed to spawn managed worker thread");
155                runtime.event_sink.emit(AgentEvent::Error {
156                    thread_name: Some(thread_name.clone()),
157                    message: format!("Failed to spawn thread '{}': {}", thread_name, e),
158                });
159                ToolResult {
160                    content: format!("Failed to spawn thread '{}': {}", thread_name, e),
161                    is_error: true,
162                }
163            }
164            Ok(run) if run.timed_out => {
165                tracing::warn!(
166                    thread_name = %thread_name,
167                    exit_code = run.exit_code,
168                    stderr_len = run.stderr.len(),
169                    stdout_len = run.stdout.len(),
170                    timeout_reason = ?run.timeout_reason,
171                    timeout_secs,
172                    "managed worker thread timed out"
173                );
174                let timeout_reason = run.timeout_reason.clone();
175                runtime.event_sink.emit(AgentEvent::ThreadFinished {
176                    name: thread_name.clone(),
177                    exit_code: run.exit_code,
178                    timed_out: true,
179                    timeout_reason: timeout_reason.clone(),
180                });
181                ToolResult {
182                    content: match timeout_reason {
183                        Some(reason) => {
184                            format!(
185                                "Thread '{}' timed out after {}s.\n{}",
186                                thread_name, timeout_secs, reason
187                            )
188                        }
189                        None => format!("Thread '{}' timed out after {}s", thread_name, timeout_secs),
190                    },
191                    is_error: true,
192                }
193            }
194            Ok(run) if run.exit_code != 0 => {
195                tracing::error!(
196                    thread_name = %thread_name,
197                    exit_code = run.exit_code,
198                    stderr_len = run.stderr.len(),
199                    stdout_len = run.stdout.len(),
200                    "managed worker thread exited with failure"
201                );
202                runtime.event_sink.emit(AgentEvent::ThreadFinished {
203                    name: thread_name.clone(),
204                    exit_code: run.exit_code,
205                    timed_out: false,
206                    timeout_reason: None,
207                });
208                let details = if !run.stderr.trim().is_empty() {
209                    run.stderr.trim().to_string()
210                } else if !run.stdout.trim().is_empty() {
211                    run.stdout.trim().to_string()
212                } else {
213                    "no output".to_string()
214                };
215                ToolResult {
216                    content: format!(
217                        "Thread '{}' failed (exit {}):\n{}",
218                        thread_name, run.exit_code, details
219                    ),
220                    is_error: true,
221                }
222            }
223            Ok(run) => {
224                tracing::info!(
225                    thread_name = %thread_name,
226                    exit_code = run.exit_code,
227                    stderr_len = run.stderr.len(),
228                    stdout_len = run.stdout.len(),
229                    "managed worker thread completed successfully"
230                );
231                runtime.event_sink.emit(AgentEvent::ThreadFinished {
232                    name: thread_name.clone(),
233                    exit_code: run.exit_code,
234                    timed_out: false,
235                    timeout_reason: None,
236                });
237                ToolResult {
238                    content: run.stdout.trim().to_string(),
239                    is_error: false,
240                }
241            }
242        }
243    }
244    .instrument(tracing::info_span!(
245        "thread_dispatch",
246        session_id = %session_id,
247        thread_name = %thread_name,
248        source_thread_count = source_threads.len(),
249        timeout_secs,
250        store_path = %runtime.store_path.display(),
251        sandboxed = runtime.sandbox.is_some(),
252    ))
253    .await
254}
255
256pub async fn execute_threads(runtime: &ToolRuntime) -> ToolResult {
257    let session_id = match require_session(runtime) {
258        Ok(s) => s.to_string(),
259        Err(e) => return e,
260    };
261
262    let store_path = runtime.store_path.clone();
263    let sid = session_id.clone();
264    let threads =
265        match tokio::task::spawn_blocking(move || store::list_threads(&store_path, &sid)).await {
266            Ok(Ok(threads)) => threads,
267            Ok(Err(error)) => {
268                return ToolResult {
269                    content: format!("Error listing threads: {}", error),
270                    is_error: true,
271                }
272            }
273            Err(join_error) => {
274                return ToolResult {
275                    content: format!("Internal error listing threads: {}", join_error),
276                    is_error: true,
277                }
278            }
279        };
280
281    if threads.is_empty() {
282        return ToolResult {
283            content: "No retained threads in this session.".to_string(),
284            is_error: false,
285        };
286    }
287
288    let active_threads = runtime.active_threads.lock().await.clone();
289
290    let mut output = String::from("Retained threads:");
291    for thread in threads {
292        output.push_str(&format!(
293            "\n- {} | {} episodes | status: {} | created {} | updated {}",
294            thread.name,
295            thread.episode_count,
296            if active_threads.contains(&thread.name) {
297                "running"
298            } else {
299                "retained"
300            },
301            thread.created_at,
302            thread.updated_at
303        ));
304        if let Some(action) = thread.latest_action.as_deref() {
305            output.push_str(&format!(" | last action: {}", action));
306        }
307    }
308
309    ToolResult {
310        content: output,
311        is_error: false,
312    }
313}
314
315pub async fn execute_thread_read(args: Value, runtime: &ToolRuntime) -> ToolResult {
316    let thread_name = match require_str(&args, "name") {
317        Ok(s) => s,
318        Err(e) => return e,
319    };
320    let session_id = match require_session(runtime) {
321        Ok(s) => s.to_string(),
322        Err(e) => return e,
323    };
324
325    let store_path = runtime.store_path.clone();
326    let sid = session_id.clone();
327    let tname = thread_name.clone();
328    match tokio::task::spawn_blocking(move || store::thread_read(&store_path, &sid, &tname)).await {
329        Ok(Ok(episodes)) => ToolResult {
330            content: store::render_thread_document(&thread_name, &episodes),
331            is_error: false,
332        },
333        Ok(Err(error)) => ToolResult {
334            content: format!("Error reading thread '{}': {}", thread_name, error),
335            is_error: true,
336        },
337        Err(join_error) => ToolResult {
338            content: format!(
339                "Internal error reading thread '{}': {}",
340                thread_name, join_error
341            ),
342            is_error: true,
343        },
344    }
345}
346
347pub async fn execute_thread_delete(args: Value, runtime: &ToolRuntime) -> ToolResult {
348    let thread_name = match require_str(&args, "name") {
349        Ok(s) => s,
350        Err(e) => return e,
351    };
352    let session_id = match require_session(runtime) {
353        Ok(s) => s.to_string(),
354        Err(e) => return e,
355    };
356
357    if is_thread_active(runtime, &thread_name).await {
358        return ToolResult {
359            content: format!(
360                "Thread '{}' is currently running; wait for it to finish before deleting it.",
361                thread_name
362            ),
363            is_error: true,
364        };
365    }
366
367    let store_path = runtime.store_path.clone();
368    let sid = session_id.clone();
369    let tname = thread_name.clone();
370    match tokio::task::spawn_blocking(move || store::delete_thread(&store_path, &sid, &tname)).await
371    {
372        Ok(Ok(true)) => ToolResult {
373            content: format!(
374                "Deleted thread '{}' and its retained episodes.",
375                thread_name
376            ),
377            is_error: false,
378        },
379        Ok(Ok(false)) => ToolResult {
380            content: format!("Thread '{}' does not exist in this session.", thread_name),
381            is_error: true,
382        },
383        Ok(Err(error)) => ToolResult {
384            content: format!("Error deleting thread '{}': {}", thread_name, error),
385            is_error: true,
386        },
387        Err(join_error) => ToolResult {
388            content: format!(
389                "Internal error deleting thread '{}': {}",
390                thread_name, join_error
391            ),
392            is_error: true,
393        },
394    }
395}
396
397fn def(name: &str, description: &str, parameters: serde_json::Value) -> ToolDefinition {
398    ToolDefinition {
399        def_type: "function".to_string(),
400        function: crate::types::FunctionDef {
401            name: name.to_string(),
402            description: description.to_string(),
403            parameters,
404        },
405    }
406}
407
408fn require_session(runtime: &ToolRuntime) -> Result<&str, ToolResult> {
409    runtime.session_id.as_deref().ok_or_else(|| ToolResult {
410        content: "Error: thread tools require an active session".to_string(),
411        is_error: true,
412    })
413}
414
415fn resolve_thread_timeout_secs(args: &Value, default_timeout_secs: u64) -> u64 {
416    args.get("timeout")
417        .and_then(|v| v.as_u64())
418        .unwrap_or(default_timeout_secs)
419        .max(MIN_THREAD_TIMEOUT_SECS)
420}
421
422async fn mark_thread_active(runtime: &ToolRuntime, thread_name: &str) -> bool {
423    let mut active = runtime.active_threads.lock().await;
424    if active.contains(thread_name) {
425        false
426    } else {
427        active.insert(thread_name.to_string());
428        true
429    }
430}
431
432async fn unmark_thread_active(runtime: &ToolRuntime, thread_name: &str) {
433    runtime.active_threads.lock().await.remove(thread_name);
434}
435
436async fn is_thread_active(runtime: &ToolRuntime, thread_name: &str) -> bool {
437    runtime.active_threads.lock().await.contains(thread_name)
438}
439
440struct WorkerRun {
441    stdout: String,
442    stderr: String,
443    exit_code: i32,
444    timed_out: bool,
445    timeout_reason: Option<String>,
446}
447
448#[derive(Clone, Debug, PartialEq, Eq)]
449struct ActiveToolCallTrace {
450    name: String,
451    args_detail: Option<String>,
452}
453
454#[derive(Clone, Debug, PartialEq, Eq)]
455enum TimeoutLocation {
456    Startup,
457    ModelApi { iteration: usize },
458    ToolCall,
459    BetweenToolAndModel,
460    Finalizing,
461}
462
463impl Default for TimeoutLocation {
464    fn default() -> Self {
465        Self::Startup
466    }
467}
468
469#[derive(Default)]
470struct WorkerTimeoutTrace {
471    location: TimeoutLocation,
472    active_tool_calls: BTreeMap<String, ActiveToolCallTrace>,
473}
474
475impl WorkerTimeoutTrace {
476    fn observe(&mut self, event: &AgentEvent) {
477        match event {
478            AgentEvent::RunStarted { .. } => {
479                self.location = TimeoutLocation::Startup;
480                self.active_tool_calls.clear();
481            }
482            AgentEvent::ModelCallStarted { iteration, .. } => {
483                self.location = TimeoutLocation::ModelApi {
484                    iteration: *iteration,
485                };
486                self.active_tool_calls.clear();
487            }
488            AgentEvent::ToolCallStarted {
489                call_id,
490                name,
491                args_detail,
492                ..
493            } => {
494                self.location = TimeoutLocation::ToolCall;
495                self.active_tool_calls.insert(
496                    call_id.clone(),
497                    ActiveToolCallTrace {
498                        name: name.clone(),
499                        args_detail: args_detail.clone(),
500                    },
501                );
502            }
503            AgentEvent::ToolCallFinished { call_id, .. } => {
504                self.active_tool_calls.remove(call_id);
505                if self.active_tool_calls.is_empty() {
506                    self.location = TimeoutLocation::BetweenToolAndModel;
507                } else {
508                    self.location = TimeoutLocation::ToolCall;
509                }
510            }
511            AgentEvent::AssistantMessage { .. } | AgentEvent::RunFinished { .. } => {
512                self.location = TimeoutLocation::Finalizing;
513                self.active_tool_calls.clear();
514            }
515            AgentEvent::Error { .. }
516            | AgentEvent::ThreadLog { .. }
517            | AgentEvent::TerminalSnapshot { .. } => {}
518            AgentEvent::ThreadStarted { .. }
519            | AgentEvent::ThreadSpawned { .. }
520            | AgentEvent::ThreadFinished { .. } => {}
521            AgentEvent::StreamTextDelta { .. } | AgentEvent::StreamComplete { .. } => {}
522            AgentEvent::ModelIterationUsage { .. } => {}
523            AgentEvent::GoalContinuation { .. }
524            | AgentEvent::GoalTurnAccounted { .. }
525            | AgentEvent::GoalErrorTransition { .. } => {}
526            AgentEvent::LeanResumeTriggered { .. } => {}
527        }
528    }
529
530    fn timeout_reason(&self) -> String {
531        match &self.location {
532            TimeoutLocation::ModelApi { iteration } => format!(
533                "The thread timed out at a call to the model API.\nModel call: iteration {}",
534                iteration
535            ),
536            TimeoutLocation::ToolCall if !self.active_tool_calls.is_empty() => {
537                if self.active_tool_calls.len() == 1 {
538                    let (call_id, call) = self.active_tool_calls.iter().next().unwrap();
539                    return format!(
540                        "The thread timed out at a tool call.\nTool call: {} {}\narguments: {}",
541                        call.name,
542                        call_id,
543                        call.args_detail.as_deref().unwrap_or("<not captured>")
544                    );
545                }
546
547                let mut reason = String::from("The thread timed out at tool calls:");
548                for (call_id, call) in &self.active_tool_calls {
549                    reason.push_str(&format!("\n- {} {}", call.name, call_id));
550                    match call.args_detail.as_deref() {
551                        Some(args_detail) => {
552                            reason.push_str(&format!("\n  arguments: {}", args_detail));
553                        }
554                        None => reason.push_str("\n  arguments: <not captured>"),
555                    }
556                }
557                reason
558            }
559            TimeoutLocation::BetweenToolAndModel => {
560                "The thread timed out after tool call completion while preparing the next model API call."
561                    .to_string()
562            }
563            TimeoutLocation::Finalizing => {
564                "The thread timed out after producing a final response while the worker was exiting."
565                    .to_string()
566            }
567            TimeoutLocation::Startup | TimeoutLocation::ToolCall => {
568                "The thread timed out before entering a model API call or tool call.".to_string()
569            }
570        }
571    }
572}
573
574async fn run_worker(
575    runtime: &ToolRuntime,
576    client: &ModelClient,
577    session_id: &str,
578    thread_name: &str,
579    action: &str,
580    source_threads: &[String],
581    timeout_secs: u64,
582) -> std::io::Result<WorkerRun> {
583    let executable = runtime.worker_executable.clone().ok_or_else(|| {
584        std::io::Error::new(
585            std::io::ErrorKind::NotFound,
586            "worker executable path is not configured",
587        )
588    })?;
589    let executable_display = executable.display().to_string();
590    let cwd = std::env::current_dir()?;
591    tracing::debug!(
592        thread_name = %thread_name,
593        session_id = %session_id,
594        executable = %executable_display,
595        cwd = %cwd.display(),
596        sandboxed = runtime.sandbox.is_some(),
597        source_threads = ?source_threads,
598        timeout_secs,
599        "resolved managed worker spawn context"
600    );
601    let mut command = Command::new(executable);
602    command
603        .arg("__worker")
604        .arg("--session-id")
605        .arg(session_id)
606        .arg("--thread-name")
607        .arg(thread_name)
608        .arg("--action")
609        .arg(action)
610        .arg("--api-model")
611        .arg(client.model.as_str())
612        .arg("--api-base-url")
613        .arg(client.base_url())
614        .arg("--backend")
615        .arg(client.backend().as_str())
616        .arg("--store-path")
617        .arg(runtime.store_path.as_os_str())
618        .stdout(Stdio::piped())
619        .stderr(Stdio::piped());
620
621    if let Some(reasoning_effort) = client.reasoning_effort() {
622        command.arg("--effort").arg(reasoning_effort.as_str());
623    }
624
625    for source_thread in source_threads {
626        command.arg("--source-thread").arg(source_thread);
627    }
628    if let Some(sandbox) = &runtime.sandbox {
629        command.args(sandbox.worker_cli_args());
630    }
631    isolate_process_group(&mut command);
632
633    let mut child = command.spawn().map_err(|error| {
634        tracing::error!(
635            thread_name = %thread_name,
636            session_id = %session_id,
637            executable = %executable_display,
638            cwd = %cwd.display(),
639            sandboxed = runtime.sandbox.is_some(),
640            error = %error,
641            "managed worker spawn failed"
642        );
643        error
644    })?;
645
646    runtime.event_sink.emit(AgentEvent::ThreadSpawned {
647        name: thread_name.to_string(),
648        executable: executable_display.clone(),
649        cwd: cwd.display().to_string(),
650        sandboxed: runtime.sandbox.is_some(),
651    });
652    tracing::info!(
653        thread_name = %thread_name,
654        session_id = %session_id,
655        pid = ?child.id(),
656        "managed worker process spawned"
657    );
658
659    let timeout_trace = Arc::new(Mutex::new(WorkerTimeoutTrace::default()));
660    let stderr = child.stderr.take().unwrap();
661    let event_sink = runtime.event_sink.clone();
662    let thread_name_for_logs = thread_name.to_string();
663    let timeout_trace_for_logs = timeout_trace.clone();
664    let terminal_manager = runtime.terminal_manager.clone();
665    let thread_name_for_terminal_events = thread_name.to_string();
666    let stderr_handle = tokio::spawn(async move {
667        let reader = BufReader::new(stderr);
668        let mut lines = reader.lines();
669        let mut output = String::new();
670        while let Ok(Some(line)) = lines.next_line().await {
671            if let Some(event) = decode_stderr_event(&line) {
672                timeout_trace_for_logs.lock().await.observe(&event);
673                event_sink.emit(event);
674            } else {
675                let terminals = terminal_manager.list().await;
676                event_sink.emit(AgentEvent::TerminalSnapshot {
677                    thread_name: Some(thread_name_for_terminal_events.clone()),
678                    terminals,
679                });
680                event_sink.emit(AgentEvent::ThreadLog {
681                    name: thread_name_for_logs.clone(),
682                    line: line.clone(),
683                });
684                if !output.is_empty() {
685                    output.push('\n');
686                }
687                output.push_str(&line);
688            }
689        }
690        output
691    });
692
693    let stdout = child.stdout.take().unwrap();
694    let stdout_handle = tokio::spawn(async move {
695        let reader = BufReader::new(stdout);
696        let mut lines = reader.lines();
697        let mut output = String::new();
698        while let Ok(Some(line)) = lines.next_line().await {
699            if !output.is_empty() {
700                output.push('\n');
701            }
702            output.push_str(&line);
703        }
704        output
705    });
706
707    let status = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
708    let timed_out = status.is_err();
709    if timed_out {
710        terminate_child_tree(&mut child).await;
711    }
712
713    let stderr = stderr_handle.await.unwrap_or_default();
714    let stdout = stdout_handle.await.unwrap_or_default();
715    let timeout_reason = if timed_out {
716        Some(timeout_trace.lock().await.timeout_reason())
717    } else {
718        None
719    };
720    let exit_code = match status {
721        Ok(wait_result) => wait_result?.code().unwrap_or(-1),
722        Err(_) => -1,
723    };
724
725    Ok(WorkerRun {
726        stdout,
727        stderr,
728        exit_code,
729        timed_out,
730        timeout_reason,
731    })
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use serde_json::json;
738
739    #[test]
740    fn thread_timeout_defaults_to_one_hour() {
741        assert_eq!(
742            resolve_thread_timeout_secs(&json!({}), DEFAULT_THREAD_TIMEOUT_SECS),
743            60 * 60
744        );
745    }
746
747    #[test]
748    fn thread_timeout_is_clamped_to_thirty_minutes() {
749        assert_eq!(resolve_thread_timeout_secs(&json!({}), 10), 30 * 60);
750        assert_eq!(
751            resolve_thread_timeout_secs(&json!({ "timeout": 20 }), DEFAULT_THREAD_TIMEOUT_SECS),
752            30 * 60
753        );
754        assert_eq!(
755            resolve_thread_timeout_secs(&json!({ "timeout": 7200 }), DEFAULT_THREAD_TIMEOUT_SECS),
756            7200
757        );
758    }
759
760    #[test]
761    fn timeout_trace_reports_model_api_location() {
762        let mut trace = WorkerTimeoutTrace::default();
763        trace.observe(&AgentEvent::ModelCallStarted {
764            thread_name: Some("impl/auth".to_string()),
765            iteration: 2,
766        });
767
768        assert_eq!(
769            trace.timeout_reason(),
770            "The thread timed out at a call to the model API.\nModel call: iteration 2"
771        );
772    }
773
774    #[test]
775    fn timeout_trace_reports_active_tool_call_details() {
776        let mut trace = WorkerTimeoutTrace::default();
777        trace.observe(&AgentEvent::ToolCallStarted {
778            thread_name: Some("impl/auth".to_string()),
779            call_id: "call_123".to_string(),
780            name: "exec_command".to_string(),
781            args_preview: "cargo test -p sac".to_string(),
782            args_detail: Some(
783                r#"{"cmd":"cargo test -p sac","tty":false,"yield_time_ms":300000}"#.to_string(),
784            ),
785        });
786
787        assert_eq!(
788            trace.timeout_reason(),
789            "The thread timed out at a tool call.\nTool call: exec_command call_123\narguments: {\"cmd\":\"cargo test -p sac\",\"tty\":false,\"yield_time_ms\":300000}"
790        );
791    }
792
793    #[test]
794    fn timeout_trace_ignores_thread_spawned_event() {
795        let mut trace = WorkerTimeoutTrace::default();
796        trace.observe(&AgentEvent::ThreadSpawned {
797            name: "impl/auth".to_string(),
798            executable: "/home/secemp9/.local/bin/sac".to_string(),
799            cwd: "/workspace/project".to_string(),
800            sandboxed: false,
801        });
802
803        assert_eq!(
804            trace.timeout_reason(),
805            "The thread timed out before entering a model API call or tool call."
806        );
807    }
808}