Skip to main content

codewhale_core/
lib.rs

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