1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use std::time::Duration;
6
7use anyhow::Result;
8use codewhale_agent::ModelRegistry;
9use codewhale_config::{CliRuntimeOverrides, ConfigToml, ProviderKind};
10use codewhale_execpolicy::{
11 AskForApproval, ExecApprovalRequirement, ExecPolicyContext, ExecPolicyDecision,
12 ExecPolicyEngine,
13};
14use codewhale_hooks::{HookDispatcher, HookEvent};
15use codewhale_mcp::{
16 McpManager, McpStartupCompleteEvent, McpStartupStatus as McpManagerStartupStatus,
17};
18use codewhale_protocol::{
19 AppResponse, EventFrame, ExecApprovalRequestEvent, PromptRequest, PromptResponse,
20 ResponseChannel, ReviewDecision, Status, Thread, ThreadForkParams, ThreadGoal,
21 ThreadGoalClearParams, ThreadGoalGetParams, ThreadGoalProgressParams, ThreadGoalSetParams,
22 ThreadGoalStatus, ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse,
23 ThreadResumeParams, ThreadSetNameParams, ThreadStatus, ToolPayload, UserInputRequestEvent,
24};
25use codewhale_state::{
26 JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord,
27 ThreadGoalStatus as PersistedThreadGoalStatus, ThreadListFilters, ThreadMetadata,
28 ThreadStatus as PersistedThreadStatus,
29};
30use codewhale_tools::{ToolCall, ToolRegistry};
31use serde_json::{Value, json};
32use tokio::time;
33use uuid::Uuid;
34
35fn tool_dispatch_timeout() -> Duration {
38 if cfg!(test) {
39 Duration::from_millis(50)
40 } else {
41 Duration::from_secs(300)
42 }
43}
44
45#[derive(Debug, Clone)]
47pub enum InitialHistory {
48 New,
50 Forked(Vec<Value>),
52 Resumed {
54 conversation_id: String,
55 history: Vec<Value>,
56 rollout_path: PathBuf,
57 },
58}
59
60#[derive(Debug, Clone)]
62pub struct NewThread {
63 pub thread: Thread,
65 pub model: String,
67 pub model_provider: String,
69 pub cwd: PathBuf,
71 pub approval_policy: Option<String>,
73 pub sandbox: Option<String>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum JobStatus {
80 Queued,
82 Running,
84 Paused,
86 Completed,
88 Failed,
90 Cancelled,
92}
93
94impl Status for JobStatus {
95 fn is_terminal(&self) -> bool {
96 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
97 }
98 fn is_active(&self) -> bool {
99 matches!(self, Self::Queued | Self::Running)
100 }
101 fn is_paused(&self) -> bool {
102 matches!(self, Self::Paused)
103 }
104}
105
106const JOB_DETAIL_SCHEMA_VERSION: u8 = 1;
107const DEFAULT_JOB_MAX_ATTEMPTS: u32 = 3;
108const DEFAULT_JOB_BACKOFF_BASE_MS: u64 = 500;
109const MAX_JOB_HISTORY_ENTRIES: usize = 64;
110
111#[derive(Debug, Clone)]
113pub struct JobRetryMetadata {
114 pub attempt: u32,
116 pub max_attempts: u32,
118 pub backoff_base_ms: u64,
120 pub next_backoff_ms: u64,
122 pub next_retry_at: Option<i64>,
124}
125
126impl Default for JobRetryMetadata {
127 fn default() -> Self {
128 Self {
129 attempt: 0,
130 max_attempts: DEFAULT_JOB_MAX_ATTEMPTS,
131 backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS,
132 next_backoff_ms: 0,
133 next_retry_at: None,
134 }
135 }
136}
137
138#[derive(Debug, Clone)]
140pub struct JobHistoryEntry {
141 pub at: i64,
143 pub phase: String,
145 pub status: JobStatus,
147 pub progress: Option<u8>,
149 pub detail: Option<String>,
151 pub retry: JobRetryMetadata,
153}
154
155#[derive(Debug, Clone)]
156struct PersistedJobDetail {
157 pub status: JobStatus,
158 pub detail: Option<String>,
159 pub retry: JobRetryMetadata,
160 pub history: Vec<JobHistoryEntry>,
161}
162
163#[derive(Debug, Clone)]
165pub struct JobRecord {
166 pub id: String,
168 pub name: String,
170 pub status: JobStatus,
172 pub progress: Option<u8>,
174 pub detail: Option<String>,
176 pub retry: JobRetryMetadata,
178 pub history: Vec<JobHistoryEntry>,
180 pub created_at: i64,
182 pub updated_at: i64,
184}
185
186#[derive(Debug, Default)]
188pub struct JobManager {
189 jobs: HashMap<String, JobRecord>,
190}
191
192impl JobManager {
193 fn now_ts() -> i64 {
194 chrono::Utc::now().timestamp()
195 }
196
197 fn deterministic_backoff_ms(retry: &JobRetryMetadata) -> u64 {
198 if retry.attempt == 0 {
199 return 0;
200 }
201 let exponent = retry.attempt.saturating_sub(1).min(20);
202 let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
203 retry.backoff_base_ms.saturating_mul(multiplier)
204 }
205
206 fn clear_retry_schedule(retry: &mut JobRetryMetadata) {
207 retry.next_backoff_ms = 0;
208 retry.next_retry_at = None;
209 }
210
211 fn push_history(job: &mut JobRecord, phase: &str) {
212 job.history.push(JobHistoryEntry {
213 at: job.updated_at,
214 phase: phase.to_string(),
215 status: job.status,
216 progress: job.progress,
217 detail: job.detail.clone(),
218 retry: job.retry.clone(),
219 });
220 if job.history.len() > MAX_JOB_HISTORY_ENTRIES {
221 let to_drain = job.history.len() - MAX_JOB_HISTORY_ENTRIES;
222 job.history.drain(0..to_drain);
223 }
224 }
225
226 fn parse_persisted_detail(raw: Option<&str>) -> Option<PersistedJobDetail> {
227 let raw = raw?;
228 let parsed: Value = serde_json::from_str(raw).ok()?;
229 let status = parsed
230 .get("status")
231 .and_then(Value::as_str)
232 .and_then(job_status_from_str)?;
233 let detail = parsed.get("detail").and_then(json_optional_string);
234 let retry = parse_retry_metadata(parsed.get("retry"));
235 let history = parsed
236 .get("history")
237 .and_then(Value::as_array)
238 .map(|items| {
239 items
240 .iter()
241 .filter_map(parse_history_entry)
242 .collect::<Vec<_>>()
243 })
244 .unwrap_or_default();
245 Some(PersistedJobDetail {
246 status,
247 detail,
248 retry,
249 history,
250 })
251 }
252
253 fn encode_persisted_detail(job: &JobRecord) -> Result<Option<String>> {
254 let encoded = json!({
255 "schema_version": JOB_DETAIL_SCHEMA_VERSION,
256 "status": job_status_to_str(job.status),
257 "detail": job.detail.clone(),
258 "retry": job_retry_to_value(&job.retry),
259 "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
260 })
261 .to_string();
262 Ok(Some(encoded))
263 }
264
265 pub fn enqueue(&mut self, name: impl Into<String>) -> JobRecord {
267 let now = Self::now_ts();
268 let id = format!("job-{}", Uuid::new_v4());
269 let mut job = JobRecord {
270 id: id.clone(),
271 name: name.into(),
272 status: JobStatus::Queued,
273 progress: Some(0),
274 detail: None,
275 retry: JobRetryMetadata::default(),
276 history: Vec::new(),
277 created_at: now,
278 updated_at: now,
279 };
280 Self::push_history(&mut job, "created");
281 self.jobs.insert(id, job.clone());
282 job
283 }
284
285 pub fn set_running(&mut self, id: &str) {
287 if let Some(job) = self.jobs.get_mut(id) {
288 job.status = JobStatus::Running;
289 Self::clear_retry_schedule(&mut job.retry);
290 job.updated_at = Self::now_ts();
291 Self::push_history(job, "running");
292 }
293 }
294
295 pub fn update_progress(&mut self, id: &str, progress: u8, detail: Option<String>) {
297 if let Some(job) = self.jobs.get_mut(id) {
298 job.progress = Some(progress.min(100));
299 job.detail = detail;
300 job.updated_at = Self::now_ts();
301 Self::push_history(job, "progress_updated");
302 }
303 }
304
305 pub fn complete(&mut self, id: &str) {
307 if let Some(job) = self.jobs.get_mut(id) {
308 job.status = JobStatus::Completed;
309 job.progress = Some(100);
310 Self::clear_retry_schedule(&mut job.retry);
311 job.updated_at = Self::now_ts();
312 Self::push_history(job, "completed");
313 }
314 }
315
316 pub fn fail(&mut self, id: &str, detail: impl Into<String>) {
318 if let Some(job) = self.jobs.get_mut(id) {
319 let now = Self::now_ts();
320 job.status = JobStatus::Failed;
321 job.detail = Some(detail.into());
322 if job.retry.attempt < job.retry.max_attempts {
323 job.retry.attempt += 1;
324 job.retry.next_backoff_ms = Self::deterministic_backoff_ms(&job.retry);
325 let delay_secs = ((job.retry.next_backoff_ms.saturating_add(999)) / 1000)
326 .min(i64::MAX as u64) as i64;
327 job.retry.next_retry_at = Some(now.saturating_add(delay_secs));
328 } else {
329 Self::clear_retry_schedule(&mut job.retry);
330 }
331 job.updated_at = now;
332 Self::push_history(job, "failed");
333 }
334 }
335
336 pub fn cancel(&mut self, id: &str) {
338 if let Some(job) = self.jobs.get_mut(id) {
339 job.status = JobStatus::Cancelled;
340 Self::clear_retry_schedule(&mut job.retry);
341 job.updated_at = Self::now_ts();
342 Self::push_history(job, "cancelled");
343 }
344 }
345
346 pub fn pause(&mut self, id: &str, detail: Option<String>) {
348 if let Some(job) = self.jobs.get_mut(id) {
349 job.status = JobStatus::Paused;
350 if detail.is_some() {
351 job.detail = detail;
352 }
353 job.updated_at = Self::now_ts();
354 Self::push_history(job, "paused");
355 }
356 }
357
358 pub fn resume(&mut self, id: &str, detail: Option<String>) {
360 if let Some(job) = self.jobs.get_mut(id) {
361 job.status = JobStatus::Running;
362 if detail.is_some() {
363 job.detail = detail;
364 }
365 Self::clear_retry_schedule(&mut job.retry);
366 job.updated_at = Self::now_ts();
367 Self::push_history(job, "resumed");
368 }
369 }
370
371 pub fn list(&self) -> Vec<JobRecord> {
373 let mut out = self.jobs.values().cloned().collect::<Vec<_>>();
374 out.sort_by_key(|job| std::cmp::Reverse(job.updated_at));
375 out
376 }
377
378 pub fn history(&self, id: &str) -> Vec<JobHistoryEntry> {
380 self.jobs
381 .get(id)
382 .map(|job| job.history.clone())
383 .unwrap_or_default()
384 }
385
386 pub fn resume_pending(&mut self) -> Vec<JobRecord> {
388 let mut resumed = Vec::new();
389 for job in self.jobs.values_mut() {
390 if matches!(job.status, JobStatus::Queued | JobStatus::Running) {
391 job.status = JobStatus::Queued;
392 job.updated_at = Self::now_ts();
393 Self::push_history(job, "queued_after_resume");
394 resumed.push(job.clone());
395 }
396 }
397 resumed
398 }
399
400 pub fn load_from_store(&mut self, store: &StateStore) -> Result<()> {
402 let persisted = store.list_jobs(Some(500))?;
403 for job in persisted {
404 let fallback_status = job_state_status_to_runtime(job.status);
405 let parsed = Self::parse_persisted_detail(job.detail.as_deref());
406 let (status, detail, retry, history) = if let Some(detail_state) = parsed {
407 (
408 detail_state.status,
409 detail_state.detail,
410 detail_state.retry,
411 detail_state.history,
412 )
413 } else {
414 (
415 fallback_status,
416 job.detail,
417 JobRetryMetadata::default(),
418 Vec::new(),
419 )
420 };
421 self.jobs.insert(
422 job.id.clone(),
423 JobRecord {
424 id: job.id,
425 name: job.name,
426 status,
427 progress: job.progress,
428 detail,
429 retry,
430 history,
431 created_at: job.created_at,
432 updated_at: job.updated_at,
433 },
434 );
435 }
436 Ok(())
437 }
438
439 pub fn persist_job(&self, store: &StateStore, id: &str) -> Result<()> {
441 let Some(job) = self.jobs.get(id) else {
442 return Ok(());
443 };
444 let encoded_detail = Self::encode_persisted_detail(job)?;
445 store.upsert_job(&JobStateRecord {
446 id: job.id.clone(),
447 name: job.name.clone(),
448 status: runtime_status_to_job_state(job.status),
449 progress: job.progress,
450 detail: encoded_detail,
451 created_at: job.created_at,
452 updated_at: job.updated_at,
453 })
454 }
455
456 pub fn persist_all(&self, store: &StateStore) -> Result<()> {
458 for id in self.jobs.keys() {
459 self.persist_job(store, id)?;
460 }
461 Ok(())
462 }
463}
464
465pub struct ThreadManager {
467 store: StateStore,
468 running_threads: HashMap<String, Thread>,
469 cli_version: String,
470}
471
472impl ThreadManager {
473 pub fn new(store: StateStore) -> Self {
475 Self {
476 store,
477 running_threads: HashMap::new(),
478 cli_version: env!("CARGO_PKG_VERSION").to_string(),
479 }
480 }
481
482 pub fn state_store(&self) -> &StateStore {
484 &self.store
485 }
486
487 pub fn spawn_thread_with_history(
489 &mut self,
490 model_provider: String,
491 cwd: PathBuf,
492 initial_history: InitialHistory,
493 persist_extended_history: bool,
494 ) -> Result<NewThread> {
495 let id = format!("thread-{}", Uuid::new_v4());
496 let now = chrono::Utc::now().timestamp();
497 let preview = preview_from_initial_history(&initial_history);
498 let source = match initial_history {
499 InitialHistory::New => SessionSource::Interactive,
500 InitialHistory::Forked(_) => SessionSource::Fork,
501 InitialHistory::Resumed { .. } => SessionSource::Resume,
502 };
503 let thread = Thread {
504 id: id.clone(),
505 preview,
506 ephemeral: !persist_extended_history,
507 model_provider: model_provider.clone(),
508 created_at: now,
509 updated_at: now,
510 status: ThreadStatus::Running,
511 path: None,
512 cwd: cwd.clone(),
513 cli_version: self.cli_version.clone(),
514 source: match source {
515 SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
516 SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
517 SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
518 SessionSource::Api => codewhale_protocol::SessionSource::Api,
519 SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
520 },
521 name: None,
522 };
523 self.persist_thread(&thread, None)?;
524 match &initial_history {
525 InitialHistory::Forked(items) => {
526 for item in items {
527 self.store.append_message(
528 &thread.id,
529 "history",
530 &item.to_string(),
531 Some(item.clone()),
532 )?;
533 }
534 }
535 InitialHistory::Resumed { history, .. } => {
536 for item in history {
537 self.store.append_message(
538 &thread.id,
539 "history",
540 &item.to_string(),
541 Some(item.clone()),
542 )?;
543 }
544 }
545 InitialHistory::New => {}
546 }
547 self.running_threads
548 .insert(thread.id.clone(), thread.clone());
549 Ok(NewThread {
550 thread,
551 model: "auto".to_string(),
552 model_provider,
553 cwd,
554 approval_policy: None,
555 sandbox: None,
556 })
557 }
558
559 pub fn resume_thread_with_history(
561 &mut self,
562 params: &ThreadResumeParams,
563 fallback_cwd: &Path,
564 model_provider: String,
565 ) -> Result<Option<NewThread>> {
566 if params.history.is_none()
567 && let Some(thread) = self.running_threads.get(¶ms.thread_id).cloned()
568 {
569 return Ok(Some(NewThread {
570 model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
571 model_provider: params.model_provider.clone().unwrap_or(model_provider),
572 cwd: params.cwd.clone().unwrap_or_else(|| thread.cwd.clone()),
573 approval_policy: params.approval_policy.clone(),
574 sandbox: params.sandbox.clone(),
575 thread,
576 }));
577 }
578
579 let persisted = self.store.get_thread(¶ms.thread_id)?;
580 let Some(metadata) = persisted else {
581 return Ok(None);
582 };
583 let mut thread = to_protocol_thread(metadata);
584 thread.status = ThreadStatus::Running;
585 thread.updated_at = chrono::Utc::now().timestamp();
586 thread.cwd = params
587 .cwd
588 .clone()
589 .unwrap_or_else(|| fallback_cwd.to_path_buf());
590 self.persist_thread(&thread, None)?;
591 self.running_threads
592 .insert(thread.id.clone(), thread.clone());
593 if let Some(history) = params.history.as_ref() {
594 for item in history {
595 self.store.append_message(
596 &thread.id,
597 "history",
598 &item.to_string(),
599 Some(item.clone()),
600 )?;
601 }
602 }
603
604 Ok(Some(NewThread {
605 model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
606 model_provider: params.model_provider.clone().unwrap_or(model_provider),
607 cwd: thread.cwd.clone(),
608 approval_policy: params.approval_policy.clone(),
609 sandbox: params.sandbox.clone(),
610 thread,
611 }))
612 }
613
614 pub fn fork_thread(
616 &mut self,
617 params: &ThreadForkParams,
618 fallback_cwd: &Path,
619 ) -> Result<Option<NewThread>> {
620 let parent = self.store.get_thread(¶ms.thread_id)?;
621 let Some(parent) = parent else {
622 return Ok(None);
623 };
624 let parent_thread = to_protocol_thread(parent);
625 let new = self.spawn_thread_with_history(
626 params
627 .model_provider
628 .clone()
629 .unwrap_or_else(|| parent_thread.model_provider.clone()),
630 params
631 .cwd
632 .clone()
633 .unwrap_or_else(|| fallback_cwd.to_path_buf()),
634 InitialHistory::Forked(vec![json!({
635 "type": "fork",
636 "from_thread_id": parent_thread.id
637 })]),
638 params.persist_extended_history,
639 )?;
640 Ok(Some(new))
641 }
642
643 pub fn list_threads(&self, params: &ThreadListParams) -> Result<Vec<Thread>> {
645 let list = self.store.list_threads(ThreadListFilters {
646 include_archived: params.include_archived,
647 limit: params.limit,
648 })?;
649 Ok(list.into_iter().map(to_protocol_thread).collect())
650 }
651
652 pub fn read_thread(&self, params: &ThreadReadParams) -> Result<Option<Thread>> {
654 Ok(self
655 .store
656 .get_thread(¶ms.thread_id)?
657 .map(to_protocol_thread))
658 }
659
660 pub fn set_thread_name(&mut self, params: &ThreadSetNameParams) -> Result<Option<Thread>> {
662 let Some(mut metadata) = self.store.get_thread(¶ms.thread_id)? else {
663 return Ok(None);
664 };
665 metadata.name = Some(params.name.clone());
666 metadata.updated_at = chrono::Utc::now().timestamp();
667 self.store.upsert_thread(&metadata)?;
668 let updated = to_protocol_thread(metadata);
669 self.running_threads
670 .insert(updated.id.clone(), updated.clone());
671 Ok(Some(updated))
672 }
673
674 pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
676 if self.store.get_thread(¶ms.thread_id)?.is_none() {
677 return Ok(None);
678 }
679 let now = chrono::Utc::now().timestamp();
680 let goal = ThreadGoalRecord {
681 thread_id: params.thread_id.clone(),
682 goal_id: format!("goal-{}", Uuid::new_v4()),
683 objective: params.objective.clone(),
684 status: PersistedThreadGoalStatus::Active,
685 token_budget: params.token_budget,
686 tokens_used: 0,
687 time_used_seconds: 0,
688 continuation_count: 0,
689 created_at: now,
690 updated_at: now,
691 };
692 self.store.upsert_thread_goal(&goal)?;
693 Ok(Some(to_protocol_goal(goal)))
694 }
695
696 pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result<Option<ThreadGoal>> {
698 Ok(self
699 .store
700 .get_thread_goal(¶ms.thread_id)?
701 .map(to_protocol_goal))
702 }
703
704 pub fn record_thread_goal_progress(
706 &mut self,
707 params: &ThreadGoalProgressParams,
708 ) -> Result<Option<ThreadGoal>> {
709 if self.store.get_thread(¶ms.thread_id)?.is_none() {
710 return Ok(None);
711 }
712
713 let now = chrono::Utc::now().timestamp();
714 let mut goal = if params.token_delta != 0 || params.time_delta_seconds != 0 {
715 self.store.record_thread_goal_usage(
716 ¶ms.thread_id,
717 params.token_delta,
718 params.time_delta_seconds,
719 now,
720 )?
721 } else {
722 self.store.get_thread_goal(¶ms.thread_id)?
723 };
724
725 if params.record_continuation {
726 goal = self
727 .store
728 .record_thread_goal_continuation(¶ms.thread_id, now)?;
729 }
730
731 Ok(goal.map(to_protocol_goal))
732 }
733
734 pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result<bool> {
736 self.store.delete_thread_goal(¶ms.thread_id)
737 }
738
739 pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> {
741 self.store.mark_archived(thread_id)?;
742 if let Some(thread) = self.running_threads.get_mut(thread_id) {
743 thread.status = ThreadStatus::Archived;
744 }
745 Ok(())
746 }
747
748 pub fn unarchive_thread(&mut self, thread_id: &str) -> Result<()> {
750 self.store.mark_unarchived(thread_id)?;
751 if let Some(metadata) = self.store.get_thread(thread_id)? {
752 let thread = to_protocol_thread(metadata);
753 if let Some(cached) = self.running_threads.get_mut(thread_id) {
754 *cached = thread;
755 }
756 }
757 Ok(())
758 }
759
760 pub fn touch_message(&mut self, thread_id: &str, input: &str) -> Result<()> {
762 let Some(mut metadata) = self.store.get_thread(thread_id)? else {
763 return Ok(());
764 };
765 metadata.updated_at = chrono::Utc::now().timestamp();
766 metadata.preview = truncate_preview(input);
767 metadata.status = PersistedThreadStatus::Running;
768 self.store.upsert_thread(&metadata)?;
769 if let Some(thread) = self.running_threads.get_mut(thread_id) {
770 thread.updated_at = metadata.updated_at;
771 thread.preview = metadata.preview;
772 thread.status = ThreadStatus::Running;
773 }
774 let message_id = self.store.append_message(thread_id, "user", input, None)?;
775 self.store.save_checkpoint(
776 thread_id,
777 "latest",
778 &json!({
779 "reason": "thread_message",
780 "message_id": message_id,
781 "role": "user",
782 "preview": truncate_preview(input),
783 "updated_at": metadata.updated_at
784 }),
785 )?;
786 Ok(())
787 }
788
789 fn persist_thread(&self, thread: &Thread, rollout_path: Option<PathBuf>) -> Result<()> {
790 self.store.upsert_thread(&ThreadMetadata {
791 id: thread.id.clone(),
792 rollout_path,
793 preview: thread.preview.clone(),
794 ephemeral: thread.ephemeral,
795 model_provider: thread.model_provider.clone(),
796 created_at: thread.created_at,
797 updated_at: thread.updated_at,
798 status: to_persisted_status(&thread.status),
799 path: thread.path.clone(),
800 cwd: thread.cwd.clone(),
801 cli_version: thread.cli_version.clone(),
802 source: to_persisted_source(&thread.source),
803 name: thread.name.clone(),
804 sandbox_policy: None,
805 approval_mode: None,
806 archived: matches!(thread.status, ThreadStatus::Archived),
807 archived_at: None,
808 git_sha: None,
809 git_branch: None,
810 git_origin_url: None,
811 memory_mode: None,
812 current_leaf_id: None,
813 })
814 }
815}
816
817pub struct Runtime {
819 pub config: ConfigToml,
821 pub model_registry: ModelRegistry,
823 pub thread_manager: ThreadManager,
825 pub tool_registry: Arc<ToolRegistry>,
827 pub mcp_manager: Arc<McpManager>,
829 pub exec_policy: ExecPolicyEngine,
831 pub hooks: HookDispatcher,
833 pub jobs: JobManager,
835}
836
837impl Runtime {
838 pub fn new(
840 config: ConfigToml,
841 model_registry: ModelRegistry,
842 state: StateStore,
843 tool_registry: Arc<ToolRegistry>,
844 mcp_manager: Arc<McpManager>,
845 exec_policy: ExecPolicyEngine,
846 hooks: HookDispatcher,
847 ) -> Self {
848 let mut jobs = JobManager::default();
849 if let Err(e) = jobs.load_from_store(&state) {
850 tracing::warn!("Failed to load job store, starting with empty job list: {e}");
851 }
852 Self {
853 config,
854 model_registry,
855 thread_manager: ThreadManager::new(state),
856 tool_registry,
857 mcp_manager,
858 exec_policy,
859 hooks,
860 jobs,
861 }
862 }
863
864 pub fn update_config(&mut self, config: ConfigToml) {
876 self.config = config;
877 }
878
879 pub fn reload_config_and_policy(&mut self, config: ConfigToml, exec_policy: ExecPolicyEngine) {
900 self.config = config;
901 self.exec_policy = exec_policy;
902 }
903
904 fn persisted_thread_data(&self, thread_id: &str) -> Result<Value> {
905 let history = self
906 .thread_manager
907 .state_store()
908 .list_messages(thread_id, Some(500))?
909 .into_iter()
910 .map(|message| {
911 json!({
912 "id": message.id,
913 "role": message.role,
914 "content": message.content,
915 "item": message.item,
916 "created_at": message.created_at
917 })
918 })
919 .collect::<Vec<_>>();
920
921 let checkpoint = self
922 .thread_manager
923 .state_store()
924 .load_checkpoint(thread_id, None)?
925 .map(|record| {
926 json!({
927 "checkpoint_id": record.checkpoint_id,
928 "state": record.state,
929 "created_at": record.created_at
930 })
931 });
932
933 let goal = self
934 .thread_manager
935 .state_store()
936 .get_thread_goal(thread_id)?
937 .map(to_protocol_goal);
938
939 Ok(json!({
940 "history": history,
941 "checkpoint": checkpoint,
942 "goal": goal
943 }))
944 }
945
946 fn persist_latest_checkpoint(&self, thread_id: &str, reason: &str, state: Value) -> Result<()> {
947 self.thread_manager.state_store().save_checkpoint(
948 thread_id,
949 "latest",
950 &json!({
951 "reason": reason,
952 "saved_at": chrono::Utc::now().timestamp(),
953 "state": state
954 }),
955 )
956 }
957
958 pub async fn handle_thread(&mut self, req: ThreadRequest) -> Result<ThreadResponse> {
960 match req {
961 ThreadRequest::Create { .. } => {
962 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
963 let new = self.thread_manager.spawn_thread_with_history(
964 "deepseek".to_string(),
965 cwd,
966 InitialHistory::New,
967 false,
968 )?;
969 let mut response = thread_response_from_new("created", new);
970 response.data = self.persisted_thread_data(&response.thread_id)?;
971 Ok(response)
972 }
973 ThreadRequest::Start(params) => {
974 let cwd = params.cwd.clone().unwrap_or_else(|| {
975 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
976 });
977 let new = self.thread_manager.spawn_thread_with_history(
978 params
979 .model_provider
980 .clone()
981 .unwrap_or_else(|| "deepseek".to_string()),
982 cwd,
983 InitialHistory::New,
984 params.persist_extended_history,
985 )?;
986 let mut response = thread_response_from_new("started", new);
987 response.data = self.persisted_thread_data(&response.thread_id)?;
988 Ok(response)
989 }
990 ThreadRequest::Resume(params) => {
991 let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
992 if let Some(new) = self.thread_manager.resume_thread_with_history(
993 ¶ms,
994 &fallback_cwd,
995 "deepseek".to_string(),
996 )? {
997 let mut response = thread_response_from_new("resumed", new);
998 response.data = self.persisted_thread_data(&response.thread_id)?;
999 Ok(response)
1000 } else {
1001 Ok(ThreadResponse {
1002 thread_id: params.thread_id,
1003 status: "missing".to_string(),
1004 thread: None,
1005 threads: Vec::new(),
1006 goal: None,
1007 model: None,
1008 model_provider: None,
1009 cwd: None,
1010 approval_policy: params.approval_policy,
1011 sandbox: params.sandbox,
1012 events: Vec::new(),
1013 data: json!({"error":"thread not found"}),
1014 })
1015 }
1016 }
1017 ThreadRequest::Fork(params) => {
1018 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1019 if let Some(new) = self.thread_manager.fork_thread(¶ms, &cwd)? {
1020 let mut response = thread_response_from_new("forked", new);
1021 response.data = self.persisted_thread_data(&response.thread_id)?;
1022 Ok(response)
1023 } else {
1024 Ok(ThreadResponse {
1025 thread_id: params.thread_id,
1026 status: "missing".to_string(),
1027 thread: None,
1028 threads: Vec::new(),
1029 goal: None,
1030 model: None,
1031 model_provider: None,
1032 cwd: None,
1033 approval_policy: params.approval_policy,
1034 sandbox: params.sandbox,
1035 events: Vec::new(),
1036 data: json!({"error":"thread not found"}),
1037 })
1038 }
1039 }
1040 ThreadRequest::List(params) => Ok(ThreadResponse {
1041 thread_id: "list".to_string(),
1042 status: "ok".to_string(),
1043 thread: None,
1044 threads: self.thread_manager.list_threads(¶ms)?,
1045 goal: None,
1046 model: None,
1047 model_provider: None,
1048 cwd: None,
1049 approval_policy: None,
1050 sandbox: None,
1051 events: Vec::new(),
1052 data: json!({}),
1053 }),
1054 ThreadRequest::Read(params) => {
1055 let id = params.thread_id.clone();
1056 let data = self.persisted_thread_data(&id)?;
1057 Ok(ThreadResponse {
1058 thread_id: id,
1059 status: "ok".to_string(),
1060 thread: self.thread_manager.read_thread(¶ms)?,
1061 threads: Vec::new(),
1062 goal: self.thread_manager.get_thread_goal(&ThreadGoalGetParams {
1063 thread_id: params.thread_id,
1064 })?,
1065 model: None,
1066 model_provider: None,
1067 cwd: None,
1068 approval_policy: None,
1069 sandbox: None,
1070 events: Vec::new(),
1071 data,
1072 })
1073 }
1074 ThreadRequest::SetName(params) => Ok(ThreadResponse {
1075 thread_id: params.thread_id.clone(),
1076 status: "ok".to_string(),
1077 thread: self.thread_manager.set_thread_name(¶ms)?,
1078 threads: Vec::new(),
1079 goal: None,
1080 model: None,
1081 model_provider: None,
1082 cwd: None,
1083 approval_policy: None,
1084 sandbox: None,
1085 events: Vec::new(),
1086 data: json!({}),
1087 }),
1088 ThreadRequest::GoalSet(params) => {
1089 let thread_id = params.thread_id.clone();
1090 if let Some(goal) = self.thread_manager.set_thread_goal(¶ms)? {
1091 Ok(ThreadResponse {
1092 thread_id,
1093 status: "ok".to_string(),
1094 thread: None,
1095 threads: Vec::new(),
1096 goal: Some(goal.clone()),
1097 model: None,
1098 model_provider: None,
1099 cwd: None,
1100 approval_policy: None,
1101 sandbox: None,
1102 events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1103 data: json!({ "goal": goal }),
1104 })
1105 } else {
1106 Ok(ThreadResponse {
1107 thread_id,
1108 status: "missing".to_string(),
1109 thread: None,
1110 threads: Vec::new(),
1111 goal: None,
1112 model: None,
1113 model_provider: None,
1114 cwd: None,
1115 approval_policy: None,
1116 sandbox: None,
1117 events: Vec::new(),
1118 data: json!({"error":"thread not found"}),
1119 })
1120 }
1121 }
1122 ThreadRequest::GoalGet(params) => {
1123 let goal = self.thread_manager.get_thread_goal(¶ms)?;
1124 Ok(ThreadResponse {
1125 thread_id: params.thread_id,
1126 status: "ok".to_string(),
1127 thread: None,
1128 threads: Vec::new(),
1129 goal: goal.clone(),
1130 model: None,
1131 model_provider: None,
1132 cwd: None,
1133 approval_policy: None,
1134 sandbox: None,
1135 events: Vec::new(),
1136 data: json!({ "goal": goal }),
1137 })
1138 }
1139 ThreadRequest::GoalClear(params) => {
1140 let thread_id = params.thread_id.clone();
1141 let cleared = self.thread_manager.clear_thread_goal(¶ms)?;
1142 Ok(ThreadResponse {
1143 thread_id: thread_id.clone(),
1144 status: if cleared { "cleared" } else { "empty" }.to_string(),
1145 thread: None,
1146 threads: Vec::new(),
1147 goal: None,
1148 model: None,
1149 model_provider: None,
1150 cwd: None,
1151 approval_policy: None,
1152 sandbox: None,
1153 events: if cleared {
1154 vec![EventFrame::ThreadGoalCleared { thread_id }]
1155 } else {
1156 Vec::new()
1157 },
1158 data: json!({ "cleared": cleared }),
1159 })
1160 }
1161 ThreadRequest::GoalRecordProgress(params) => {
1162 let thread_id = params.thread_id.clone();
1163 if let Some(goal) = self.thread_manager.record_thread_goal_progress(¶ms)? {
1164 Ok(ThreadResponse {
1165 thread_id,
1166 status: "ok".to_string(),
1167 thread: None,
1168 threads: Vec::new(),
1169 goal: Some(goal.clone()),
1170 model: None,
1171 model_provider: None,
1172 cwd: None,
1173 approval_policy: None,
1174 sandbox: None,
1175 events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1176 data: json!({ "goal": goal }),
1177 })
1178 } else {
1179 Ok(ThreadResponse {
1180 thread_id,
1181 status: "missing".to_string(),
1182 thread: None,
1183 threads: Vec::new(),
1184 goal: None,
1185 model: None,
1186 model_provider: None,
1187 cwd: None,
1188 approval_policy: None,
1189 sandbox: None,
1190 events: Vec::new(),
1191 data: json!({"error":"thread or goal not found"}),
1192 })
1193 }
1194 }
1195 ThreadRequest::Archive { thread_id } => {
1196 self.thread_manager.archive_thread(&thread_id)?;
1197 Ok(ThreadResponse {
1198 thread_id,
1199 status: "archived".to_string(),
1200 thread: None,
1201 threads: Vec::new(),
1202 goal: None,
1203 model: None,
1204 model_provider: None,
1205 cwd: None,
1206 approval_policy: None,
1207 sandbox: None,
1208 events: Vec::new(),
1209 data: json!({}),
1210 })
1211 }
1212 ThreadRequest::Unarchive { thread_id } => {
1213 self.thread_manager.unarchive_thread(&thread_id)?;
1214 Ok(ThreadResponse {
1215 thread_id,
1216 status: "unarchived".to_string(),
1217 thread: None,
1218 threads: Vec::new(),
1219 goal: None,
1220 model: None,
1221 model_provider: None,
1222 cwd: None,
1223 approval_policy: None,
1224 sandbox: None,
1225 events: Vec::new(),
1226 data: json!({}),
1227 })
1228 }
1229 ThreadRequest::Message { thread_id, input } => {
1230 self.thread_manager.touch_message(&thread_id, &input)?;
1231 let response_id = format!("{thread_id}:{}", input.len());
1232 self.hooks
1233 .emit(HookEvent::ResponseStart {
1234 response_id: response_id.clone(),
1235 })
1236 .await;
1237 self.hooks
1238 .emit(HookEvent::ResponseEnd {
1239 response_id: response_id.clone(),
1240 })
1241 .await;
1242
1243 Ok(ThreadResponse {
1244 thread_id,
1245 status: "accepted".to_string(),
1246 thread: None,
1247 threads: Vec::new(),
1248 goal: None,
1249 model: None,
1250 model_provider: None,
1251 cwd: None,
1252 approval_policy: None,
1253 sandbox: None,
1254 events: vec![
1255 EventFrame::ResponseStart {
1256 response_id: response_id.clone(),
1257 },
1258 EventFrame::ResponseDelta {
1259 response_id: response_id.clone(),
1260 delta: "queued".to_string(),
1261 channel: ResponseChannel::Text,
1262 },
1263 EventFrame::ResponseEnd { response_id },
1264 ],
1265 data: json!({}),
1266 })
1267 }
1268 }
1269 }
1270
1271 pub async fn handle_prompt(
1273 &mut self,
1274 req: PromptRequest,
1275 cli_overrides: &CliRuntimeOverrides,
1276 ) -> Result<PromptResponse> {
1277 let resolved = self.config.resolve_runtime_options(cli_overrides);
1278 let requested_model = req.model.clone().unwrap_or_else(|| resolved.model.clone());
1279 let selection = self
1280 .model_registry
1281 .resolve(Some(&requested_model), Some(resolved.provider));
1282 let resolved_model = selection.resolved.id.clone();
1283 let response_id = format!("resp-{}", Uuid::new_v4());
1284
1285 self.hooks
1286 .emit(HookEvent::ResponseStart {
1287 response_id: response_id.clone(),
1288 })
1289 .await;
1290 self.hooks
1291 .emit(HookEvent::ResponseDelta {
1292 response_id: response_id.clone(),
1293 delta: "model-selected".to_string(),
1294 })
1295 .await;
1296 self.hooks
1297 .emit(HookEvent::ResponseEnd {
1298 response_id: response_id.clone(),
1299 })
1300 .await;
1301
1302 let payload = json!({
1303 "provider": resolved.provider.as_str(),
1304 "model": resolved_model.clone(),
1305 "prompt": req.prompt,
1306 "telemetry": resolved.telemetry,
1307 "base_url": resolved.base_url,
1308 "has_api_key": resolved.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()),
1309 "approval_policy": resolved.approval_policy,
1310 "sandbox_mode": resolved.sandbox_mode
1311 });
1312 if let Some(thread_id) = req.thread_id.as_ref() {
1313 self.thread_manager.touch_message(thread_id, &req.prompt)?;
1314 let assistant_message_id = self.thread_manager.store.append_message(
1315 thread_id,
1316 "assistant",
1317 &payload.to_string(),
1318 Some(payload.clone()),
1319 )?;
1320 self.persist_latest_checkpoint(
1321 thread_id,
1322 "prompt_response",
1323 json!({
1324 "response_id": response_id.clone(),
1325 "model": resolved_model.clone(),
1326 "provider": resolved.provider.as_str(),
1327 "assistant_message_id": assistant_message_id
1328 }),
1329 )?;
1330 }
1331
1332 Ok(PromptResponse {
1333 output: payload.to_string(),
1334 model: resolved_model,
1335 events: vec![
1336 EventFrame::ResponseStart {
1337 response_id: response_id.clone(),
1338 },
1339 EventFrame::ResponseDelta {
1340 response_id: response_id.clone(),
1341 delta: "model-selected".to_string(),
1342 channel: ResponseChannel::Text,
1343 },
1344 EventFrame::ResponseEnd { response_id },
1345 ],
1346 })
1347 }
1348
1349 pub async fn invoke_tool(
1351 &self,
1352 call: ToolCall,
1353 approval_mode: AskForApproval,
1354 cwd: &Path,
1355 ) -> Result<Value> {
1356 let fallback_cwd = cwd.display().to_string();
1357 let (command, policy_cwd, execution_kind) = call.execution_subject(&fallback_cwd);
1358 let policy_tool = match &call.payload {
1359 ToolPayload::LocalShell { .. } => "exec_shell",
1360 _ => call.name.as_str(),
1361 };
1362 let policy_path = permission_path_for_call(&call);
1363 let decision = self.exec_policy.check(ExecPolicyContext {
1364 command: &command,
1365 cwd: &policy_cwd,
1366 tool: Some(policy_tool),
1367 path: policy_path.as_deref(),
1368 ask_for_approval: approval_mode,
1369 sandbox_mode: None,
1370 })?;
1371 let precheck = policy_precheck_payload(&decision, &command, &policy_cwd, execution_kind);
1372 let response_id = format!("tool-{}", Uuid::new_v4());
1373 let call_id = call
1374 .raw_tool_call_id
1375 .clone()
1376 .unwrap_or_else(|| format!("tool-call-{}", Uuid::new_v4()));
1377 self.hooks
1378 .emit(HookEvent::ToolLifecycle {
1379 response_id: response_id.clone(),
1380 tool_name: call.name.clone(),
1381 phase: "precheck".to_string(),
1382 payload: precheck.clone(),
1383 })
1384 .await;
1385
1386 if !decision.allow {
1387 let reason = decision.reason().to_string();
1388 let approval_id = format!("approval-{}", Uuid::new_v4());
1389 let error_frame = EventFrame::Error {
1390 response_id: response_id.clone(),
1391 message: reason.clone(),
1392 };
1393 self.hooks
1394 .emit(HookEvent::ApprovalLifecycle {
1395 approval_id,
1396 phase: "denied".to_string(),
1397 reason: Some(reason.clone()),
1398 })
1399 .await;
1400 self.hooks
1401 .emit(HookEvent::GenericEventFrame {
1402 frame: Box::new(error_frame.clone()),
1403 })
1404 .await;
1405 return Ok(json!({
1406 "ok": false,
1407 "status": "denied",
1408 "execution_kind": execution_kind,
1409 "response_id": response_id,
1410 "precheck": precheck,
1411 "error": reason,
1412 "events": [event_frame_payload(&error_frame)],
1413 }));
1414 }
1415
1416 if decision.requires_approval {
1417 let approval_id = format!("approval-{}", Uuid::new_v4());
1418 let reason = decision.reason().to_string();
1419 let maybe_approval_frame = approval_request_frame(
1420 &decision.requirement,
1421 decision.matched_rule.as_deref(),
1422 call_id,
1423 approval_id.clone(),
1424 response_id.clone(),
1425 command.clone(),
1426 policy_cwd.clone(),
1427 );
1428 self.hooks
1429 .emit(HookEvent::ApprovalLifecycle {
1430 approval_id: approval_id.clone(),
1431 phase: "requested".to_string(),
1432 reason: Some(reason.clone()),
1433 })
1434 .await;
1435 let mut events = Vec::new();
1436 if let Some(frame) = maybe_approval_frame {
1437 self.hooks
1438 .emit(HookEvent::GenericEventFrame {
1439 frame: Box::new(frame.clone()),
1440 })
1441 .await;
1442 events.push(event_frame_payload(&frame));
1443 }
1444 return Ok(json!({
1445 "ok": false,
1446 "status": "approval_required",
1447 "execution_kind": execution_kind,
1448 "response_id": response_id,
1449 "approval_id": approval_id,
1450 "precheck": precheck,
1451 "error": reason,
1452 "events": events,
1453 }));
1454 }
1455
1456 if call.name == REQUEST_USER_INPUT_TOOL_NAME {
1465 let request_id = format!("user-input-{}", Uuid::new_v4());
1466 let arguments = match &call.payload {
1467 ToolPayload::Function { arguments } => arguments.as_str(),
1468 _ => "",
1471 };
1472 let maybe_frame = user_input_request_frame(
1473 call_id.clone(),
1474 response_id.clone(),
1475 request_id.clone(),
1476 arguments,
1477 );
1478 let mut events = Vec::new();
1479 if let Some(frame) = maybe_frame {
1480 self.hooks
1481 .emit(HookEvent::GenericEventFrame {
1482 frame: Box::new(frame.clone()),
1483 })
1484 .await;
1485 events.push(event_frame_payload(&frame));
1486 }
1487 return Ok(json!({
1488 "ok": false,
1489 "status": "user_input_required",
1490 "execution_kind": execution_kind,
1491 "response_id": response_id,
1492 "request_id": request_id,
1493 "precheck": precheck,
1494 "events": events,
1495 }));
1496 }
1497
1498 let start_frame = EventFrame::ToolCallStart {
1499 response_id: response_id.clone(),
1500 tool_name: call.name.clone(),
1501 arguments: tool_payload_value(&call.payload),
1502 };
1503 self.hooks
1504 .emit(HookEvent::GenericEventFrame {
1505 frame: Box::new(start_frame.clone()),
1506 })
1507 .await;
1508 self.hooks
1509 .emit(HookEvent::ToolLifecycle {
1510 response_id: response_id.clone(),
1511 tool_name: call.name.clone(),
1512 phase: "dispatching".to_string(),
1513 payload: json!({
1514 "call_id": call_id,
1515 "execution_kind": execution_kind
1516 }),
1517 })
1518 .await;
1519
1520 match time::timeout(
1521 tool_dispatch_timeout(),
1522 self.tool_registry.dispatch(call.clone(), true),
1523 )
1524 .await
1525 {
1526 Ok(Ok(tool_output)) => {
1527 let result_frame = EventFrame::ToolCallResult {
1528 response_id: response_id.clone(),
1529 tool_name: call.name.clone(),
1530 output: tool_output_value(&tool_output),
1531 };
1532 self.hooks
1533 .emit(HookEvent::GenericEventFrame {
1534 frame: Box::new(result_frame.clone()),
1535 })
1536 .await;
1537 self.hooks
1538 .emit(HookEvent::ToolLifecycle {
1539 response_id: response_id.clone(),
1540 tool_name: call.name,
1541 phase: "completed".to_string(),
1542 payload: json!({ "ok": true }),
1543 })
1544 .await;
1545 Ok(json!({
1546 "ok": true,
1547 "status": "completed",
1548 "execution_kind": execution_kind,
1549 "response_id": response_id,
1550 "precheck": precheck,
1551 "output": tool_output,
1552 "events": [
1553 event_frame_payload(&start_frame),
1554 event_frame_payload(&result_frame)
1555 ]
1556 }))
1557 }
1558 Ok(Err(err)) => {
1559 let message = format!("{err:?}");
1560 let error_frame = EventFrame::Error {
1561 response_id: response_id.clone(),
1562 message: message.clone(),
1563 };
1564 self.hooks
1565 .emit(HookEvent::GenericEventFrame {
1566 frame: Box::new(error_frame.clone()),
1567 })
1568 .await;
1569 self.hooks
1570 .emit(HookEvent::ToolLifecycle {
1571 response_id: response_id.clone(),
1572 tool_name: call.name,
1573 phase: "failed".to_string(),
1574 payload: json!({ "error": message.clone() }),
1575 })
1576 .await;
1577 Ok(json!({
1578 "ok": false,
1579 "status": "failed",
1580 "execution_kind": execution_kind,
1581 "response_id": response_id,
1582 "precheck": precheck,
1583 "error": message,
1584 "events": [
1585 event_frame_payload(&start_frame),
1586 event_frame_payload(&error_frame)
1587 ]
1588 }))
1589 }
1590 Err(_elapsed) => {
1591 let seconds = tool_dispatch_timeout().as_secs().max(1);
1592 let message = format!("Tool '{}' timed out after {seconds}s", call.name);
1593 let error_frame = EventFrame::Error {
1594 response_id: response_id.clone(),
1595 message: message.clone(),
1596 };
1597 self.hooks
1598 .emit(HookEvent::GenericEventFrame {
1599 frame: Box::new(error_frame.clone()),
1600 })
1601 .await;
1602 self.hooks
1603 .emit(HookEvent::ToolLifecycle {
1604 response_id: response_id.clone(),
1605 tool_name: call.name,
1606 phase: "failed".to_string(),
1607 payload: json!({ "error": message.clone(), "timeout": true }),
1608 })
1609 .await;
1610 Ok(json!({
1611 "ok": false,
1612 "status": "timeout",
1613 "execution_kind": execution_kind,
1614 "response_id": response_id,
1615 "precheck": precheck,
1616 "error": message,
1617 "events": [
1618 event_frame_payload(&start_frame),
1619 event_frame_payload(&error_frame)
1620 ]
1621 }))
1622 }
1623 }
1624 }
1625
1626 pub async fn mcp_startup(&self) -> McpStartupCompleteEvent {
1628 let mut updates = Vec::new();
1629 let summary = self.mcp_manager.start_all(|update| {
1630 updates.push(update);
1631 });
1632 for update in updates {
1633 let status = match update.status {
1634 McpManagerStartupStatus::Starting => codewhale_protocol::McpStartupStatus::Starting,
1635 McpManagerStartupStatus::Ready => codewhale_protocol::McpStartupStatus::Ready,
1636 McpManagerStartupStatus::Failed { error } => {
1637 codewhale_protocol::McpStartupStatus::Failed { error }
1638 }
1639 McpManagerStartupStatus::Cancelled => {
1640 codewhale_protocol::McpStartupStatus::Cancelled
1641 }
1642 };
1643 self.hooks
1644 .emit(HookEvent::GenericEventFrame {
1645 frame: Box::new(EventFrame::McpStartupUpdate {
1646 update: codewhale_protocol::McpStartupUpdateEvent {
1647 server_name: update.server_name,
1648 status,
1649 },
1650 }),
1651 })
1652 .await;
1653 }
1654 self.hooks
1655 .emit(HookEvent::GenericEventFrame {
1656 frame: Box::new(EventFrame::McpStartupComplete {
1657 summary: codewhale_protocol::McpStartupCompleteEvent {
1658 ready: summary.ready.clone(),
1659 failed: summary
1660 .failed
1661 .iter()
1662 .map(|f| codewhale_protocol::McpStartupFailure {
1663 server_name: f.server_name.clone(),
1664 error: f.error.clone(),
1665 })
1666 .collect(),
1667 cancelled: summary.cancelled.clone(),
1668 },
1669 }),
1670 })
1671 .await;
1672 summary
1673 }
1674
1675 pub fn app_status(&self) -> AppResponse {
1677 let jobs = self.jobs.list();
1678 let events = jobs
1679 .iter()
1680 .flat_map(|job| {
1681 job.history.iter().map(|entry| EventFrame::ResponseDelta {
1682 response_id: job.id.clone(),
1683 delta: json!({
1684 "kind": "job_transition",
1685 "job_id": job.id.clone(),
1686 "phase": entry.phase.clone(),
1687 "status": job_status_to_str(entry.status),
1688 "progress": entry.progress,
1689 "detail": entry.detail.clone(),
1690 "retry": job_retry_to_value(&entry.retry),
1691 "at": entry.at
1692 })
1693 .to_string(),
1694 channel: ResponseChannel::Text,
1695 })
1696 })
1697 .collect::<Vec<_>>();
1698 AppResponse {
1699 ok: true,
1700 data: json!({
1701 "jobs": jobs.into_iter().map(|job| {
1702 json!({
1703 "id": job.id,
1704 "name": job.name,
1705 "status": job_status_to_str(job.status),
1706 "progress": job.progress,
1707 "detail": job.detail,
1708 "retry": job_retry_to_value(&job.retry),
1709 "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
1710 })
1711 }).collect::<Vec<_>>()
1712 }),
1713 events,
1714 }
1715 }
1716
1717 pub fn provider_default(&self) -> ProviderKind {
1719 self.config.provider
1720 }
1721
1722 pub fn save_thread_checkpoint(
1724 &self,
1725 thread_id: &str,
1726 checkpoint_id: &str,
1727 state: &Value,
1728 ) -> Result<()> {
1729 self.thread_manager
1730 .state_store()
1731 .save_checkpoint(thread_id, checkpoint_id, state)
1732 }
1733
1734 pub fn load_thread_checkpoint(
1736 &self,
1737 thread_id: &str,
1738 checkpoint_id: Option<&str>,
1739 ) -> Result<Option<Value>> {
1740 Ok(self
1741 .thread_manager
1742 .state_store()
1743 .load_checkpoint(thread_id, checkpoint_id)?
1744 .map(|checkpoint| checkpoint.state))
1745 }
1746
1747 pub fn enqueue_job(&mut self, name: impl Into<String>) -> Result<JobRecord> {
1749 let job = self.jobs.enqueue(name);
1750 self.jobs
1751 .persist_job(self.thread_manager.state_store(), &job.id)?;
1752 Ok(job)
1753 }
1754
1755 pub fn set_job_running(&mut self, job_id: &str) -> Result<()> {
1757 self.jobs.set_running(job_id);
1758 self.jobs
1759 .persist_job(self.thread_manager.state_store(), job_id)
1760 }
1761
1762 pub fn update_job_progress(
1764 &mut self,
1765 job_id: &str,
1766 progress: u8,
1767 detail: Option<String>,
1768 ) -> Result<()> {
1769 self.jobs.update_progress(job_id, progress, detail);
1770 self.jobs
1771 .persist_job(self.thread_manager.state_store(), job_id)
1772 }
1773
1774 pub fn complete_job(&mut self, job_id: &str) -> Result<()> {
1776 self.jobs.complete(job_id);
1777 self.jobs
1778 .persist_job(self.thread_manager.state_store(), job_id)
1779 }
1780
1781 pub fn fail_job(&mut self, job_id: &str, detail: impl Into<String>) -> Result<()> {
1783 self.jobs.fail(job_id, detail);
1784 self.jobs
1785 .persist_job(self.thread_manager.state_store(), job_id)
1786 }
1787
1788 pub fn cancel_job(&mut self, job_id: &str) -> Result<()> {
1790 self.jobs.cancel(job_id);
1791 self.jobs
1792 .persist_job(self.thread_manager.state_store(), job_id)
1793 }
1794
1795 pub fn pause_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1797 self.jobs.pause(job_id, detail);
1798 self.jobs
1799 .persist_job(self.thread_manager.state_store(), job_id)
1800 }
1801
1802 pub fn resume_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1804 self.jobs.resume(job_id, detail);
1805 self.jobs
1806 .persist_job(self.thread_manager.state_store(), job_id)
1807 }
1808
1809 pub fn job_history(&self, job_id: &str) -> Vec<JobHistoryEntry> {
1811 self.jobs.history(job_id)
1812 }
1813}
1814
1815fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse {
1816 ThreadResponse {
1817 thread_id: new.thread.id.clone(),
1818 status: status.to_string(),
1819 thread: Some(new.thread),
1820 threads: Vec::new(),
1821 goal: None,
1822 model: Some(new.model),
1823 model_provider: Some(new.model_provider),
1824 cwd: Some(new.cwd),
1825 approval_policy: new.approval_policy,
1826 sandbox: new.sandbox,
1827 events: Vec::new(),
1828 data: json!({}),
1829 }
1830}
1831
1832fn preview_from_initial_history(initial_history: &InitialHistory) -> String {
1833 match initial_history {
1834 InitialHistory::New => "New conversation".to_string(),
1835 InitialHistory::Forked(items) => truncate_preview(
1836 &items
1837 .first()
1838 .map(Value::to_string)
1839 .unwrap_or_else(|| "Forked conversation".to_string()),
1840 ),
1841 InitialHistory::Resumed { history, .. } => truncate_preview(
1842 &history
1843 .first()
1844 .map(Value::to_string)
1845 .unwrap_or_else(|| "Resumed conversation".to_string()),
1846 ),
1847 }
1848}
1849
1850fn permission_path_for_call(call: &ToolCall) -> Option<String> {
1851 match &call.payload {
1852 ToolPayload::Function { arguments } => serde_json::from_str::<Value>(arguments)
1853 .ok()
1854 .and_then(|value| {
1855 value
1856 .get("path")
1857 .and_then(Value::as_str)
1858 .map(str::to_string)
1859 }),
1860 ToolPayload::Mcp { raw_arguments, .. } => raw_arguments
1861 .get("path")
1862 .and_then(Value::as_str)
1863 .map(str::to_string),
1864 ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None,
1865 }
1866}
1867
1868fn truncate_preview(value: &str) -> String {
1869 value.chars().take(120).collect()
1870}
1871
1872fn to_protocol_thread(thread: ThreadMetadata) -> Thread {
1873 Thread {
1874 id: thread.id,
1875 preview: thread.preview,
1876 ephemeral: thread.ephemeral,
1877 model_provider: thread.model_provider,
1878 created_at: thread.created_at,
1879 updated_at: thread.updated_at,
1880 status: match thread.status {
1881 PersistedThreadStatus::Running => ThreadStatus::Running,
1882 PersistedThreadStatus::Idle => ThreadStatus::Idle,
1883 PersistedThreadStatus::Completed => ThreadStatus::Completed,
1884 PersistedThreadStatus::Failed => ThreadStatus::Failed,
1885 PersistedThreadStatus::Paused => ThreadStatus::Paused,
1886 PersistedThreadStatus::Archived => ThreadStatus::Archived,
1887 },
1888 path: thread.path,
1889 cwd: thread.cwd,
1890 cli_version: thread.cli_version,
1891 source: match thread.source {
1892 SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
1893 SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
1894 SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
1895 SessionSource::Api => codewhale_protocol::SessionSource::Api,
1896 SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
1897 },
1898 name: thread.name,
1899 }
1900}
1901
1902fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal {
1903 ThreadGoal {
1904 thread_id: goal.thread_id,
1905 goal_id: goal.goal_id,
1906 objective: goal.objective,
1907 status: to_protocol_goal_status(goal.status),
1908 token_budget: goal.token_budget,
1909 tokens_used: goal.tokens_used,
1910 time_used_seconds: goal.time_used_seconds,
1911 continuation_count: goal.continuation_count,
1912 created_at: goal.created_at,
1913 updated_at: goal.updated_at,
1914 }
1915}
1916
1917fn to_protocol_goal_status(status: PersistedThreadGoalStatus) -> ThreadGoalStatus {
1918 match status {
1919 PersistedThreadGoalStatus::Active => ThreadGoalStatus::Active,
1920 PersistedThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
1921 PersistedThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
1922 PersistedThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
1923 PersistedThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
1924 PersistedThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
1925 }
1926}
1927
1928fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus {
1929 match status {
1930 ThreadStatus::Running => PersistedThreadStatus::Running,
1931 ThreadStatus::Idle => PersistedThreadStatus::Idle,
1932 ThreadStatus::Completed => PersistedThreadStatus::Completed,
1933 ThreadStatus::Failed => PersistedThreadStatus::Failed,
1934 ThreadStatus::Paused => PersistedThreadStatus::Paused,
1935 ThreadStatus::Archived => PersistedThreadStatus::Archived,
1936 }
1937}
1938
1939fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSource {
1940 match source {
1941 codewhale_protocol::SessionSource::Interactive => SessionSource::Interactive,
1942 codewhale_protocol::SessionSource::Resume => SessionSource::Resume,
1943 codewhale_protocol::SessionSource::Fork => SessionSource::Fork,
1944 codewhale_protocol::SessionSource::Api => SessionSource::Api,
1945 codewhale_protocol::SessionSource::Unknown => SessionSource::Unknown,
1946 }
1947}
1948
1949fn approval_request_frame(
1950 requirement: &ExecApprovalRequirement,
1951 matched_rule: Option<&str>,
1952 call_id: String,
1953 approval_id: String,
1954 turn_id: String,
1955 command: String,
1956 cwd: String,
1957) -> Option<EventFrame> {
1958 let ExecApprovalRequirement::NeedsApproval {
1959 reason,
1960 proposed_execpolicy_amendment,
1961 proposed_network_policy_amendments,
1962 } = requirement
1963 else {
1964 return None;
1965 };
1966
1967 let mut available_decisions = vec![
1968 ReviewDecision::Approved,
1969 ReviewDecision::ApprovedForSession,
1970 ReviewDecision::Denied,
1971 ReviewDecision::Abort,
1972 ];
1973 if proposed_execpolicy_amendment
1974 .as_ref()
1975 .is_some_and(|amendment| !amendment.prefixes.is_empty())
1976 {
1977 available_decisions.push(ReviewDecision::ApprovedExecpolicyAmendment);
1978 }
1979 available_decisions.extend(proposed_network_policy_amendments.iter().cloned().map(
1980 |amendment| ReviewDecision::NetworkPolicyAmendment {
1981 host: amendment.host,
1982 action: amendment.action,
1983 },
1984 ));
1985
1986 Some(EventFrame::ExecApprovalRequest {
1987 request: ExecApprovalRequestEvent {
1988 call_id,
1989 approval_id,
1990 turn_id,
1991 command,
1992 cwd,
1993 reason: reason.clone(),
1994 matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()),
1995 network_approval_context: None,
1996 proposed_execpolicy_amendment: proposed_execpolicy_amendment
1997 .as_ref()
1998 .map(|amendment| amendment.prefixes.clone())
1999 .unwrap_or_default(),
2000 proposed_network_policy_amendments: proposed_network_policy_amendments.clone(),
2001 additional_permissions: Vec::new(),
2002 available_decisions,
2003 },
2004 })
2005}
2006
2007fn user_input_request_frame(
2015 call_id: String,
2016 turn_id: String,
2017 request_id: String,
2018 arguments: &str,
2019) -> Option<EventFrame> {
2020 let parsed: Value = serde_json::from_str(arguments).ok()?;
2021 let questions = parsed.get("questions").cloned().filter(Value::is_array)?;
2025 let request = UserInputRequestEvent {
2026 call_id,
2027 turn_id,
2028 request_id,
2029 questions: serde_json::from_value(questions).ok()?,
2030 };
2031 Some(EventFrame::UserInputRequest { request })
2032}
2033
2034fn approval_requirement_payload(requirement: &ExecApprovalRequirement) -> Value {
2035 match requirement {
2036 ExecApprovalRequirement::Skip {
2037 bypass_sandbox,
2038 proposed_execpolicy_amendment,
2039 } => json!({
2040 "type": "skip",
2041 "bypass_sandbox": bypass_sandbox,
2042 "reason": requirement.reason(),
2043 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2044 .as_ref()
2045 .map(|amendment| amendment.prefixes.clone())
2046 .unwrap_or_default()
2047 }),
2048 ExecApprovalRequirement::NeedsApproval {
2049 reason,
2050 proposed_execpolicy_amendment,
2051 proposed_network_policy_amendments,
2052 } => json!({
2053 "type": "needs_approval",
2054 "reason": reason,
2055 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2056 .as_ref()
2057 .map(|amendment| amendment.prefixes.clone())
2058 .unwrap_or_default(),
2059 "proposed_network_policy_amendments": proposed_network_policy_amendments
2060 }),
2061 ExecApprovalRequirement::Forbidden { reason } => json!({
2062 "type": "forbidden",
2063 "reason": reason
2064 }),
2065 }
2066}
2067
2068fn policy_precheck_payload(
2069 decision: &ExecPolicyDecision,
2070 command: &str,
2071 cwd: &str,
2072 execution_kind: &str,
2073) -> Value {
2074 json!({
2075 "execution_kind": execution_kind,
2076 "command": command,
2077 "cwd": cwd,
2078 "allow": decision.allow,
2079 "requires_approval": decision.requires_approval,
2080 "matched_rule": decision.matched_rule.clone(),
2081 "phase": decision.requirement.phase(),
2082 "reason": decision.reason(),
2083 "requirement": approval_requirement_payload(&decision.requirement)
2084 })
2085}
2086
2087fn tool_payload_value(payload: &ToolPayload) -> Value {
2088 serde_json::to_value(payload).unwrap_or_else(
2089 |_| json!({"type":"serialization_error","message":"tool payload unavailable"}),
2090 )
2091}
2092
2093fn tool_output_value(output: &codewhale_protocol::ToolOutput) -> Value {
2094 serde_json::to_value(output).unwrap_or_else(
2095 |_| json!({"type":"serialization_error","message":"tool output unavailable"}),
2096 )
2097}
2098
2099fn event_frame_payload(frame: &EventFrame) -> Value {
2100 serde_json::to_value(frame)
2101 .unwrap_or_else(|_| json!({"event":"error","message":"failed to encode event frame"}))
2102}
2103
2104const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
2110
2111fn json_optional_string(value: &Value) -> Option<String> {
2112 if value.is_null() {
2113 None
2114 } else {
2115 value.as_str().map(ToString::to_string)
2116 }
2117}
2118
2119fn parse_retry_metadata(value: Option<&Value>) -> JobRetryMetadata {
2120 let Some(value) = value else {
2121 return JobRetryMetadata::default();
2122 };
2123 JobRetryMetadata {
2124 attempt: value
2125 .get("attempt")
2126 .and_then(Value::as_u64)
2127 .unwrap_or(0)
2128 .min(u32::MAX as u64) as u32,
2129 max_attempts: value
2130 .get("max_attempts")
2131 .and_then(Value::as_u64)
2132 .unwrap_or(DEFAULT_JOB_MAX_ATTEMPTS as u64)
2133 .min(u32::MAX as u64) as u32,
2134 backoff_base_ms: value
2135 .get("backoff_base_ms")
2136 .and_then(Value::as_u64)
2137 .unwrap_or(DEFAULT_JOB_BACKOFF_BASE_MS),
2138 next_backoff_ms: value
2139 .get("next_backoff_ms")
2140 .and_then(Value::as_u64)
2141 .unwrap_or(0),
2142 next_retry_at: value.get("next_retry_at").and_then(Value::as_i64),
2143 }
2144}
2145
2146fn parse_history_entry(value: &Value) -> Option<JobHistoryEntry> {
2147 let status = value
2148 .get("status")
2149 .and_then(Value::as_str)
2150 .and_then(job_status_from_str)?;
2151 Some(JobHistoryEntry {
2152 at: value.get("at").and_then(Value::as_i64).unwrap_or(0),
2153 phase: value
2154 .get("phase")
2155 .and_then(Value::as_str)
2156 .unwrap_or("unknown")
2157 .to_string(),
2158 status,
2159 progress: value
2160 .get("progress")
2161 .and_then(Value::as_u64)
2162 .map(|v| v.min(u8::MAX as u64) as u8),
2163 detail: value.get("detail").and_then(json_optional_string),
2164 retry: parse_retry_metadata(value.get("retry")),
2165 })
2166}
2167
2168fn job_status_to_str(status: JobStatus) -> &'static str {
2169 match status {
2170 JobStatus::Queued => "queued",
2171 JobStatus::Running => "running",
2172 JobStatus::Paused => "paused",
2173 JobStatus::Completed => "completed",
2174 JobStatus::Failed => "failed",
2175 JobStatus::Cancelled => "cancelled",
2176 }
2177}
2178
2179fn job_status_from_str(value: &str) -> Option<JobStatus> {
2180 match value {
2181 "queued" => Some(JobStatus::Queued),
2182 "running" => Some(JobStatus::Running),
2183 "paused" => Some(JobStatus::Paused),
2184 "completed" => Some(JobStatus::Completed),
2185 "failed" => Some(JobStatus::Failed),
2186 "cancelled" => Some(JobStatus::Cancelled),
2187 _ => None,
2188 }
2189}
2190
2191fn job_retry_to_value(retry: &JobRetryMetadata) -> Value {
2192 json!({
2193 "attempt": retry.attempt,
2194 "max_attempts": retry.max_attempts,
2195 "backoff_base_ms": retry.backoff_base_ms,
2196 "next_backoff_ms": retry.next_backoff_ms,
2197 "next_retry_at": retry.next_retry_at
2198 })
2199}
2200
2201fn job_history_to_value(entry: &JobHistoryEntry) -> Value {
2202 json!({
2203 "at": entry.at,
2204 "phase": entry.phase.clone(),
2205 "status": job_status_to_str(entry.status),
2206 "progress": entry.progress,
2207 "detail": entry.detail.clone(),
2208 "retry": job_retry_to_value(&entry.retry)
2209 })
2210}
2211
2212fn runtime_status_to_job_state(status: JobStatus) -> JobStateStatus {
2213 match status {
2214 JobStatus::Queued => JobStateStatus::Queued,
2215 JobStatus::Running => JobStateStatus::Running,
2216 JobStatus::Paused => JobStateStatus::Paused,
2217 JobStatus::Completed => JobStateStatus::Completed,
2218 JobStatus::Failed => JobStateStatus::Failed,
2219 JobStatus::Cancelled => JobStateStatus::Cancelled,
2220 }
2221}
2222
2223fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus {
2224 match status {
2225 JobStateStatus::Queued => JobStatus::Queued,
2226 JobStateStatus::Running => JobStatus::Running,
2227 JobStateStatus::Paused => JobStatus::Paused,
2228 JobStateStatus::Completed => JobStatus::Completed,
2229 JobStateStatus::Failed => JobStatus::Failed,
2230 JobStateStatus::Cancelled => JobStatus::Cancelled,
2231 }
2232}
2233
2234#[cfg(test)]
2235mod tests {
2236 use super::*;
2237 use codewhale_protocol::ThreadResumeParams;
2238 use codewhale_tools::ToolCallSource;
2239
2240 fn temp_core_state(name: &str) -> StateStore {
2241 let dir =
2242 std::env::temp_dir().join(format!("codewhale-core-{name}-{}", Uuid::new_v4().simple()));
2243 std::fs::create_dir_all(&dir).expect("create temp state dir");
2244 StateStore::open(Some(dir.join("state.db"))).expect("open state store")
2245 }
2246
2247 fn test_thread_metadata(id: &str) -> ThreadMetadata {
2248 ThreadMetadata {
2249 id: id.to_string(),
2250 rollout_path: None,
2251 preview: "test thread".to_string(),
2252 ephemeral: false,
2253 model_provider: "deepseek".to_string(),
2254 created_at: 10,
2255 updated_at: 10,
2256 status: PersistedThreadStatus::Running,
2257 path: None,
2258 cwd: PathBuf::from("/tmp/codewhale"),
2259 cli_version: "0.0.0-test".to_string(),
2260 source: SessionSource::Interactive,
2261 name: None,
2262 sandbox_policy: None,
2263 approval_mode: None,
2264 archived: false,
2265 archived_at: None,
2266 git_sha: None,
2267 git_branch: None,
2268 git_origin_url: None,
2269 memory_mode: None,
2270 current_leaf_id: None,
2271 }
2272 }
2273
2274 #[test]
2277 fn permission_path_for_call_extracts_function_path_argument() {
2278 let call = ToolCall {
2279 name: "read_file".to_string(),
2280 payload: ToolPayload::Function {
2281 arguments: json!({ "path": "README.md" }).to_string(),
2282 },
2283 source: ToolCallSource::Direct,
2284 raw_tool_call_id: None,
2285 };
2286
2287 assert_eq!(
2288 permission_path_for_call(&call).as_deref(),
2289 Some("README.md")
2290 );
2291 }
2292
2293 #[test]
2294 fn permission_path_for_call_extracts_mcp_path_argument() {
2295 let call = ToolCall {
2296 name: "mcp_fs_read".to_string(),
2297 payload: ToolPayload::Mcp {
2298 server: "fs".to_string(),
2299 tool: "read".to_string(),
2300 raw_arguments: json!({ "path": "secrets/token.txt" }),
2301 raw_tool_call_id: None,
2302 },
2303 source: ToolCallSource::Direct,
2304 raw_tool_call_id: None,
2305 };
2306
2307 assert_eq!(
2308 permission_path_for_call(&call).as_deref(),
2309 Some("secrets/token.txt")
2310 );
2311 }
2312
2313 #[test]
2314 fn permission_path_for_call_ignores_shell_payload() {
2315 let call = ToolCall {
2316 name: "exec_shell".to_string(),
2317 payload: ToolPayload::LocalShell {
2318 params: codewhale_protocol::LocalShellParams {
2319 command: "cargo test".to_string(),
2320 cwd: None,
2321 timeout_ms: None,
2322 },
2323 },
2324 source: ToolCallSource::Direct,
2325 raw_tool_call_id: None,
2326 };
2327
2328 assert_eq!(permission_path_for_call(&call), None);
2329 }
2330
2331 #[test]
2332 fn thread_goal_progress_accumulates_durable_accounting() {
2333 let store = temp_core_state("thread-goal-progress");
2334 store
2335 .upsert_thread(&test_thread_metadata("thread-1"))
2336 .expect("upsert thread");
2337 let mut manager = ThreadManager::new(store);
2338 manager
2339 .set_thread_goal(&ThreadGoalSetParams {
2340 thread_id: "thread-1".to_string(),
2341 objective: "Carry the goal across turns".to_string(),
2342 token_budget: Some(2_000),
2343 })
2344 .expect("set goal")
2345 .expect("goal exists");
2346
2347 let updated = manager
2348 .record_thread_goal_progress(&ThreadGoalProgressParams {
2349 thread_id: "thread-1".to_string(),
2350 token_delta: 750,
2351 time_delta_seconds: 12,
2352 record_continuation: true,
2353 })
2354 .expect("record progress")
2355 .expect("goal exists");
2356
2357 assert_eq!(updated.tokens_used, 750);
2358 assert_eq!(updated.time_used_seconds, 12);
2359 assert_eq!(updated.continuation_count, 1);
2360
2361 let persisted = manager
2362 .get_thread_goal(&ThreadGoalGetParams {
2363 thread_id: "thread-1".to_string(),
2364 })
2365 .expect("read goal")
2366 .expect("goal exists");
2367 assert_eq!(persisted.tokens_used, 750);
2368 assert_eq!(persisted.time_used_seconds, 12);
2369 assert_eq!(persisted.continuation_count, 1);
2370 }
2371
2372 #[test]
2373 fn approval_request_frame_includes_matched_rule() {
2374 let requirement = ExecApprovalRequirement::NeedsApproval {
2375 reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
2376 .to_string(),
2377 proposed_execpolicy_amendment: None,
2378 proposed_network_policy_amendments: Vec::new(),
2379 };
2380
2381 let frame = approval_request_frame(
2382 &requirement,
2383 Some("tool=exec_shell command=cargo test"),
2384 "call-1".to_string(),
2385 "approval-1".to_string(),
2386 "turn-1".to_string(),
2387 "cargo test --workspace".to_string(),
2388 "/repo".to_string(),
2389 )
2390 .expect("approval frame");
2391
2392 let EventFrame::ExecApprovalRequest { request } = frame else {
2393 panic!("expected exec approval request frame");
2394 };
2395 assert_eq!(
2396 request.matched_rule.as_deref(),
2397 Some("tool=exec_shell command=cargo test")
2398 );
2399 assert_eq!(request.reason, requirement.reason());
2400 }
2401
2402 #[test]
2403 fn user_input_request_frame_lifts_questions_from_arguments() {
2404 let arguments = r#"{"questions":[{"header":"Scope","id":"scope","question":"Which?","options":[{"label":"A","description":"a"},{"label":"B","description":"b"}],"allow_free_text":true}]}"#;
2408 let frame = user_input_request_frame(
2409 "call-1".to_string(),
2410 "turn-1".to_string(),
2411 "ui-1".to_string(),
2412 arguments,
2413 )
2414 .expect("user input frame");
2415
2416 let EventFrame::UserInputRequest { request } = frame else {
2417 panic!("expected user_input_request frame");
2418 };
2419 assert_eq!(request.call_id, "call-1");
2420 assert_eq!(request.turn_id, "turn-1");
2421 assert_eq!(request.request_id, "ui-1");
2422 assert_eq!(request.questions.len(), 1);
2423 assert_eq!(request.questions[0].id, "scope");
2424 assert!(request.questions[0].allow_free_text);
2425 assert!(!request.questions[0].multi_select);
2427 assert_eq!(request.questions[0].options.len(), 2);
2428 }
2429
2430 #[test]
2431 fn user_input_request_frame_returns_none_on_invalid_arguments() {
2432 let frame = user_input_request_frame(
2435 "call-1".to_string(),
2436 "turn-1".to_string(),
2437 "ui-1".to_string(),
2438 "not json",
2439 );
2440 assert!(frame.is_none());
2441
2442 let frame = user_input_request_frame(
2444 "call-1".to_string(),
2445 "turn-1".to_string(),
2446 "ui-1".to_string(),
2447 r#"{"foo":"bar"}"#,
2448 );
2449 assert!(frame.is_none());
2450 }
2451
2452 #[test]
2453 fn enqueue_creates_queued_job_with_zero_progress() {
2454 let mut jm = JobManager::default();
2455 let job = jm.enqueue("build");
2456 assert_eq!(job.name, "build");
2457 assert_eq!(job.status, JobStatus::Queued);
2458 assert_eq!(job.progress, Some(0));
2459 assert!(job.detail.is_none());
2460 assert_eq!(job.history.len(), 1);
2461 assert_eq!(job.history[0].phase, "created");
2462 }
2463
2464 #[test]
2465 fn set_running_transitions_from_queued() {
2466 let mut jm = JobManager::default();
2467 let job = jm.enqueue("deploy");
2468 let id = job.id.clone();
2469 jm.set_running(&id);
2470 let jobs = jm.list();
2471 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2472 assert_eq!(updated.status, JobStatus::Running);
2473 assert_eq!(updated.history.last().unwrap().phase, "running");
2474 }
2475
2476 #[test]
2477 fn update_progress_clamps_to_100() {
2478 let mut jm = JobManager::default();
2479 let job = jm.enqueue("task");
2480 let id = job.id.clone();
2481 jm.update_progress(&id, 150, Some("over".to_string()));
2482 let jobs = jm.list();
2483 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2484 assert_eq!(updated.progress, Some(100));
2485 }
2486
2487 #[test]
2488 fn complete_sets_progress_to_100() {
2489 let mut jm = JobManager::default();
2490 let job = jm.enqueue("task");
2491 let id = job.id.clone();
2492 jm.set_running(&id);
2493 jm.complete(&id);
2494 let jobs = jm.list();
2495 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2496 assert_eq!(updated.status, JobStatus::Completed);
2497 assert_eq!(updated.progress, Some(100));
2498 }
2499
2500 #[test]
2501 fn fail_increments_attempt_and_sets_backoff() {
2502 let mut jm = JobManager::default();
2503 let job = jm.enqueue("fragile");
2504 let id = job.id.clone();
2505 jm.set_running(&id);
2506 jm.fail(&id, "crashed");
2507 let jobs = jm.list();
2508 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2509 assert_eq!(updated.status, JobStatus::Failed);
2510 assert_eq!(updated.retry.attempt, 1);
2511 assert!(updated.retry.next_backoff_ms > 0);
2512 assert!(updated.retry.next_retry_at.is_some());
2513 assert_eq!(updated.detail.as_deref(), Some("crashed"));
2514 }
2515
2516 #[test]
2517 fn fail_clears_retry_after_max_attempts() {
2518 let mut jm = JobManager::default();
2519 let job = jm.enqueue("fragile");
2520 let id = job.id.clone();
2521 for _ in 0..=DEFAULT_JOB_MAX_ATTEMPTS {
2522 jm.set_running(&id);
2523 jm.fail(&id, "boom");
2524 }
2525 let jobs = jm.list();
2526 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2527 assert_eq!(updated.retry.attempt, DEFAULT_JOB_MAX_ATTEMPTS);
2528 assert_eq!(updated.retry.next_backoff_ms, 0);
2529 assert!(updated.retry.next_retry_at.is_none());
2530 }
2531
2532 #[test]
2533 fn cancel_sets_status_and_clears_retry() {
2534 let mut jm = JobManager::default();
2535 let job = jm.enqueue("task");
2536 let id = job.id.clone();
2537 jm.cancel(&id);
2538 let jobs = jm.list();
2539 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2540 assert_eq!(updated.status, JobStatus::Cancelled);
2541 assert_eq!(updated.retry.next_backoff_ms, 0);
2542 }
2543
2544 #[test]
2545 fn pause_and_resume_round_trip() {
2546 let mut jm = JobManager::default();
2547 let job = jm.enqueue("task");
2548 let id = job.id.clone();
2549 jm.set_running(&id);
2550 jm.pause(&id, Some("waiting".to_string()));
2551 let jobs = jm.list();
2552 let paused = jobs.iter().find(|j| j.id == id).unwrap();
2553 assert_eq!(paused.status, JobStatus::Paused);
2554 assert_eq!(paused.detail.as_deref(), Some("waiting"));
2555
2556 jm.resume(&id, None);
2557 let jobs = jm.list();
2558 let resumed = jobs.iter().find(|j| j.id == id).unwrap();
2559 assert_eq!(resumed.status, JobStatus::Running);
2560 assert_eq!(resumed.history.last().unwrap().phase, "resumed");
2561 }
2562
2563 #[test]
2564 fn list_returns_jobs_sorted_by_updated_at_desc() {
2565 let mut jm = JobManager::default();
2566 jm.enqueue("first");
2567 jm.enqueue("second");
2568 jm.enqueue("third");
2569 let jobs = jm.list();
2570 assert_eq!(jobs.len(), 3);
2571 for window in jobs.windows(2) {
2572 assert!(window[0].updated_at >= window[1].updated_at);
2573 }
2574 }
2575
2576 #[test]
2577 fn history_returns_entries_for_existing_job() {
2578 let mut jm = JobManager::default();
2579 let job = jm.enqueue("task");
2580 let id = job.id.clone();
2581 jm.set_running(&id);
2582 jm.complete(&id);
2583 let history = jm.history(&id);
2584 assert_eq!(history.len(), 3); assert_eq!(history[0].phase, "created");
2586 assert_eq!(history[1].phase, "running");
2587 assert_eq!(history[2].phase, "completed");
2588 }
2589
2590 #[test]
2591 fn history_returns_empty_for_unknown_job() {
2592 let jm = JobManager::default();
2593 assert!(jm.history("nonexistent").is_empty());
2594 }
2595
2596 #[test]
2597 fn resume_pending_requeues_running_and_queued() {
2598 let mut jm = JobManager::default();
2599 let _j1 = jm.enqueue("queued_task");
2600 let j2 = jm.enqueue("running_task");
2601 let j3 = jm.enqueue("completed_task");
2602 let id2 = j2.id.clone();
2603 let id3 = j3.id.clone();
2604 jm.set_running(&id2);
2605 jm.set_running(&id3);
2606 jm.complete(&id3);
2607
2608 let resumed = jm.resume_pending();
2609 assert_eq!(resumed.len(), 2);
2610 for job in &resumed {
2611 assert_eq!(job.status, JobStatus::Queued);
2612 }
2613 }
2614
2615 #[test]
2618 fn deterministic_backoff_zero_on_first_attempt() {
2619 let retry = JobRetryMetadata {
2620 attempt: 0,
2621 ..Default::default()
2622 };
2623 assert_eq!(JobManager::deterministic_backoff_ms(&retry), 0);
2624 }
2625
2626 #[test]
2627 fn deterministic_backoff_exponential_growth() {
2628 let base = DEFAULT_JOB_BACKOFF_BASE_MS;
2629 for attempt in 1..=5 {
2630 let retry = JobRetryMetadata {
2631 attempt,
2632 backoff_base_ms: base,
2633 ..Default::default()
2634 };
2635 let expected = base * 2u64.pow(attempt.saturating_sub(1).min(20));
2636 assert_eq!(
2637 JobManager::deterministic_backoff_ms(&retry),
2638 expected,
2639 "attempt {attempt}"
2640 );
2641 }
2642 }
2643
2644 #[test]
2645 fn deterministic_backoff_saturates_at_high_exponent() {
2646 let retry = JobRetryMetadata {
2647 attempt: 63,
2648 backoff_base_ms: 1000,
2649 ..Default::default()
2650 };
2651 let _ = JobManager::deterministic_backoff_ms(&retry);
2653 }
2654
2655 #[test]
2658 fn push_history_truncates_beyond_max() {
2659 let mut jm = JobManager::default();
2660 let job = jm.enqueue("task");
2661 let id = job.id.clone();
2662 for i in 0..(MAX_JOB_HISTORY_ENTRIES + 20) {
2664 jm.update_progress(&id, (i % 100) as u8, Some(format!("step {i}")));
2665 }
2666 let history = jm.history(&id);
2667 assert_eq!(history.len(), MAX_JOB_HISTORY_ENTRIES);
2668 }
2669
2670 #[test]
2673 fn encode_and_parse_persisted_detail_round_trip() {
2674 let mut jm = JobManager::default();
2675 let job = jm.enqueue("task");
2676 let id = job.id.clone();
2677 jm.set_running(&id);
2678 jm.fail(&id, "oops");
2679 let job = jm.list().into_iter().find(|j| j.id == id).unwrap();
2680
2681 let encoded = JobManager::encode_persisted_detail(&job).unwrap().unwrap();
2682 let parsed = JobManager::parse_persisted_detail(Some(&encoded)).unwrap();
2683
2684 assert_eq!(parsed.status, job.status);
2685 assert_eq!(parsed.detail, job.detail);
2686 assert_eq!(parsed.retry.attempt, job.retry.attempt);
2687 assert_eq!(parsed.history.len(), job.history.len());
2688 }
2689
2690 #[test]
2691 fn parse_persisted_detail_returns_none_for_none_input() {
2692 assert!(JobManager::parse_persisted_detail(None).is_none());
2693 }
2694
2695 #[test]
2696 fn parse_persisted_detail_returns_none_for_invalid_json() {
2697 assert!(JobManager::parse_persisted_detail(Some("not json")).is_none());
2698 }
2699
2700 #[test]
2703 fn job_status_round_trip_str() {
2704 let statuses = [
2705 JobStatus::Queued,
2706 JobStatus::Running,
2707 JobStatus::Paused,
2708 JobStatus::Completed,
2709 JobStatus::Failed,
2710 JobStatus::Cancelled,
2711 ];
2712 for status in &statuses {
2713 let s = job_status_to_str(*status);
2714 let parsed = job_status_from_str(s);
2715 assert_eq!(parsed, Some(*status), "round-trip failed for {s:?}");
2716 }
2717 }
2718
2719 #[test]
2720 fn job_status_from_str_returns_none_for_unknown() {
2721 assert_eq!(job_status_from_str("unknown"), None);
2722 assert_eq!(job_status_from_str(""), None);
2723 }
2724
2725 #[test]
2726 fn truncate_preview_limits_to_120_chars() {
2727 let long = "a".repeat(200);
2728 let truncated = truncate_preview(&long);
2729 assert_eq!(truncated.len(), 120);
2730 }
2731
2732 #[test]
2733 fn truncate_preview_preserves_short_strings() {
2734 let short = "hello";
2735 assert_eq!(truncate_preview(short), "hello");
2736 }
2737
2738 #[test]
2739 fn runtime_status_to_job_state_maps_correctly() {
2740 assert_eq!(
2741 runtime_status_to_job_state(JobStatus::Queued),
2742 JobStateStatus::Queued
2743 );
2744 assert_eq!(
2745 runtime_status_to_job_state(JobStatus::Running),
2746 JobStateStatus::Running
2747 );
2748 assert_eq!(
2749 runtime_status_to_job_state(JobStatus::Paused),
2750 JobStateStatus::Paused
2751 );
2752 assert_eq!(
2753 runtime_status_to_job_state(JobStatus::Completed),
2754 JobStateStatus::Completed
2755 );
2756 assert_eq!(
2757 runtime_status_to_job_state(JobStatus::Failed),
2758 JobStateStatus::Failed
2759 );
2760 assert_eq!(
2761 runtime_status_to_job_state(JobStatus::Cancelled),
2762 JobStateStatus::Cancelled
2763 );
2764 }
2765
2766 #[test]
2767 fn job_state_status_to_runtime_maps_correctly() {
2768 assert_eq!(
2769 job_state_status_to_runtime(JobStateStatus::Queued),
2770 JobStatus::Queued
2771 );
2772 assert_eq!(
2773 job_state_status_to_runtime(JobStateStatus::Running),
2774 JobStatus::Running
2775 );
2776 assert_eq!(
2777 job_state_status_to_runtime(JobStateStatus::Paused),
2778 JobStatus::Paused
2779 );
2780 assert_eq!(
2781 job_state_status_to_runtime(JobStateStatus::Completed),
2782 JobStatus::Completed
2783 );
2784 assert_eq!(
2785 job_state_status_to_runtime(JobStateStatus::Failed),
2786 JobStatus::Failed
2787 );
2788 assert_eq!(
2789 job_state_status_to_runtime(JobStateStatus::Cancelled),
2790 JobStatus::Cancelled
2791 );
2792 }
2793
2794 #[test]
2795 fn preview_from_initial_history_new() {
2796 let preview = preview_from_initial_history(&InitialHistory::New);
2797 assert_eq!(preview, "New conversation");
2798 }
2799
2800 #[test]
2801 fn preview_from_initial_history_forked() {
2802 let preview = preview_from_initial_history(&InitialHistory::Forked(vec![json!("hello")]));
2803 assert!(preview.contains("hello"));
2804 }
2805
2806 #[test]
2807 fn preview_from_initial_history_resumed() {
2808 let preview = preview_from_initial_history(&InitialHistory::Resumed {
2809 conversation_id: "test".to_string(),
2810 history: vec![json!("world")],
2811 rollout_path: PathBuf::from("/tmp/test"),
2812 });
2813 assert!(preview.contains("world"));
2814 }
2815
2816 #[test]
2817 fn json_optional_string_handles_null() {
2818 assert!(json_optional_string(&Value::Null).is_none());
2819 }
2820
2821 #[test]
2822 fn json_optional_string_handles_string() {
2823 assert_eq!(
2824 json_optional_string(&Value::String("hello".to_string())),
2825 Some("hello".to_string())
2826 );
2827 }
2828
2829 #[test]
2830 fn json_optional_string_handles_non_string() {
2831 assert!(json_optional_string(&json!(42)).is_none());
2832 }
2833
2834 #[test]
2835 fn parse_retry_metadata_returns_default_for_none() {
2836 let retry = parse_retry_metadata(None);
2837 assert_eq!(retry.attempt, 0);
2838 assert_eq!(retry.max_attempts, DEFAULT_JOB_MAX_ATTEMPTS);
2839 assert_eq!(retry.backoff_base_ms, DEFAULT_JOB_BACKOFF_BASE_MS);
2840 }
2841
2842 #[test]
2843 fn parse_retry_metadata_parses_fields() {
2844 let value = json!({
2845 "attempt": 2,
2846 "max_attempts": 5,
2847 "backoff_base_ms": 1000,
2848 "next_backoff_ms": 2000,
2849 "next_retry_at": 1234567890i64
2850 });
2851 let retry = parse_retry_metadata(Some(&value));
2852 assert_eq!(retry.attempt, 2);
2853 assert_eq!(retry.max_attempts, 5);
2854 assert_eq!(retry.backoff_base_ms, 1000);
2855 assert_eq!(retry.next_backoff_ms, 2000);
2856 assert_eq!(retry.next_retry_at, Some(1234567890));
2857 }
2858
2859 #[test]
2860 fn parse_history_entry_returns_none_without_status() {
2861 let value = json!({"at": 1, "phase": "test"});
2862 assert!(parse_history_entry(&value).is_none());
2863 }
2864
2865 #[test]
2866 fn parse_history_entry_parses_valid_entry() {
2867 let value = json!({
2868 "at": 100,
2869 "phase": "running",
2870 "status": "running",
2871 "progress": 50,
2872 "detail": "working",
2873 "retry": {"attempt": 0, "max_attempts": 3, "backoff_base_ms": 500}
2874 });
2875 let entry = parse_history_entry(&value).unwrap();
2876 assert_eq!(entry.at, 100);
2877 assert_eq!(entry.phase, "running");
2878 assert_eq!(entry.status, JobStatus::Running);
2879 assert_eq!(entry.progress, Some(50));
2880 assert_eq!(entry.detail.as_deref(), Some("working"));
2881 }
2882
2883 #[test]
2884 fn paused_job_persists_as_paused_not_running() {
2885 let store = temp_core_state("paused-persist");
2886 let mut jm = JobManager::default();
2887 let job = jm.enqueue("task");
2888 let id = job.id.clone();
2889 jm.set_running(&id);
2890 jm.pause(&id, Some("waiting".to_string()));
2891 jm.persist_job(&store, &id).expect("persist paused job");
2892
2893 let persisted = store.list_jobs(Some(10)).expect("list jobs");
2894 let record = persisted.iter().find(|job| job.id == id).unwrap();
2895 assert_eq!(record.status, JobStateStatus::Paused);
2896
2897 let mut reloaded = JobManager::default();
2898 reloaded.load_from_store(&store).expect("reload jobs");
2899 let jobs = reloaded.list();
2900 let reloaded_job = jobs.iter().find(|job| job.id == id).unwrap();
2901 assert_eq!(reloaded_job.status, JobStatus::Paused);
2902 }
2903
2904 #[test]
2905 fn unarchive_thread_updates_running_threads_cache() {
2906 let store = temp_core_state("unarchive-cache");
2907 let mut manager = ThreadManager::new(store);
2908 let spawned = manager
2909 .spawn_thread_with_history(
2910 "deepseek".to_string(),
2911 PathBuf::from("/tmp/codewhale"),
2912 InitialHistory::New,
2913 true,
2914 )
2915 .expect("spawn thread");
2916 let thread_id = spawned.thread.id.clone();
2917 let resume_params = ThreadResumeParams {
2918 thread_id: thread_id.clone(),
2919 history: None,
2920 path: None,
2921 model: None,
2922 model_provider: None,
2923 cwd: None,
2924 approval_policy: None,
2925 sandbox: None,
2926 config: None,
2927 base_instructions: None,
2928 developer_instructions: None,
2929 personality: None,
2930 persist_extended_history: false,
2931 };
2932
2933 manager.archive_thread(&thread_id).expect("archive thread");
2934 let archived = manager
2935 .resume_thread_with_history(
2936 &resume_params,
2937 Path::new("/tmp/codewhale"),
2938 "deepseek".to_string(),
2939 )
2940 .expect("resume archived thread")
2941 .expect("thread in cache");
2942 assert_eq!(archived.thread.status, ThreadStatus::Archived);
2943
2944 manager
2945 .unarchive_thread(&thread_id)
2946 .expect("unarchive thread");
2947 let restored = manager
2948 .resume_thread_with_history(
2949 &resume_params,
2950 Path::new("/tmp/codewhale"),
2951 "deepseek".to_string(),
2952 )
2953 .expect("resume unarchived thread")
2954 .expect("thread in cache");
2955 assert_eq!(restored.thread.status, ThreadStatus::Idle);
2956 }
2957
2958 #[tokio::test]
2959 async fn invoke_tool_returns_timeout_status_for_slow_tools() {
2960 use async_trait::async_trait;
2961 use codewhale_agent::ModelRegistry;
2962 use codewhale_config::ConfigToml;
2963 use codewhale_execpolicy::{AskForApproval, ExecPolicyEngine};
2964 use codewhale_hooks::HookDispatcher;
2965 use codewhale_mcp::McpManager;
2966 use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
2967 use codewhale_tools::{FunctionCallError, ToolDescriptor, ToolHandler, ToolInvocation};
2968
2969 struct SlowTool;
2970 #[async_trait]
2971 impl ToolHandler for SlowTool {
2972 fn kind(&self) -> ToolKind {
2973 ToolKind::Function
2974 }
2975
2976 async fn handle(
2977 &self,
2978 _invocation: ToolInvocation,
2979 ) -> std::result::Result<ToolOutput, FunctionCallError> {
2980 time::sleep(Duration::from_millis(200)).await;
2981 Ok(ToolOutput::Function {
2982 body: Some(json!("late")),
2983 success: true,
2984 })
2985 }
2986 }
2987
2988 let mut registry = ToolRegistry::default();
2989 registry
2990 .register(
2991 ToolDescriptor {
2992 name: "slow_tool".to_string(),
2993 input_schema: json!({"type":"object"}),
2994 output_schema: json!({"type":"object"}),
2995 supports_parallel_tool_calls: true,
2996 timeout_ms: None,
2997 },
2998 Arc::new(SlowTool),
2999 )
3000 .expect("register slow tool");
3001
3002 let runtime = Runtime::new(
3003 ConfigToml::default(),
3004 ModelRegistry::default(),
3005 temp_core_state("invoke-tool-timeout"),
3006 Arc::new(registry),
3007 Arc::new(McpManager::default()),
3008 ExecPolicyEngine::new(vec![], vec![]),
3009 HookDispatcher::default(),
3010 );
3011
3012 let result = runtime
3013 .invoke_tool(
3014 ToolCall {
3015 name: "slow_tool".to_string(),
3016 payload: ToolPayload::Function {
3017 arguments: "{}".to_string(),
3018 },
3019 source: ToolCallSource::Direct,
3020 raw_tool_call_id: None,
3021 },
3022 AskForApproval::Never,
3023 Path::new("/tmp/codewhale"),
3024 )
3025 .await
3026 .expect("invoke tool");
3027
3028 assert_eq!(result["status"], "timeout");
3029 assert_eq!(result["ok"], false);
3030 }
3031}