Skip to main content

codewhale_core/
lib.rs

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
35/// Per-tool dispatch budget for the headless runtime. Matches the generous
36/// subagent default so long-running tools are not cut off prematurely.
37fn tool_dispatch_timeout() -> Duration {
38    if cfg!(test) {
39        Duration::from_millis(50)
40    } else {
41        Duration::from_secs(300)
42    }
43}
44
45/// How a new thread's conversation history is initialized.
46#[derive(Debug, Clone)]
47pub enum InitialHistory {
48    /// Start with an empty conversation.
49    New,
50    /// Forked from an existing thread with the given history items.
51    Forked(Vec<Value>),
52    /// Resumed from a persisted thread with its full history.
53    Resumed {
54        conversation_id: String,
55        history: Vec<Value>,
56        rollout_path: PathBuf,
57    },
58}
59
60/// Result of spawning or resuming a thread.
61#[derive(Debug, Clone)]
62pub struct NewThread {
63    /// The thread metadata.
64    pub thread: Thread,
65    /// Resolved model identifier.
66    pub model: String,
67    /// Provider that serves the model.
68    pub model_provider: String,
69    /// Working directory for the thread.
70    pub cwd: PathBuf,
71    /// Approval policy override, if any.
72    pub approval_policy: Option<String>,
73    /// Sandbox mode override, if any.
74    pub sandbox: Option<String>,
75}
76
77/// Status of a background job.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum JobStatus {
80    /// Waiting to be picked up.
81    Queued,
82    /// Currently executing.
83    Running,
84    /// Temporarily paused.
85    Paused,
86    /// Finished successfully.
87    Completed,
88    /// Finished with an error.
89    Failed,
90    /// Cancelled by the user.
91    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/// Retry state for a job that failed and may be retried.
112#[derive(Debug, Clone)]
113pub struct JobRetryMetadata {
114    /// Current attempt number (0 = not yet retried).
115    pub attempt: u32,
116    /// Maximum number of retry attempts before giving up.
117    pub max_attempts: u32,
118    /// Base delay in milliseconds for exponential backoff.
119    pub backoff_base_ms: u64,
120    /// Computed delay in milliseconds until the next retry.
121    pub next_backoff_ms: u64,
122    /// Timestamp when the next retry should be attempted.
123    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/// A single entry in a job's history log.
139#[derive(Debug, Clone)]
140pub struct JobHistoryEntry {
141    /// Timestamp when this entry was recorded.
142    pub at: i64,
143    /// Phase name (e.g., "created", "running", "failed").
144    pub phase: String,
145    /// Job status at this point in time.
146    pub status: JobStatus,
147    /// Progress percentage at this point, if available.
148    pub progress: Option<u8>,
149    /// Human-readable detail message.
150    pub detail: Option<String>,
151    /// Retry state snapshot at this point.
152    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/// A complete job record with all metadata and history.
164#[derive(Debug, Clone)]
165pub struct JobRecord {
166    /// Unique job identifier.
167    pub id: String,
168    /// Human-readable job name.
169    pub name: String,
170    /// Current job status.
171    pub status: JobStatus,
172    /// Current progress percentage (0-100).
173    pub progress: Option<u8>,
174    /// Human-readable detail about the current state.
175    pub detail: Option<String>,
176    /// Retry state for failed jobs.
177    pub retry: JobRetryMetadata,
178    /// Chronological history of state transitions.
179    pub history: Vec<JobHistoryEntry>,
180    /// Timestamp when the job was created.
181    pub created_at: i64,
182    /// Timestamp of the last state change.
183    pub updated_at: i64,
184}
185
186/// Manages background jobs with retry logic and persistence.
187#[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    /// Enqueues a new job and returns its record.
266    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    /// Transitions a job to running and clears its retry schedule.
286    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    /// Updates a job's progress (clamped to 100) and optional detail message.
296    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    /// Marks a job as completed with 100% progress and clears its retry schedule.
306    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    /// Marks a job as failed and schedules a retry if attempts remain.
317    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    /// Cancels a job and clears any pending retry schedule.
337    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    /// Pauses a job, optionally updating its detail message.
347    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    /// Resumes a paused or failed job back to running status.
359    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    /// Returns all jobs sorted by most recently updated first.
372    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    /// Returns the history entries for a job, or an empty vec if not found.
379    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    /// Resets queued or running jobs back to queued on application resume.
387    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    /// Loads jobs from the state store, deserializing extended detail when available.
401    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    /// Persists a single job's current state to the state store.
440    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    /// Persists all in-memory jobs to the state store.
457    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
465/// Manages thread lifecycle: spawn, resume, fork, archive, and persistence.
466pub struct ThreadManager {
467    store: StateStore,
468    running_threads: HashMap<String, Thread>,
469    cli_version: String,
470}
471
472impl ThreadManager {
473    /// Creates a new `ThreadManager` backed by the given state store.
474    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    /// Returns a reference to the underlying state store.
483    pub fn state_store(&self) -> &StateStore {
484        &self.store
485    }
486
487    /// Spawns a new thread with the given initial history and persists it.
488    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    /// Resumes an existing thread, returning `None` if not found.
560    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(&params.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(&params.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    /// Forks an existing thread into a new one, inheriting the parent's provider.
615    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(&params.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    /// Lists threads matching the given filter parameters.
644    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    /// Reads a single thread by id, or `None` if not found.
653    pub fn read_thread(&self, params: &ThreadReadParams) -> Result<Option<Thread>> {
654        Ok(self
655            .store
656            .get_thread(&params.thread_id)?
657            .map(to_protocol_thread))
658    }
659
660    /// Sets the display name for a thread, returning the updated thread or `None`.
661    pub fn set_thread_name(&mut self, params: &ThreadSetNameParams) -> Result<Option<Thread>> {
662        let Some(mut metadata) = self.store.get_thread(&params.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    /// Sets or replaces the persisted goal for a thread.
675    pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
676        if self.store.get_thread(&params.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    /// Reads the persisted goal for a thread.
697    pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result<Option<ThreadGoal>> {
698        Ok(self
699            .store
700            .get_thread_goal(&params.thread_id)?
701            .map(to_protocol_goal))
702    }
703
704    /// Accrues durable per-goal usage and/or a continuation pass for a thread.
705    pub fn record_thread_goal_progress(
706        &mut self,
707        params: &ThreadGoalProgressParams,
708    ) -> Result<Option<ThreadGoal>> {
709        if self.store.get_thread(&params.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                &params.thread_id,
717                params.token_delta,
718                params.time_delta_seconds,
719                now,
720            )?
721        } else {
722            self.store.get_thread_goal(&params.thread_id)?
723        };
724
725        if params.record_continuation {
726            goal = self
727                .store
728                .record_thread_goal_continuation(&params.thread_id, now)?;
729        }
730
731        Ok(goal.map(to_protocol_goal))
732    }
733
734    /// Clears the persisted goal for a thread, returning whether one existed.
735    pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result<bool> {
736        self.store.delete_thread_goal(&params.thread_id)
737    }
738
739    /// Archives a thread so it no longer appears in default listings.
740    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    /// Restores an archived thread to active status.
749    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    /// Records a user message in a thread and updates its preview and timestamp.
761    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
817/// Top-level runtime combining config, model registry, threads, tools, MCP, and hooks.
818pub struct Runtime {
819    /// Resolved application configuration.
820    pub config: ConfigToml,
821    /// Registry of available model providers.
822    pub model_registry: ModelRegistry,
823    /// Manages conversation thread lifecycle.
824    pub thread_manager: ThreadManager,
825    /// Registry of callable tools.
826    pub tool_registry: Arc<ToolRegistry>,
827    /// Manager for MCP server connections.
828    pub mcp_manager: Arc<McpManager>,
829    /// Engine for evaluating execution policy decisions.
830    pub exec_policy: ExecPolicyEngine,
831    /// Dispatcher for lifecycle hooks.
832    pub hooks: HookDispatcher,
833    /// Manager for background job lifecycle.
834    pub jobs: JobManager,
835}
836
837impl Runtime {
838    /// Constructs a new `Runtime`, loading existing jobs from the state store.
839    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    /// Update the live configuration in-place so the next turn picks up
865    /// changes without a restart.  Called by the app-server after
866    /// `ConfigSet` or `ConfigUnset`.
867    ///
868    /// Only `config.toml` is touched by those operations, so the sibling
869    /// `permissions.toml` (and therefore `exec_policy`) is left unchanged.
870    ///
871    /// Fields that the TUI caches on its `App` struct (`api_provider`,
872    /// `reasoning_effort`, `mcp_config_path`, `skills_dir`, …) are read
873    /// live from `self.config` here via `resolve_runtime_options`, so they
874    /// take effect on the next prompt turn without any extra plumbing.
875    pub fn update_config(&mut self, config: ConfigToml) {
876        self.config = config;
877    }
878
879    /// Reload the live configuration **and** the exec policy from a
880    /// freshly-loaded `ConfigStore`.  Used by the app-server's
881    /// `ConfigReload` request, which re-reads both `config.toml` and the
882    /// sibling `permissions.toml` from disk.
883    ///
884    /// Unlike `update_config`, this also refreshes `self.exec_policy` so
885    /// externally edited permission rules take effect without a restart.
886    ///
887    /// Mirrors the TUI `reload_runtime_config` codepath for everything
888    /// that is reachable from the headless `Runtime`. The TUI-only caches
889    /// (`last_effective_reasoning_effort`, `model_compaction_budget`,
890    /// `ui_locale`, …) do not exist on `Runtime` and need no work here.
891    ///
892    /// **Not** refreshed by this call:
893    /// * `mcp_manager` — MCP server connections are loaded once at
894    ///   startup from `mcp_config_path`. Changing `mcp_config_path` or the
895    ///   referenced `mcp.json` still requires a restart, exactly as the
896    ///   TUI flags via `mcp_restart_required`.
897    /// * `tool_registry` — built once at startup.
898    /// * `model_registry` — static catalog.
899    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    /// Dispatches a thread request (create, start, resume, fork, list, read, etc.).
959    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                    &params,
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(&params, &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(&params)?,
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(&params)?,
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(&params)?,
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(&params)? {
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(&params)?;
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(&params)?;
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(&params)? {
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    /// Resolves the model for a prompt, records the message, and returns the response.
1272    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    /// Evaluates execution policy and dispatches a tool call.
1350    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        // Headless `request_user_input`: mirror the approval fire-and-return
1457        // branch (issue #3102). The TUI intercepts this tool by name before
1458        // dispatch and blocks on a reply channel; the headless runtime instead
1459        // emits a typed `UserInputRequest` frame and returns a
1460        // `user_input_required` status so the client can render the question
1461        // and POST answers back via `AppRequest::SubmitUserInput`. It does NOT
1462        // block — consistent with the headless approval model, which has no
1463        // resume channel either.
1464        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                // Custom/Mcp/LocalShell can't carry a user_input payload; fall
1469                // through to the generic dispatch error below.
1470                _ => "",
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    /// Starts all configured MCP servers and emits startup events via hooks.
1627    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    /// Returns the current application status including all jobs and their history.
1676    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    /// Returns the default model provider from the resolved configuration.
1718    pub fn provider_default(&self) -> ProviderKind {
1719        self.config.provider
1720    }
1721
1722    /// Saves a named checkpoint for a thread.
1723    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    /// Loads a checkpoint for a thread. Pass `None` for the latest.
1735    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    /// Enqueues a new background job and persists it immediately.
1748    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    /// Transitions a job to running and persists the change.
1756    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    /// Updates a job's progress and persists the change.
1763    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    /// Marks a job as completed and persists the change.
1775    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    /// Marks a job as failed and persists the change.
1782    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    /// Cancels a job and persists the change.
1789    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    /// Pauses a job and persists the change.
1796    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    /// Resumes a paused job and persists the change.
1803    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    /// Returns the state-transition history for a job.
1810    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
2007/// Build an [`EventFrame::UserInputRequest`] for a headless
2008/// `request_user_input` tool call, mirroring [`approval_request_frame`].
2009///
2010/// `arguments` is the raw JSON arguments string the model supplied to the
2011/// `request_user_input` tool (a `ToolPayload::Function` body). On parse
2012/// failure we return `None` so the caller falls through to the generic tool
2013/// error path rather than silently dropping the request.
2014fn 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    // Extract the `questions` array and lift it into the headless event
2022    // shape. We tolerate missing `allow_free_text`/`multi_select` (default
2023    // false) and extra fields, matching the lenient TUI `from_value` path.
2024    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
2104/// Tool name that triggers the headless clarification-question flow.
2105///
2106/// Mirrors the TUI's `REQUEST_USER_INPUT_NAME`
2107/// (`crates/tui/src/core/engine/tool_catalog.rs`); duplicated here rather than
2108/// depended on across crates so `core` stays free of `tui` imports.
2109const 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    // ── JobManager: lifecycle ──────────────────────────────────────────
2275
2276    #[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        // issue #3102: the headless frame constructor must parse the model's
2405        // `request_user_input` arguments and lift the questions into the
2406        // UserInputRequestEvent, defaulting the boolean flags when omitted.
2407        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        // multi_select omitted in the payload → defaults to false.
2426        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        // On parse failure the constructor returns None so invoke_tool falls
2433        // through to the generic tool error path instead of silently dropping.
2434        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        // Valid JSON but missing the questions array is also rejected.
2443        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); // created, running, completed
2585        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    // ── JobManager: backoff ────────────────────────────────────────────
2616
2617    #[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        // Should not panic; result saturates
2652        let _ = JobManager::deterministic_backoff_ms(&retry);
2653    }
2654
2655    // ── JobManager: history truncation ─────────────────────────────────
2656
2657    #[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        // Generate more history entries than the limit
2663        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    // ── JobManager: persistence encoding/parsing ───────────────────────
2671
2672    #[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    // ── Helper functions ───────────────────────────────────────────────
2701
2702    #[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}